mirror of
https://github.com/larksuite/cli.git
synced 2026-07-09 18:34:03 +08:00
Compare commits
5 Commits
feat/slide
...
feat/drive
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
faa7a92217 | ||
|
|
40ea4d60ef | ||
|
|
f0b6f35fee | ||
|
|
91d785f92f | ||
|
|
e621c6e50f |
@@ -65,13 +65,13 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
return cmd
|
||||
}
|
||||
|
||||
// completeSchemaPath is a thin adapter over the embedded catalog's Complete.
|
||||
// It uses the embedded source so completion candidates match what `schema`
|
||||
// execution can resolve (both overlay-free).
|
||||
// completeSchemaPath is a thin adapter over the schema catalog's Complete.
|
||||
// It uses the same source as schema execution so completion candidates match
|
||||
// what `schema` can resolve.
|
||||
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
||||
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
mode := f.ResolveStrictMode(cmd.Context())
|
||||
completions, noSpace := registry.EmbeddedCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
|
||||
completions, noSpace := registry.SchemaCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
|
||||
directive := cobra.ShellCompDirectiveNoFileComp
|
||||
if noSpace {
|
||||
directive |= cobra.ShellCompDirectiveNoSpace
|
||||
@@ -86,13 +86,19 @@ func schemaRun(opts *SchemaOptions) error {
|
||||
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
|
||||
}
|
||||
|
||||
// runSchema resolves the path through the embedded catalog and renders the
|
||||
// runSchema resolves the path through the schema catalog and renders the
|
||||
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
|
||||
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
|
||||
// output shape — a single resolved method renders as one envelope object,
|
||||
// anything broader as an array — and maps resolve failures to hints.
|
||||
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||
catalog := registry.EmbeddedCatalog()
|
||||
catalog := registry.SchemaCatalog()
|
||||
if len(catalog.Services()) == 0 {
|
||||
// No embedded metadata and the runtime fallback is empty too: offline
|
||||
// with a cold cache, remote meta off, or an unwritable cache dir.
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "No API metadata available").
|
||||
WithHint("this binary has no embedded API metadata; run any command with network access to the open platform once so metadata can be fetched and cached")
|
||||
}
|
||||
target, err := catalog.Resolve(parts)
|
||||
if err != nil {
|
||||
return resolveError(err)
|
||||
|
||||
@@ -102,7 +102,8 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
||||
Long: `Update lark-cli to the latest version.
|
||||
|
||||
Detects the installation method automatically:
|
||||
- npm install: runs npm install -g @larksuite/cli@<version>
|
||||
- npm install: runs npm install -g @larksuite/cli@<version>
|
||||
- pnpm install: runs pnpm add -g @larksuite/cli@<version>
|
||||
- manual/other: shows GitHub Releases download URL
|
||||
|
||||
Use --json for structured output (for AI agents and scripts).
|
||||
@@ -164,7 +165,7 @@ func updateRun(opts *UpdateOptions) error {
|
||||
if !detect.CanAutoUpdate() {
|
||||
return doManualUpdate(opts, io, cur, latest, detect, updater)
|
||||
}
|
||||
return doNpmUpdate(opts, io, cur, latest, updater)
|
||||
return doAutoUpdate(opts, io, cur, latest, detect, updater)
|
||||
}
|
||||
|
||||
// --- Output helpers ---
|
||||
@@ -226,12 +227,23 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
|
||||
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
|
||||
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
|
||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||
if detect.Method == selfupdate.InstallPnpm {
|
||||
fmt.Fprintf(io.ErrOut, "\nOr install via pnpm (note: skills will not be synced):\n pnpm add -g %s@%s\n pnpm dlx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||
} else {
|
||||
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||
}
|
||||
emitSkillsTextHints(io, skillsResult)
|
||||
return nil
|
||||
}
|
||||
|
||||
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
|
||||
func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
|
||||
pm := "npm"
|
||||
install := updater.RunNpmInstall
|
||||
if detect.Method == selfupdate.InstallPnpm {
|
||||
pm = "pnpm"
|
||||
install = updater.RunPnpmInstall
|
||||
}
|
||||
|
||||
restore, err := updater.PrepareSelfReplace()
|
||||
if err != nil {
|
||||
return reportError(opts, io, "update_error",
|
||||
@@ -239,19 +251,19 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
||||
}
|
||||
|
||||
if !opts.JSON {
|
||||
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via npm ...\n", cur, symArrow(), latest)
|
||||
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via %s ...\n", cur, symArrow(), latest, pm)
|
||||
}
|
||||
|
||||
npmResult := updater.RunNpmInstall(latest)
|
||||
npmResult := install(latest)
|
||||
if npmResult.Err != nil {
|
||||
restore()
|
||||
combined := npmResult.CombinedOutput()
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": false, "error": map[string]interface{}{
|
||||
"type": "update_error", "message": fmt.Sprintf("npm install failed: %s", npmResult.Err),
|
||||
"type": "update_error", "message": fmt.Sprintf("%s install failed: %s", pm, npmResult.Err),
|
||||
"detail": selfupdate.Truncate(combined, maxNpmOutput),
|
||||
"hint": permissionHint(combined),
|
||||
"hint": permissionHint(combined, pm),
|
||||
},
|
||||
})
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
@@ -263,7 +275,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
||||
fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
|
||||
if hint := permissionHint(combined); hint != "" {
|
||||
if hint := permissionHint(combined, pm); hint != "" {
|
||||
fmt.Fprintf(io.ErrOut, " %s\n", hint)
|
||||
}
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
@@ -274,7 +286,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
||||
if err := updater.VerifyBinary(latest); err != nil {
|
||||
restore()
|
||||
msg := fmt.Sprintf("new binary verification failed: %s", err)
|
||||
hint := verificationFailureHint(updater, latest)
|
||||
hint := verificationFailureHint(updater, latest, pm)
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": false,
|
||||
@@ -304,23 +316,33 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
||||
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
|
||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||
if skillsResult != nil {
|
||||
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n")
|
||||
skillsPM := "npx"
|
||||
if detect.Method == selfupdate.InstallPnpm && detect.PnpmAvailable {
|
||||
skillsPM = "pnpm dlx"
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "\nUpdating skills via %s ...\n", skillsPM)
|
||||
}
|
||||
emitSkillsTextHints(io, skillsResult)
|
||||
return nil
|
||||
}
|
||||
|
||||
func permissionHint(npmOutput string) string {
|
||||
if strings.Contains(npmOutput, "EACCES") && !isWindows() {
|
||||
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
|
||||
func permissionHint(pmOutput, pm string) string {
|
||||
if !strings.Contains(pmOutput, "EACCES") || isWindows() {
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
if pm == "pnpm" {
|
||||
return "Permission denied. Ensure your pnpm global directory is writable — re-run `pnpm setup`, or see https://pnpm.io/pnpm-cli"
|
||||
}
|
||||
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
|
||||
}
|
||||
|
||||
func verificationFailureHint(updater *selfupdate.Updater, latest string) string {
|
||||
func verificationFailureHint(updater *selfupdate.Updater, latest, pm string) string {
|
||||
if updater.CanRestorePreviousVersion() {
|
||||
return "the previous version has been restored"
|
||||
}
|
||||
if pm == "pnpm" {
|
||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): pnpm add -g %s@%s && pnpm dlx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||
}
|
||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,27 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
}
|
||||
|
||||
// mockDetectAndPnpm mirrors mockDetectAndNpm but wires the pnpm install path
|
||||
// and fails the test if the npm install path is invoked.
|
||||
func mockDetectAndPnpm(t *testing.T, result selfupdate.DetectResult, pnpmFn func(string) *selfupdate.NpmResult) {
|
||||
t.Helper()
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||
u.PnpmInstallOverride = pnpmFn
|
||||
u.NpmInstallOverride = func(string) *selfupdate.NpmResult {
|
||||
t.Errorf("npm install must not be called for a pnpm install")
|
||||
return &selfupdate.NpmResult{}
|
||||
}
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
|
||||
u.SkillsCommandOverride = successfulSkillsCommand()
|
||||
return u
|
||||
}
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
}
|
||||
|
||||
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
|
||||
return func() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
@@ -81,6 +102,110 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePnpm_JSON(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndPnpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
||||
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if out := stdout.String(); !strings.Contains(out, `"action": "updated"`) {
|
||||
t.Errorf("expected updated in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePnpm_Human(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndPnpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
||||
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "via pnpm") {
|
||||
t.Errorf("expected 'via pnpm' in stderr, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Updating skills via pnpm dlx ...") {
|
||||
t.Errorf("expected skills sync to report pnpm dlx launcher, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Successfully updated") {
|
||||
t.Errorf("expected success message, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePnpm_InstallError_JSON(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndPnpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
||||
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{Err: errors.New("pnpm boom")} },
|
||||
)
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error exit")
|
||||
}
|
||||
if out := stdout.String(); !strings.Contains(out, `"ok": false`) || !strings.Contains(out, "update_error") {
|
||||
t.Errorf("expected failure envelope, got: %s", out)
|
||||
}
|
||||
if out := stdout.String(); !strings.Contains(out, "pnpm install failed") {
|
||||
t.Errorf("expected message to report pnpm as the package manager, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePnpm_Unavailable_ManualFallback(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: false})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "installed via pnpm, but pnpm is not available in PATH") {
|
||||
t.Errorf("expected pnpm manual reason, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "pnpm add -g") {
|
||||
t.Errorf("expected pnpm add -g hint, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
@@ -266,6 +391,9 @@ func TestUpdateNpm_Human(t *testing.T) {
|
||||
if !strings.Contains(out, "Successfully updated") {
|
||||
t.Errorf("expected success message in stderr, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Updating skills via npx ...") {
|
||||
t.Errorf("expected skills sync to report npx launcher for npm install, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateForce_JSON(t *testing.T) {
|
||||
@@ -739,9 +867,9 @@ func TestPermissionHint(t *testing.T) {
|
||||
origOS := currentOS
|
||||
defer func() { currentOS = origOS }()
|
||||
|
||||
// Linux: EACCES should produce a hint with npm prefix guidance.
|
||||
// Linux + npm: EACCES should produce a hint with npm prefix guidance.
|
||||
currentOS = "linux"
|
||||
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'")
|
||||
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'", "npm")
|
||||
if !strings.Contains(hint, "npm global prefix") {
|
||||
t.Errorf("expected npm prefix hint on linux, got: %s", hint)
|
||||
}
|
||||
@@ -749,16 +877,25 @@ func TestPermissionHint(t *testing.T) {
|
||||
t.Errorf("should not suggest raw sudo npm install, got: %s", hint)
|
||||
}
|
||||
|
||||
// Linux + pnpm: EACCES should point at pnpm setup, not npm prefix/sudo.
|
||||
pnpmHint := permissionHint("EACCES: permission denied, access '/Users/x/Library/pnpm'", "pnpm")
|
||||
if !strings.Contains(pnpmHint, "pnpm setup") {
|
||||
t.Errorf("expected pnpm setup hint, got: %s", pnpmHint)
|
||||
}
|
||||
if strings.Contains(pnpmHint, "npm global prefix") || strings.Contains(pnpmHint, "sudo") {
|
||||
t.Errorf("pnpm hint must not reference npm prefix or sudo, got: %s", pnpmHint)
|
||||
}
|
||||
|
||||
// Windows: EACCES hint is suppressed (no EACCES on Windows).
|
||||
currentOS = "windows"
|
||||
hint = permissionHint("EACCES: permission denied")
|
||||
hint = permissionHint("EACCES: permission denied", "npm")
|
||||
if hint != "" {
|
||||
t.Errorf("expected empty hint on Windows, got: %s", hint)
|
||||
}
|
||||
|
||||
// Non-EACCES error: always empty.
|
||||
currentOS = "linux"
|
||||
if got := permissionHint("some other error"); got != "" {
|
||||
if got := permissionHint("some other error", "npm"); got != "" {
|
||||
t.Errorf("expected empty hint for non-EACCES, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,14 +77,10 @@ func loadService(service string) map[string]json.RawMessage {
|
||||
// space→dot fallback covers domains where the two already coincide.
|
||||
func commandFormResolver(service string) func(string) string {
|
||||
byForm := map[string]string{}
|
||||
for _, svc := range registry.EmbeddedServicesTyped() {
|
||||
if svc.Name != service {
|
||||
continue
|
||||
}
|
||||
if svc, ok := registry.SchemaCatalog().Service(service); ok {
|
||||
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
|
||||
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
|
||||
}
|
||||
break
|
||||
}
|
||||
return func(h string) string {
|
||||
h = strings.TrimSpace(h)
|
||||
|
||||
@@ -6,12 +6,10 @@ package cmdutil
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
@@ -40,8 +38,6 @@ const (
|
||||
BuildKindUnknown = "unknown"
|
||||
|
||||
officialModulePath = "github.com/larksuite/cli"
|
||||
|
||||
agentTraceMaxLen = 1024
|
||||
)
|
||||
|
||||
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
|
||||
@@ -49,25 +45,6 @@ func UserAgentValue() string {
|
||||
return SourceValue + "/" + build.Version
|
||||
}
|
||||
|
||||
// AgentTraceValue returns a header-safe value from the
|
||||
// LARKSUITE_CLI_AGENT_TRACE environment variable. It trims
|
||||
// surrounding whitespace, rejects values containing any Unicode
|
||||
// control character or exceeding agentTraceMaxLen, and returns ""
|
||||
// for any invalid or empty value. Callers can use the result
|
||||
// directly in HTTP headers without further sanitisation.
|
||||
func AgentTraceValue() string {
|
||||
v := strings.TrimSpace(os.Getenv(envvars.CliAgentTrace))
|
||||
if v == "" || len(v) > agentTraceMaxLen {
|
||||
return ""
|
||||
}
|
||||
for _, r := range v {
|
||||
if unicode.IsControl(r) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// BaseSecurityHeaders returns headers that every request must carry.
|
||||
func BaseSecurityHeaders() http.Header {
|
||||
h := make(http.Header)
|
||||
@@ -75,7 +52,7 @@ func BaseSecurityHeaders() http.Header {
|
||||
h.Set(HeaderVersion, build.Version)
|
||||
h.Set(HeaderBuild, DetectBuildKind())
|
||||
h.Set(HeaderUserAgent, UserAgentValue())
|
||||
if v := AgentTraceValue(); v != "" {
|
||||
if v := envvars.AgentTrace(); v != "" {
|
||||
h.Set(HeaderAgentTrace, v)
|
||||
}
|
||||
return h
|
||||
|
||||
@@ -6,7 +6,6 @@ package cmdutil
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
@@ -264,88 +263,9 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AgentTraceValue / HeaderAgentTrace
|
||||
// HeaderAgentTrace injection (via BaseSecurityHeaders)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestAgentTraceValue_EmptyWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty when env unset", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_ReturnsCleanValue(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "trace-abc-123")
|
||||
if got := AgentTraceValue(); got != "trace-abc-123" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want %q", got, "trace-abc-123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_TrimsWhitespace(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, " trace-trim ")
|
||||
if got := AgentTraceValue(); got != "trace-trim" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want %q (whitespace trimmed)", got, "trace-trim")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, " ")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty for whitespace-only value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_RejectsCRLF(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "val\r\nX-Evil: attack")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty for CR/LF value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_RejectsLF(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "val\nX-Evil: attack")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty for LF value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_RejectsTab(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "val\tinjected")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty for tab value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_RejectsControlChar(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "val\x01injected")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty for control char value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_RejectsDEL(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "val\x7finjected")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty for DEL value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_RejectsOverlongValue(t *testing.T) {
|
||||
longVal := strings.Repeat("a", agentTraceMaxLen+1)
|
||||
t.Setenv(envvars.CliAgentTrace, longVal)
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_AcceptsMaxLengthValue(t *testing.T) {
|
||||
val := strings.Repeat("a", agentTraceMaxLen)
|
||||
t.Setenv(envvars.CliAgentTrace, val)
|
||||
if got := AgentTraceValue(); got != val {
|
||||
t.Fatalf("AgentTraceValue() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "")
|
||||
h := BaseSecurityHeaders()
|
||||
|
||||
@@ -19,6 +19,7 @@ const (
|
||||
// Content safety scanning mode
|
||||
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
|
||||
|
||||
CliAgentName = "LARKSUITE_CLI_AGENT_NAME"
|
||||
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
|
||||
|
||||
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
|
||||
|
||||
36
internal/envvars/read.go
Normal file
36
internal/envvars/read.go
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package envvars
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const (
|
||||
agentNameMaxLen = 128
|
||||
agentTraceMaxLen = 1024
|
||||
)
|
||||
|
||||
func AgentName() string {
|
||||
return sanitizeSingleLine(os.Getenv(CliAgentName), agentNameMaxLen)
|
||||
}
|
||||
|
||||
func AgentTrace() string {
|
||||
return sanitizeSingleLine(os.Getenv(CliAgentTrace), agentTraceMaxLen)
|
||||
}
|
||||
|
||||
func sanitizeSingleLine(raw string, maxLen int) string {
|
||||
v := strings.TrimSpace(raw)
|
||||
if v == "" || len(v) > maxLen {
|
||||
return ""
|
||||
}
|
||||
for _, r := range v {
|
||||
if unicode.IsControl(r) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
131
internal/envvars/read_test.go
Normal file
131
internal/envvars/read_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package envvars
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(CliAgentName, "")
|
||||
if got := AgentName(); got != "" {
|
||||
t.Fatalf("AgentName() = %q, want empty when env unset", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_ReturnsCleanValue(t *testing.T) {
|
||||
t.Setenv(CliAgentName, "claude-code")
|
||||
if got := AgentName(); got != "claude-code" {
|
||||
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_TrimsWhitespace(t *testing.T) {
|
||||
t.Setenv(CliAgentName, " cursor ")
|
||||
if got := AgentName(); got != "cursor" {
|
||||
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_RejectsCRLFInjection(t *testing.T) {
|
||||
t.Setenv(CliAgentName, "agent\r\nX-Evil: attack")
|
||||
if got := AgentName(); got != "" {
|
||||
t.Fatalf("AgentName() = %q, want empty for CR/LF value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_RejectsControlChar(t *testing.T) {
|
||||
t.Setenv(CliAgentName, "agent\x01injected")
|
||||
if got := AgentName(); got != "" {
|
||||
t.Fatalf("AgentName() = %q, want empty for control char value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_RejectsOverlongValue(t *testing.T) {
|
||||
longVal := strings.Repeat("a", agentNameMaxLen+1)
|
||||
t.Setenv(CliAgentName, longVal)
|
||||
if got := AgentName(); got != "" {
|
||||
t.Fatalf("AgentName() returned non-empty for %d-byte value (max %d)", len(longVal), agentNameMaxLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_EmptyWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty when env unset", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_ReturnsCleanValue(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "trace-abc-123")
|
||||
if got := AgentTrace(); got != "trace-abc-123" {
|
||||
t.Fatalf("AgentTrace() = %q, want %q", got, "trace-abc-123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_TrimsWhitespace(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, " trace-trim ")
|
||||
if got := AgentTrace(); got != "trace-trim" {
|
||||
t.Fatalf("AgentTrace() = %q, want %q (whitespace trimmed)", got, "trace-trim")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, " ")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty for whitespace-only value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_RejectsCRLF(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "val\r\nX-Evil: attack")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty for CR/LF value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_RejectsLF(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "val\nX-Evil: attack")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty for LF value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_RejectsTab(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "val\tinjected")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty for tab value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_RejectsControlChar(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "val\x01injected")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty for control char value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_RejectsDEL(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "val\x7finjected")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty for DEL value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_RejectsOverlongValue(t *testing.T) {
|
||||
longVal := strings.Repeat("a", agentTraceMaxLen+1)
|
||||
t.Setenv(CliAgentTrace, longVal)
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_AcceptsMaxLengthValue(t *testing.T) {
|
||||
val := strings.Repeat("a", agentTraceMaxLen)
|
||||
t.Setenv(CliAgentTrace, val)
|
||||
if got := AgentTrace(); got != val {
|
||||
t.Fatalf("AgentTrace() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,7 @@ package registry
|
||||
import "github.com/larksuite/cli/internal/apicatalog"
|
||||
|
||||
// EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free)
|
||||
// metadata — deterministic across machines, for `lark-cli schema`, golden tests
|
||||
// and schema lint.
|
||||
// metadata — deterministic across machines, for golden tests and schema lint.
|
||||
func EmbeddedCatalog() apicatalog.Catalog {
|
||||
return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped())
|
||||
}
|
||||
@@ -18,3 +17,14 @@ func EmbeddedCatalog() apicatalog.Catalog {
|
||||
func RuntimeCatalog() apicatalog.Catalog {
|
||||
return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped())
|
||||
}
|
||||
|
||||
// SchemaCatalog returns the embedded catalog when metadata is compiled in,
|
||||
// otherwise the merged runtime catalog. Binaries built from the bare Go module
|
||||
// embed only the empty meta_data_default.json stub, so the embedded view has
|
||||
// nothing to resolve; the merged view is the only data such binaries have.
|
||||
func SchemaCatalog() apicatalog.Catalog {
|
||||
if len(EmbeddedServicesTyped()) > 0 {
|
||||
return EmbeddedCatalog()
|
||||
}
|
||||
return RuntimeCatalog()
|
||||
}
|
||||
|
||||
67
internal/registry/catalog_test.go
Normal file
67
internal/registry/catalog_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package registry
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
)
|
||||
|
||||
// swapEmbeddedMeta replaces the compiled-in metadata bytes for one test and
|
||||
// restores them (with a full state reset) on cleanup.
|
||||
func swapEmbeddedMeta(t *testing.T, data []byte) {
|
||||
t.Helper()
|
||||
resetInit()
|
||||
orig := embeddedMetaJSON
|
||||
embeddedMetaJSON = data
|
||||
t.Cleanup(func() {
|
||||
waitBackgroundRefresh()
|
||||
embeddedMetaJSON = orig
|
||||
resetInit()
|
||||
})
|
||||
}
|
||||
|
||||
func TestSchemaCatalog_EmbeddedWhenCompiledIn(t *testing.T) {
|
||||
swapEmbeddedMeta(t, testCacheJSON("embedded_svc"))
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
|
||||
|
||||
c := SchemaCatalog()
|
||||
|
||||
if c.Source() != apicatalog.SourceEmbedded {
|
||||
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceEmbedded)
|
||||
}
|
||||
if _, ok := c.Service("embedded_svc"); !ok {
|
||||
t.Fatal("expected embedded_svc from embedded metadata")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded simulates a binary built
|
||||
// from the bare Go module (plugin builds): only the empty meta_data_default.json
|
||||
// stub is compiled in, so SchemaCatalog must serve the merged runtime view that
|
||||
// Init seeds via sync fetch.
|
||||
func TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded(t *testing.T) {
|
||||
swapEmbeddedMeta(t, embeddedMetaDataDefaultJSON)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
w.Write(testEnvelopeJSON("remote_svc"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
testMetaURL = ts.URL
|
||||
|
||||
c := SchemaCatalog()
|
||||
|
||||
if c.Source() != apicatalog.SourceRuntime {
|
||||
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceRuntime)
|
||||
}
|
||||
if _, ok := c.Service("remote_svc"); !ok {
|
||||
t.Fatal("expected remote_svc from runtime fallback")
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
)
|
||||
|
||||
//go:embed scope_priorities.json scope_overrides.json
|
||||
@@ -85,7 +86,9 @@ func InitWithBrand(brand core.LarkBrand) {
|
||||
brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand)
|
||||
|
||||
if !brandChanged {
|
||||
if cached, err := loadCachedMerged(); err == nil {
|
||||
// After a CLI upgrade the embedded data can be fresher than an old
|
||||
// cache; an equal/older cache must not shadow it.
|
||||
if cached, err := loadCachedMerged(); err == nil && update.IsNewer(cached.Version, embeddedVersion) {
|
||||
overlayMergedServices(cached)
|
||||
}
|
||||
}
|
||||
|
||||
102
internal/registry/loader_test.go
Normal file
102
internal/registry/loader_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package registry
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
// seedCache writes a cache file + cache meta for one service whose Title is
|
||||
// marker, tagged with the given top-level data version and brand.
|
||||
func seedCache(t *testing.T, dir, name, marker, version, brand string) {
|
||||
t.Helper()
|
||||
cDir := filepath.Join(dir, "cache")
|
||||
if err := os.MkdirAll(cDir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg := MergedRegistry{
|
||||
Version: version,
|
||||
Services: []meta.Service{{Name: name, Version: "cache", Title: marker}},
|
||||
}
|
||||
data, _ := json.Marshal(reg)
|
||||
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.json"), data, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cm := CacheMeta{LastCheckAt: time.Now().Unix(), Version: version, Brand: brand}
|
||||
mData, _ := json.Marshal(cm)
|
||||
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), mData, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// initWithCache runs a fresh feishu-brand init with remote on, a high TTL and a
|
||||
// recent LastCheckAt (so no refresh fires), embedded meta at embeddedVer and a
|
||||
// pre-seeded cache at cacheVer — the overlay version gate is the only variable.
|
||||
func initWithCache(t *testing.T, embeddedVer, cacheVer string) {
|
||||
t.Helper()
|
||||
embedded, _ := json.Marshal(MergedRegistry{
|
||||
Version: embeddedVer,
|
||||
Services: []meta.Service{{Name: "svc", Version: "embedded", Title: "EMBEDDED"}},
|
||||
})
|
||||
swapEmbeddedMeta(t, embedded)
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||
t.Setenv("LARKSUITE_CLI_META_TTL", "3600")
|
||||
seedCache(t, tmp, "svc", "CACHE", cacheVer, "feishu")
|
||||
InitWithBrand(core.BrandFeishu)
|
||||
}
|
||||
|
||||
func titleOf(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
svc, ok := ServiceTyped(name)
|
||||
if !ok {
|
||||
t.Fatalf("service %q not loaded", name)
|
||||
}
|
||||
return svc.Title
|
||||
}
|
||||
|
||||
func TestOverlayGate_EqualVersion_UsesEmbedded(t *testing.T) {
|
||||
initWithCache(t, "1.0.0", "1.0.0")
|
||||
if got := titleOf(t, "svc"); got != "EMBEDDED" {
|
||||
t.Errorf("equal version: got %q, want EMBEDDED (cache must not overlay)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayGate_OlderCache_UsesEmbedded(t *testing.T) {
|
||||
initWithCache(t, "2.0.0", "1.0.0")
|
||||
if got := titleOf(t, "svc"); got != "EMBEDDED" {
|
||||
t.Errorf("older cache: got %q, want EMBEDDED", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayGate_NewerCache_OverlaysCache(t *testing.T) {
|
||||
initWithCache(t, "1.0.0", "2.0.0")
|
||||
if got := titleOf(t, "svc"); got != "CACHE" {
|
||||
t.Errorf("newer cache: got %q, want CACHE", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayGate_UnparseableCacheVersion_UsesEmbedded(t *testing.T) {
|
||||
initWithCache(t, "1.0.0", "not-a-semver")
|
||||
if got := titleOf(t, "svc"); got != "EMBEDDED" {
|
||||
t.Errorf("unparseable cache version: got %q, want EMBEDDED", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayGate_StubEmbedded_OverlaysRealCache(t *testing.T) {
|
||||
// The bare-module stub baseline is "0.0.0"; a real cache version must win so
|
||||
// plugin builds without compiled meta_data.json still get remote data.
|
||||
initWithCache(t, "0.0.0", "1.0.0")
|
||||
if got := titleOf(t, "svc"); got != "CACHE" {
|
||||
t.Errorf("stub-embedded baseline: got %q, want CACHE", got)
|
||||
}
|
||||
}
|
||||
@@ -72,9 +72,11 @@ func hasEmbeddedServices() bool {
|
||||
}
|
||||
|
||||
// testRegistry returns a minimal MergedRegistry with one service.
|
||||
// The version is a real semver newer than the embedded stub baseline ("0.0.0")
|
||||
// so cache overlay passes the version gate in InitWithBrand.
|
||||
func testRegistry(name string) MergedRegistry {
|
||||
return MergedRegistry{
|
||||
Version: "test-1.0",
|
||||
Version: "1.0.0",
|
||||
Services: []meta.Service{
|
||||
{
|
||||
Name: name,
|
||||
@@ -160,7 +162,7 @@ func TestRemoteOff_SkipsRemoteLogic(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCacheHit_WithinTTL(t *testing.T) {
|
||||
resetInit()
|
||||
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||
@@ -197,7 +199,7 @@ func TestCacheHit_WithinTTL(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNetworkError_SilentDegradation(t *testing.T) {
|
||||
resetInit()
|
||||
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||
@@ -371,8 +373,8 @@ func TestFetchRemoteMerged_200(t *testing.T) {
|
||||
if data == nil {
|
||||
t.Fatal("expected non-nil data")
|
||||
}
|
||||
if reg.Version != "test-1.0" {
|
||||
t.Errorf("expected version test-1.0, got %s", reg.Version)
|
||||
if reg.Version != "1.0.0" {
|
||||
t.Errorf("expected version 1.0.0, got %s", reg.Version)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ type InstallMethod int
|
||||
|
||||
const (
|
||||
InstallNpm InstallMethod = iota
|
||||
InstallPnpm
|
||||
InstallManual
|
||||
)
|
||||
|
||||
@@ -53,22 +54,32 @@ var (
|
||||
|
||||
// DetectResult holds installation detection results.
|
||||
type DetectResult struct {
|
||||
Method InstallMethod
|
||||
ResolvedPath string
|
||||
NpmAvailable bool
|
||||
Method InstallMethod
|
||||
ResolvedPath string
|
||||
NpmAvailable bool
|
||||
PnpmAvailable bool
|
||||
}
|
||||
|
||||
// CanAutoUpdate returns true if the CLI can update itself automatically.
|
||||
func (d DetectResult) CanAutoUpdate() bool {
|
||||
return d.Method == InstallNpm && d.NpmAvailable
|
||||
switch d.Method {
|
||||
case InstallNpm:
|
||||
return d.NpmAvailable
|
||||
case InstallPnpm:
|
||||
return d.PnpmAvailable
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ManualReason returns a human-readable explanation of why auto-update is unavailable.
|
||||
func (d DetectResult) ManualReason() string {
|
||||
if d.Method == InstallNpm && !d.NpmAvailable {
|
||||
switch {
|
||||
case d.Method == InstallNpm && !d.NpmAvailable:
|
||||
return "installed via npm, but npm is not available in PATH"
|
||||
case d.Method == InstallPnpm && !d.PnpmAvailable:
|
||||
return "installed via pnpm, but pnpm is not available in PATH"
|
||||
}
|
||||
return "not installed via npm"
|
||||
return "not installed via npm or pnpm"
|
||||
}
|
||||
|
||||
// NpmResult holds the result of an npm install or skills update execution.
|
||||
@@ -92,6 +103,7 @@ func (r *NpmResult) CombinedOutput() string {
|
||||
type Updater struct {
|
||||
DetectOverride func() DetectResult
|
||||
NpmInstallOverride func(version string) *NpmResult
|
||||
PnpmInstallOverride func(version string) *NpmResult
|
||||
SkillsIndexFetchOverride func() *NpmResult
|
||||
SkillsCommandOverride func(args ...string) *NpmResult
|
||||
VerifyOverride func(expectedVersion string) error
|
||||
@@ -101,17 +113,38 @@ type Updater struct {
|
||||
// running binary is successfully renamed to .old. Used by
|
||||
// CanRestorePreviousVersion to report whether rollback is possible.
|
||||
backupCreated bool
|
||||
|
||||
// detectCache memoizes the first real DetectInstallMethod result. How this
|
||||
// binary was installed cannot change during a single process, so caching is
|
||||
// the correct semantics — and it is required for correctness: the update
|
||||
// flow mutates the install (pnpm add -g / npm install -g) before syncing
|
||||
// skills, so a re-detection at skills time could resolve a now-stale
|
||||
// os.Executable path and misclassify. Seeded pre-update by the first call
|
||||
// (updateRun), it keeps the post-update skills launcher consistent with the
|
||||
// launcher reported to the user. Not goroutine-safe; the update flow is
|
||||
// sequential.
|
||||
detectCache *DetectResult
|
||||
}
|
||||
|
||||
// New creates an Updater with default (real) behavior.
|
||||
func New() *Updater { return &Updater{} }
|
||||
|
||||
// DetectInstallMethod determines how the CLI was installed and whether
|
||||
// npm is available for auto-update.
|
||||
// DetectInstallMethod determines how the CLI was installed and whether the
|
||||
// owning package manager is available for auto-update.
|
||||
func (u *Updater) DetectInstallMethod() DetectResult {
|
||||
if u.DetectOverride != nil {
|
||||
return u.DetectOverride()
|
||||
}
|
||||
if u.detectCache != nil {
|
||||
return *u.detectCache
|
||||
}
|
||||
result := u.detectInstallMethod()
|
||||
u.detectCache = &result
|
||||
return result
|
||||
}
|
||||
|
||||
// detectInstallMethod performs the real (uncached) detection.
|
||||
func (u *Updater) detectInstallMethod() DetectResult {
|
||||
exe, err := vfs.Executable()
|
||||
if err != nil {
|
||||
return DetectResult{Method: InstallManual}
|
||||
@@ -120,24 +153,54 @@ func (u *Updater) DetectInstallMethod() DetectResult {
|
||||
if err != nil {
|
||||
return DetectResult{Method: InstallManual, ResolvedPath: exe}
|
||||
}
|
||||
_, npmErr := exec.LookPath("npm")
|
||||
_, pnpmErr := exec.LookPath("pnpm")
|
||||
return detectFromResolved(resolved, npmErr == nil, pnpmErr == nil)
|
||||
}
|
||||
|
||||
// detectFromResolved classifies the resolved binary path into an install
|
||||
// method and records package-manager availability. Split out from
|
||||
// DetectInstallMethod so the classification is unit-testable without touching
|
||||
// the filesystem or PATH.
|
||||
func detectFromResolved(resolved string, npmOnPath, pnpmOnPath bool) DetectResult {
|
||||
method := InstallManual
|
||||
if strings.Contains(resolved, "node_modules") {
|
||||
method = InstallNpm
|
||||
}
|
||||
|
||||
npmAvailable := false
|
||||
if method == InstallNpm {
|
||||
if _, err := exec.LookPath("npm"); err == nil {
|
||||
npmAvailable = true
|
||||
if containsPnpmMarker(resolved) {
|
||||
method = InstallPnpm
|
||||
} else {
|
||||
method = InstallNpm
|
||||
}
|
||||
}
|
||||
|
||||
return DetectResult{
|
||||
Method: method,
|
||||
ResolvedPath: resolved,
|
||||
NpmAvailable: npmAvailable,
|
||||
d := DetectResult{Method: method, ResolvedPath: resolved}
|
||||
switch method {
|
||||
case InstallNpm:
|
||||
d.NpmAvailable = npmOnPath
|
||||
case InstallPnpm:
|
||||
d.PnpmAvailable = pnpmOnPath
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// containsPnpmMarker reports whether the resolved binary path belongs to a
|
||||
// pnpm-managed install. pnpm exposes two layouts: the classic virtual store
|
||||
// (a ".pnpm" directory segment) and the global content-addressable store,
|
||||
// whose resolved path runs through pnpm's home directory (e.g.
|
||||
// "~/Library/pnpm/store/v11/links/...") — a "pnpm" segment immediately
|
||||
// followed by "store". Matching only these two shapes (rather than any bare
|
||||
// "pnpm" segment) avoids misclassifying an npm install that merely lives under
|
||||
// a directory named "pnpm". Windows separators are normalized to "/" so the
|
||||
// classification is OS-independent and unit-testable anywhere.
|
||||
func containsPnpmMarker(p string) bool {
|
||||
parts := strings.Split(strings.ReplaceAll(p, `\`, "/"), "/")
|
||||
for i, part := range parts {
|
||||
if part == ".pnpm" {
|
||||
return true
|
||||
}
|
||||
if part == "pnpm" && i+1 < len(parts) && parts[i+1] == "store" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RunNpmInstall executes npm install -g @larksuite/cli@<version>.
|
||||
@@ -163,6 +226,29 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
|
||||
return r
|
||||
}
|
||||
|
||||
// RunPnpmInstall executes pnpm add -g @larksuite/cli@<version>.
|
||||
func (u *Updater) RunPnpmInstall(version string) *NpmResult {
|
||||
if u.PnpmInstallOverride != nil {
|
||||
return u.PnpmInstallOverride(version)
|
||||
}
|
||||
r := &NpmResult{}
|
||||
pnpmPath, err := exec.LookPath("pnpm")
|
||||
if err != nil {
|
||||
r.Err = fmt.Errorf("pnpm not found in PATH: %w", err)
|
||||
return r
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), npmInstallTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, pnpmPath, "add", "-g", NpmPackage+"@"+version)
|
||||
cmd.Stdout = &r.Stdout
|
||||
cmd.Stderr = &r.Stderr
|
||||
r.Err = cmd.Run()
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
r.Err = fmt.Errorf("pnpm install timed out after %s", npmInstallTimeout)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
|
||||
if u.SkillsIndexFetchOverride != nil {
|
||||
return u.SkillsIndexFetchOverride()
|
||||
@@ -261,19 +347,40 @@ func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult
|
||||
return u.runSkillsCommand(args...)
|
||||
}
|
||||
|
||||
// skillsInvocation decides how to launch the `skills` CLI. When the lark-cli
|
||||
// itself was installed via pnpm and pnpm is available, it uses `pnpm dlx` so
|
||||
// pnpm-only environments (pnpm's standalone installer bundles Node without
|
||||
// putting npm/npx on PATH) can still sync skills after a self-update.
|
||||
// Otherwise it uses `npx`. The npx auto-confirm flag "-y", when present as the
|
||||
// leading arg, maps to `pnpm dlx`'s default non-interactive behavior and is
|
||||
// dropped for the pnpm launcher. Kept pure (no exec/PATH access) so the
|
||||
// launcher selection is unit-testable on any platform.
|
||||
func skillsInvocation(method InstallMethod, pnpmAvailable bool, args []string) (launcher string, rest []string) {
|
||||
if method == InstallPnpm && pnpmAvailable {
|
||||
r := args
|
||||
if len(r) > 0 && r[0] == "-y" {
|
||||
r = r[1:]
|
||||
}
|
||||
return "pnpm", append([]string{"dlx"}, r...)
|
||||
}
|
||||
return "npx", args
|
||||
}
|
||||
|
||||
func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
|
||||
if u.SkillsCommandOverride != nil {
|
||||
return u.SkillsCommandOverride(args...)
|
||||
}
|
||||
r := &NpmResult{}
|
||||
npxPath, err := exec.LookPath("npx")
|
||||
det := u.DetectInstallMethod()
|
||||
launcher, cmdArgs := skillsInvocation(det.Method, det.PnpmAvailable, args)
|
||||
binPath, err := exec.LookPath(launcher)
|
||||
if err != nil {
|
||||
r.Err = fmt.Errorf("npx not found in PATH: %w", err)
|
||||
r.Err = fmt.Errorf("%s not found in PATH: %w", launcher, err)
|
||||
return r
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, npxPath, args...)
|
||||
cmd := exec.CommandContext(ctx, binPath, cmdArgs...)
|
||||
cmd.Stdout = &r.Stdout
|
||||
cmd.Stderr = &r.Stderr
|
||||
r.Err = cmd.Run()
|
||||
|
||||
@@ -371,3 +371,147 @@ func TestListOfficialSkillsFallsBack(t *testing.T) {
|
||||
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainsPnpmMarker(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
// Classic virtual-store layout (.pnpm segment).
|
||||
{"/Users/x/Library/pnpm/global/5/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true},
|
||||
{`C:\Users\x\AppData\Local\pnpm\global\5\node_modules\.pnpm\@larksuite+cli@1.0.44\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
|
||||
// Global content-addressable store layout (pnpm 11): resolved path runs
|
||||
// through the pnpm home store, a "pnpm" segment with no ".pnpm".
|
||||
{"/Users/x/Library/pnpm/store/v11/links/@larksuite/cli/1.0.59/abc123/node_modules/@larksuite/cli/bin/lark-cli", true},
|
||||
{"/home/x/.local/share/pnpm/store/v10/@larksuite/cli/node_modules/@larksuite/cli/bin/lark-cli", true},
|
||||
{`C:\Users\x\AppData\Local\pnpm\store\v11\links\@larksuite\cli\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
|
||||
// npm and non-package installs — no pnpm/.pnpm segment.
|
||||
{"/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||
{"/usr/local/bin/lark-cli", false},
|
||||
// Substrings that must NOT match: segment must be exactly .pnpm, or
|
||||
// "pnpm" immediately followed by "store".
|
||||
{"/opt/homebrew/.pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||
{"/opt/pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||
// A bare "pnpm" directory NOT followed by "store" (e.g. an npm install
|
||||
// living under a dir named pnpm) must not be misclassified as pnpm.
|
||||
{"/opt/pnpm/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := containsPnpmMarker(c.path); got != c.want {
|
||||
t.Errorf("containsPnpmMarker(%q) = %v, want %v", c.path, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectInstallMethod_Pnpm(t *testing.T) {
|
||||
u := &Updater{DetectOverride: nil}
|
||||
u.DetectOverride = func() DetectResult {
|
||||
// Exercise the real classification by feeding a resolved path via a small shim.
|
||||
return detectFromResolved("/x/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true, true)
|
||||
}
|
||||
got := u.DetectInstallMethod()
|
||||
if got.Method != InstallPnpm {
|
||||
t.Errorf("Method = %v, want InstallPnpm", got.Method)
|
||||
}
|
||||
if !got.PnpmAvailable {
|
||||
t.Errorf("PnpmAvailable = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectInstallMethod_NpmVsManual(t *testing.T) {
|
||||
if m := detectFromResolved("/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", true, false).Method; m != InstallNpm {
|
||||
t.Errorf("npm path Method = %v, want InstallNpm", m)
|
||||
}
|
||||
if m := detectFromResolved("/usr/local/bin/lark-cli", false, false).Method; m != InstallManual {
|
||||
t.Errorf("manual path Method = %v, want InstallManual", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanAutoUpdate_Pnpm(t *testing.T) {
|
||||
if !(DetectResult{Method: InstallPnpm, PnpmAvailable: true}).CanAutoUpdate() {
|
||||
t.Error("pnpm available should CanAutoUpdate")
|
||||
}
|
||||
if (DetectResult{Method: InstallPnpm, PnpmAvailable: false}).CanAutoUpdate() {
|
||||
t.Error("pnpm unavailable should not CanAutoUpdate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualReason_Pnpm(t *testing.T) {
|
||||
if got := (DetectResult{Method: InstallPnpm, NpmAvailable: false, PnpmAvailable: false}).ManualReason(); got != "installed via pnpm, but pnpm is not available in PATH" {
|
||||
t.Errorf("pnpm reason = %q", got)
|
||||
}
|
||||
if got := (DetectResult{Method: InstallManual}).ManualReason(); got != "not installed via npm or pnpm" {
|
||||
t.Errorf("manual reason = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPnpmInstall_Override(t *testing.T) {
|
||||
u := &Updater{PnpmInstallOverride: func(version string) *NpmResult {
|
||||
r := &NpmResult{}
|
||||
r.Stdout.WriteString("added @larksuite/cli@" + version)
|
||||
return r
|
||||
}}
|
||||
got := u.RunPnpmInstall("2.0.0")
|
||||
if got.Err != nil {
|
||||
t.Fatalf("unexpected err: %v", got.Err)
|
||||
}
|
||||
if !strings.Contains(got.CombinedOutput(), "2.0.0") {
|
||||
t.Errorf("output = %q, want version echoed", got.CombinedOutput())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPnpmInstall_Error(t *testing.T) {
|
||||
wantErr := errors.New("boom")
|
||||
u := &Updater{PnpmInstallOverride: func(string) *NpmResult { return &NpmResult{Err: wantErr} }}
|
||||
if got := u.RunPnpmInstall("2.0.0"); !errors.Is(got.Err, wantErr) {
|
||||
t.Errorf("err = %v, want %v", got.Err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsInvocation(t *testing.T) {
|
||||
addArgs := []string{"-y", "skills", "add", "https://open.feishu.cn", "-g", "-y"}
|
||||
cases := []struct {
|
||||
name string
|
||||
method InstallMethod
|
||||
pnpmAvailable bool
|
||||
args []string
|
||||
wantLauncher string
|
||||
wantRest []string
|
||||
}{
|
||||
{"pnpm install + pnpm available → pnpm dlx, drop leading -y", InstallPnpm, true, addArgs,
|
||||
"pnpm", []string{"dlx", "skills", "add", "https://open.feishu.cn", "-g", "-y"}},
|
||||
{"pnpm install but pnpm unavailable → npx unchanged", InstallPnpm, false, addArgs,
|
||||
"npx", addArgs},
|
||||
{"npm install → npx unchanged", InstallNpm, false, addArgs,
|
||||
"npx", addArgs},
|
||||
{"manual install → npx unchanged", InstallManual, false, []string{"-y", "skills", "ls", "-g"},
|
||||
"npx", []string{"-y", "skills", "ls", "-g"}},
|
||||
{"pnpm without a leading -y → prepend dlx only", InstallPnpm, true, []string{"skills", "ls", "-g"},
|
||||
"pnpm", []string{"dlx", "skills", "ls", "-g"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
gotLauncher, gotRest := skillsInvocation(c.method, c.pnpmAvailable, c.args)
|
||||
if gotLauncher != c.wantLauncher {
|
||||
t.Errorf("launcher = %q, want %q", gotLauncher, c.wantLauncher)
|
||||
}
|
||||
if strings.Join(gotRest, " ") != strings.Join(c.wantRest, " ") {
|
||||
t.Errorf("rest = %v, want %v", gotRest, c.wantRest)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectInstallMethod_Caches locks the fix for the post-update re-detection
|
||||
// hazard: DetectInstallMethod must return the first (pre-update) detection on
|
||||
// subsequent calls, so the skills launcher chosen after the binary is replaced
|
||||
// stays consistent with what was detected — and reported — before the update.
|
||||
func TestDetectInstallMethod_Caches(t *testing.T) {
|
||||
u := New()
|
||||
cached := DetectResult{Method: InstallPnpm, PnpmAvailable: true, ResolvedPath: "/x/pnpm/store/v11/links/@larksuite/cli/1.0.0/node_modules/@larksuite/cli/bin/lark-cli"}
|
||||
u.detectCache = &cached
|
||||
got := u.DetectInstallMethod()
|
||||
if got.Method != InstallPnpm || !got.PnpmAvailable {
|
||||
t.Errorf("expected cached pnpm result to be returned, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,6 +306,9 @@ var CalendarCreate = common.Shortcut{
|
||||
"start": startStr,
|
||||
"end": endStr,
|
||||
}
|
||||
if recurrence, _ := event["recurrence"].(string); recurrence != "" {
|
||||
resultData["recurrence"] = recurrence
|
||||
}
|
||||
|
||||
runtime.OutFormat(resultData, nil, func(w io.Writer) {
|
||||
var rows []map[string]interface{}
|
||||
|
||||
279
shortcuts/calendar/calendar_get.go
Normal file
279
shortcuts/calendar/calendar_get.go
Normal file
@@ -0,0 +1,279 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// calendar +get — get a single calendar event detail by calendar_id and event_id
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// calendarEventTime mirrors start_time / end_time in the API response.
|
||||
type calendarEventTime struct {
|
||||
Date string `json:"date,omitempty"`
|
||||
Timestamp string `json:"timestamp,omitempty"`
|
||||
Timezone string `json:"timezone,omitempty"`
|
||||
}
|
||||
|
||||
// calendarEventVChat mirrors the vchat block in the API response.
|
||||
type calendarEventVChat struct {
|
||||
VCType string `json:"vc_type,omitempty"`
|
||||
IconType string `json:"icon_type,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
MeetingURL string `json:"meeting_url,omitempty"`
|
||||
}
|
||||
|
||||
// calendarEventLocation mirrors the location block in the API response.
|
||||
type calendarEventLocation struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Latitude float64 `json:"latitude,omitempty"`
|
||||
Longitude float64 `json:"longitude,omitempty"`
|
||||
}
|
||||
|
||||
// calendarEventReminder mirrors a reminder entry.
|
||||
type calendarEventReminder struct {
|
||||
Minutes int `json:"minutes"`
|
||||
}
|
||||
|
||||
// calendarEventOrganizer mirrors event_organizer.
|
||||
type calendarEventOrganizer struct {
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
}
|
||||
|
||||
// calendarEventAttachment mirrors a single attachment entry.
|
||||
type calendarEventAttachment struct {
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
FileSize string `json:"file_size,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// calendarEventCheckInTime mirrors check_in_start_time / check_in_end_time.
|
||||
type calendarEventCheckInTime struct {
|
||||
TimeType string `json:"time_type,omitempty"`
|
||||
Duration int `json:"duration"`
|
||||
}
|
||||
|
||||
// calendarEventCheckIn mirrors event_check_in.
|
||||
type calendarEventCheckIn struct {
|
||||
EnableCheckIn bool `json:"enable_check_in"`
|
||||
CheckInStartTime *calendarEventCheckInTime `json:"check_in_start_time,omitempty"`
|
||||
CheckInEndTime *calendarEventCheckInTime `json:"check_in_end_time,omitempty"`
|
||||
NeedNotifyAttendees bool `json:"need_notify_attendees"`
|
||||
}
|
||||
|
||||
// calendarEvent mirrors the event object inside the API response.
|
||||
type calendarEvent struct {
|
||||
EventID string `json:"event_id,omitempty"`
|
||||
OrganizerCalendarID string `json:"organizer_calendar_id,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
StartTime *calendarEventTime `json:"start_time,omitempty"`
|
||||
EndTime *calendarEventTime `json:"end_time,omitempty"`
|
||||
VChat *calendarEventVChat `json:"vchat,omitempty"`
|
||||
Visibility string `json:"visibility,omitempty"`
|
||||
AttendeeAbility string `json:"attendee_ability,omitempty"`
|
||||
FreeBusyStatus string `json:"free_busy_status,omitempty"`
|
||||
SelfRsvpStatus string `json:"self_rsvp_status,omitempty"`
|
||||
Location *calendarEventLocation `json:"location,omitempty"`
|
||||
Color int `json:"color,omitempty"`
|
||||
Reminders []calendarEventReminder `json:"reminders,omitempty"`
|
||||
Recurrence string `json:"recurrence,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
IsException bool `json:"is_exception,omitempty"`
|
||||
RecurringEventID string `json:"recurring_event_id,omitempty"`
|
||||
CreateTime string `json:"create_time,omitempty"`
|
||||
EventOrganizer *calendarEventOrganizer `json:"event_organizer,omitempty"`
|
||||
AppLink string `json:"app_link,omitempty"`
|
||||
Attachments []calendarEventAttachment `json:"attachments,omitempty"`
|
||||
EventCheckIn *calendarEventCheckIn `json:"event_check_in,omitempty"`
|
||||
}
|
||||
|
||||
// parseCalendarEvent decodes the API response data into a typed calendarEvent.
|
||||
func parseCalendarEvent(data map[string]any) (*calendarEvent, error) {
|
||||
rawEvent, ok := data["event"]
|
||||
if !ok || rawEvent == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response missing 'event' field")
|
||||
}
|
||||
raw, err := json.Marshal(rawEvent)
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response: marshal failed: %s", err).WithCause(err)
|
||||
}
|
||||
var event calendarEvent
|
||||
if err := json.Unmarshal(raw, &event); err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response: unmarshal failed: %s", err).WithCause(err)
|
||||
}
|
||||
return &event, nil
|
||||
}
|
||||
|
||||
// buildCalendarEventOutput converts the typed event into the output map and
|
||||
// applies the four transformation rules:
|
||||
// 1. create_time -> RFC3339
|
||||
// 2. start_time / end_time timestamp -> datetime (RFC3339), drop timestamp
|
||||
// 3. flatten event into the top-level result
|
||||
// 4. when status != "cancelled", drop status (and adjust all-day end date)
|
||||
func buildCalendarEventOutput(event *calendarEvent) (map[string]interface{}, error) {
|
||||
raw, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event marshal failed: %s", err).WithCause(err)
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event unmarshal failed: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
if ctStr, ok := out["create_time"].(string); ok && ctStr != "" {
|
||||
if ts, err := strconv.ParseInt(ctStr, 10, 64); err == nil {
|
||||
out["create_time"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
|
||||
if startMap, ok := out["start_time"].(map[string]interface{}); ok {
|
||||
if tsStr, ok := startMap["timestamp"].(string); ok && tsStr != "" {
|
||||
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
|
||||
startMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
|
||||
delete(startMap, "timestamp")
|
||||
}
|
||||
}
|
||||
}
|
||||
if endMap, ok := out["end_time"].(map[string]interface{}); ok {
|
||||
if tsStr, ok := endMap["timestamp"].(string); ok && tsStr != "" {
|
||||
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
|
||||
endMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
|
||||
delete(endMap, "timestamp")
|
||||
}
|
||||
}
|
||||
// All-day event: end date is exclusive in the API; rewind by 1s and reformat.
|
||||
if dt, _ := endMap["datetime"].(string); dt == "" {
|
||||
if dateStr, ok := endMap["date"].(string); ok && dateStr != "" {
|
||||
if t, err := time.ParseInLocation("2006-01-02", dateStr, time.UTC); err == nil {
|
||||
endMap["date"] = t.Add(-1 * time.Second).Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if status, _ := out["status"].(string); status != "cancelled" {
|
||||
delete(out, "status")
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CalendarGet gets a single calendar event detail.
|
||||
var CalendarGet = common.Shortcut{
|
||||
Service: "calendar",
|
||||
Command: "+get",
|
||||
Description: "Get a single calendar event detail by calendar-id and event-id",
|
||||
Risk: "read",
|
||||
Scopes: []string{"calendar:calendar.event:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
|
||||
{Name: "event-id", Desc: "event ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, flag := range []string{"calendar-id", "event-id"} {
|
||||
if val := strings.TrimSpace(runtime.Str(flag)); val != "" {
|
||||
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||
if eventId == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "event-id cannot be empty").WithParam("--event-id")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
calendarId := strings.TrimSpace(runtime.Str("calendar-id"))
|
||||
d := common.NewDryRunAPI()
|
||||
switch calendarId {
|
||||
case "":
|
||||
d.Desc("(calendar-id omitted) Will use primary calendar")
|
||||
calendarId = "<primary>"
|
||||
case "primary":
|
||||
calendarId = "<primary>"
|
||||
}
|
||||
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||
return d.
|
||||
GET("/open-apis/calendar/v4/calendars/:calendar_id/events/:event_id").
|
||||
Set("calendar_id", calendarId).
|
||||
Set("event_id", eventId)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
calendarId := strings.TrimSpace(runtime.Str("calendar-id"))
|
||||
if calendarId == "" {
|
||||
calendarId = PrimaryCalendarIDStr
|
||||
}
|
||||
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||
|
||||
data, err := runtime.CallAPITyped("GET",
|
||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s",
|
||||
validate.EncodePathSegment(calendarId),
|
||||
validate.EncodePathSegment(eventId)),
|
||||
nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
event, err := parseCalendarEvent(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := buildCalendarEventOutput(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
summary, _ := out["summary"].(string)
|
||||
if summary == "" {
|
||||
summary = "(untitled)"
|
||||
}
|
||||
startMap, _ := out["start_time"].(map[string]interface{})
|
||||
endMap, _ := out["end_time"].(map[string]interface{})
|
||||
startStr, _ := startMap["datetime"].(string)
|
||||
if startStr == "" {
|
||||
startStr, _ = startMap["date"].(string)
|
||||
}
|
||||
endStr, _ := endMap["datetime"].(string)
|
||||
if endStr == "" {
|
||||
endStr, _ = endMap["date"].(string)
|
||||
}
|
||||
eventIdOut, _ := out["event_id"].(string)
|
||||
freeBusyStatus, _ := out["free_busy_status"].(string)
|
||||
selfRsvpStatus, _ := out["self_rsvp_status"].(string)
|
||||
row := map[string]interface{}{
|
||||
"event_id": eventIdOut,
|
||||
"summary": summary,
|
||||
"start": startStr,
|
||||
"end": endStr,
|
||||
"free_busy_status": freeBusyStatus,
|
||||
"self_rsvp_status": selfRsvpStatus,
|
||||
}
|
||||
output.PrintTable(w, []map[string]interface{}{row})
|
||||
fmt.Fprintln(w)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -2304,17 +2304,17 @@ func TestResolveStartEnd_ExplicitValues(t *testing.T) {
|
||||
// Shortcuts() registration test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestShortcuts_Returns9(t *testing.T) {
|
||||
func TestShortcuts_Returns10(t *testing.T) {
|
||||
shortcuts := Shortcuts()
|
||||
if len(shortcuts) != 9 {
|
||||
t.Fatalf("expected 9 shortcuts, got %d", len(shortcuts))
|
||||
if len(shortcuts) != 10 {
|
||||
t.Fatalf("expected 10 shortcuts, got %d", len(shortcuts))
|
||||
}
|
||||
|
||||
names := map[string]bool{}
|
||||
for _, s := range shortcuts {
|
||||
names[s.Command] = true
|
||||
}
|
||||
for _, want := range []string{"+agenda", "+create", "+update", "+freebusy", "+room-find", "+rsvp", "+suggestion"} {
|
||||
for _, want := range []string{"+agenda", "+create", "+update", "+freebusy", "+room-find", "+rsvp", "+suggestion", "+get"} {
|
||||
if !names[want] {
|
||||
t.Errorf("missing shortcut %s", want)
|
||||
}
|
||||
@@ -3178,3 +3178,193 @@ func TestSuggestion_RejectsDangerousTimezone_Typed(t *testing.T) {
|
||||
t.Errorf("param=%q, want --timezone", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CalendarGet tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGet_Success_FlattensAndConvertsTimes(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_001",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_001",
|
||||
"summary": "Daily Sync",
|
||||
"create_time": "1602504000",
|
||||
"start_time": map[string]interface{}{
|
||||
"timestamp": "1742515200",
|
||||
"timezone": "Asia/Shanghai",
|
||||
},
|
||||
"end_time": map[string]interface{}{
|
||||
"timestamp": "1742518800",
|
||||
"timezone": "Asia/Shanghai",
|
||||
},
|
||||
"status": "confirmed",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarGet, []string{
|
||||
"+get",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--event-id", "evt_001",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
// Expect flattened — fields appear directly under "data", not under "data.event"
|
||||
if strings.Contains(out, "\"event\": {") {
|
||||
t.Errorf("payload should be flattened (no event wrapper), got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "\"event_id\": \"evt_001\"") {
|
||||
t.Errorf("expected event_id in output, got: %s", out)
|
||||
}
|
||||
// status=confirmed should be dropped
|
||||
if strings.Contains(out, "\"status\": \"confirmed\"") {
|
||||
t.Errorf("status should be dropped when not cancelled, got: %s", out)
|
||||
}
|
||||
// timestamp must be replaced with datetime
|
||||
if strings.Contains(out, "\"timestamp\":") {
|
||||
t.Errorf("timestamp should be replaced with datetime, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "\"datetime\":") {
|
||||
t.Errorf("expected datetime in output, got: %s", out)
|
||||
}
|
||||
// create_time must be RFC3339 (contain 'T' and timezone)
|
||||
if !strings.Contains(out, "\"create_time\": \"2020-10-12T") {
|
||||
t.Errorf("expected RFC3339 create_time, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_CancelledStatus_PreservesStatus(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_002",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_002",
|
||||
"summary": "Cancelled Meeting",
|
||||
"create_time": "1602504000",
|
||||
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
||||
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
||||
"status": "cancelled",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarGet, []string{
|
||||
"+get",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--event-id", "evt_002",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "\"status\": \"cancelled\"") {
|
||||
t.Errorf("status should be preserved when cancelled, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_AllDayEvent_AdjustsEndDate(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
// All-day event: start 2025-03-21, end 2025-03-22 (exclusive in API).
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_003",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_003",
|
||||
"summary": "All-day",
|
||||
"start_time": map[string]interface{}{"date": "2025-03-21"},
|
||||
"end_time": map[string]interface{}{"date": "2025-03-22"},
|
||||
"status": "confirmed",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarGet, []string{
|
||||
"+get",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--event-id", "evt_003",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
// end date 2025-03-22 should rewind by 1s -> 2025-03-21
|
||||
if !strings.Contains(out, "\"date\": \"2025-03-21\"") {
|
||||
t.Errorf("expected end date adjusted to 2025-03-21, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_EmptyEventID_Typed(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, CalendarGet, []string{
|
||||
"+get",
|
||||
"--event-id", " ",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("want error for empty event-id")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("want *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if ve.Param != "--event-id" {
|
||||
t.Errorf("param=%q, want --event-id", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_MissingEventField_TypedInternal(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_404",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarGet, []string{
|
||||
"+get",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--event-id", "evt_404",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("want error when event field is missing")
|
||||
}
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("want *errs.InternalError, got %T", err)
|
||||
}
|
||||
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("subtype=%q, want invalid_response", ie.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,5 +17,6 @@ func Shortcuts() []common.Shortcut {
|
||||
CalendarSuggestion,
|
||||
CalendarMeeting,
|
||||
CalendarSearchEvent,
|
||||
CalendarGet,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ func wrapExportContextErr(err error) error {
|
||||
var DriveExport = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+export",
|
||||
Description: "Export a doc/docx/sheet/bitable/slides to a local file with limited polling",
|
||||
Description: "Export a doc/docx/sheet/bitable/slides or wiki document to a local file with limited polling",
|
||||
Risk: "read",
|
||||
Scopes: []string{
|
||||
"docs:document.content:read",
|
||||
@@ -47,10 +47,12 @@ var DriveExport = common.Shortcut{
|
||||
"docx:document:readonly",
|
||||
"drive:drive.metadata:readonly",
|
||||
},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
ConditionalScopes: []string{"wiki:node:retrieve"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "source document token", Required: true},
|
||||
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable | slides", Required: true, Enum: []string{"doc", "docx", "sheet", "bitable", "slides"}},
|
||||
{Name: "url", Desc: "source document URL; doc type and token are inferred, and wiki URLs are resolved to the underlying document"},
|
||||
{Name: "token", Desc: "source document token; bare tokens require --doc-type, while wiki tokens can use --doc-type wiki or fallback after file-token-invalid"},
|
||||
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable | slides | wiki (required only when --token is a bare token)", Enum: []string{"doc", "docx", "sheet", "bitable", "slides", "wiki"}},
|
||||
{Name: "file-extension", Desc: "export format: docx | pdf | xlsx | csv | markdown | base (bitable only) | pptx (slides only)", Required: true, Enum: []string{"docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx"}},
|
||||
{Name: "sub-id", Desc: "sub-table/sheet ID, required when exporting sheet/bitable as csv"},
|
||||
{Name: "only-schema", Type: "bool", Desc: "export only bitable schema when --doc-type bitable --file-extension base"},
|
||||
@@ -75,6 +77,7 @@ var DriveExport = common.Shortcut{
|
||||
// task and poll, but do not download" — callers that only need the ready file
|
||||
// token / status get it back without writing a local file.
|
||||
type ExportParams struct {
|
||||
URL string
|
||||
Token string
|
||||
DocType string
|
||||
FileExtension string
|
||||
@@ -87,6 +90,7 @@ type ExportParams struct {
|
||||
|
||||
func (p ExportParams) spec() driveExportSpec {
|
||||
return driveExportSpec{
|
||||
URL: p.URL,
|
||||
Token: p.Token,
|
||||
DocType: p.DocType,
|
||||
FileExtension: p.FileExtension,
|
||||
@@ -106,6 +110,7 @@ func exportParamsFromFlags(runtime *common.RuntimeContext) ExportParams {
|
||||
outputDir = "."
|
||||
}
|
||||
return ExportParams{
|
||||
URL: runtime.Str("url"),
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
@@ -127,60 +132,93 @@ func validateExport(p ExportParams) error {
|
||||
|
||||
// PlanExportDryRun builds the dry-run plan for an export without performing I/O.
|
||||
func PlanExportDryRun(runtime *common.RuntimeContext, p ExportParams) *common.DryRunAPI {
|
||||
spec := p.spec()
|
||||
spec, source, err := normalizeDriveExportSpecInput(p.spec())
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
if err := validateDriveExportNormalizedSpecForSource(spec, source); err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
if source.Type == "wiki" {
|
||||
dry.GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[0] Resolve wiki node to underlying document token").
|
||||
Params(map[string]interface{}{"token": source.Token})
|
||||
spec.Token = "obj_token_from_step_0"
|
||||
if spec.DocType == "" {
|
||||
spec.DocType = "obj_type_from_step_0"
|
||||
}
|
||||
dry.Set("wiki_token", source.Token)
|
||||
}
|
||||
|
||||
// Markdown export is a special case: docx markdown comes from the V2
|
||||
// docs_ai fetch API directly instead of the Drive export task API.
|
||||
if spec.FileExtension == "markdown" {
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: fetch docx markdown -> write local file").
|
||||
desc := "2-step orchestration: fetch docx markdown -> write local file"
|
||||
if source.Type == "wiki" {
|
||||
desc = "3-step orchestration: resolve wiki -> fetch docx markdown -> write local file"
|
||||
}
|
||||
dry.Desc(desc).
|
||||
POST(apiPath).
|
||||
Body(map[string]interface{}{
|
||||
"format": "markdown",
|
||||
}).
|
||||
Set("output_dir", p.OutputDir)
|
||||
if name := strings.TrimSpace(p.FileName); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
dry.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
return dry
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
desc := "3-step orchestration: create export task -> limited polling -> download file"
|
||||
if source.Type == "wiki" {
|
||||
desc = "4-step orchestration: resolve wiki -> create export task -> limited polling -> download file"
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
if spec.OnlySchema {
|
||||
body["only_schema"] = true
|
||||
}
|
||||
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("3-step orchestration: create export task -> limited polling -> download file").
|
||||
dry.Desc(desc).
|
||||
POST("/open-apis/drive/v1/export_tasks").
|
||||
Body(body).
|
||||
Body(buildDriveExportTaskBody(spec)).
|
||||
Set("output_dir", p.OutputDir)
|
||||
if name := strings.TrimSpace(p.FileName); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
dry.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
if !source.WasURL {
|
||||
dry.Set("wiki_token_fallback", "if export task returns file token invalid, the CLI will resolve --token as a wiki node and retry once")
|
||||
}
|
||||
return dry
|
||||
}
|
||||
|
||||
// RunExport drives create export task -> bounded poll -> optional download. It
|
||||
// is the shared core behind both drive +export and sheets +workbook-export. An
|
||||
// empty p.OutputDir skips the download step and returns the ready file token.
|
||||
func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportParams) error {
|
||||
spec := p.spec()
|
||||
spec, source, err := normalizeDriveExportSpecInput(p.spec())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDriveExportNormalizedSpecForSource(spec, source); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outputDir := p.OutputDir
|
||||
preferredFileName := strings.TrimSpace(p.FileName)
|
||||
overwrite := p.Overwrite
|
||||
|
||||
var wikiResolution driveExportWikiResolution
|
||||
|
||||
// Markdown export bypasses the async export task and writes the fetched
|
||||
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
|
||||
// higher-quality Lark-flavored Markdown output.
|
||||
if spec.FileExtension == "markdown" {
|
||||
if source.Type == "wiki" {
|
||||
resolvedSpec, resolution, err := resolveDriveExportWikiSource(ctx, runtime, spec, source.Token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec = resolvedSpec
|
||||
wikiResolution = resolution
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
@@ -222,21 +260,23 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
runtime.Out(annotateDriveExportWikiOutput(map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_name": filepath.Base(savedPath),
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": len(content),
|
||||
}, nil)
|
||||
}, wikiResolution), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
ticket, err := createDriveExportTask(runtime, spec)
|
||||
ticket, resolvedSpec, resolution, err := createDriveExportTaskWithWikiFallback(ctx, runtime, spec, source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec = resolvedSpec
|
||||
wikiResolution = resolution
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
|
||||
|
||||
var lastStatus driveExportStatus
|
||||
@@ -274,7 +314,7 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
|
||||
// no local download (e.g. sheets +workbook-export without an output
|
||||
// path). Skip the download and return the status envelope.
|
||||
if strings.TrimSpace(outputDir) == "" {
|
||||
runtime.Out(map[string]interface{}{
|
||||
runtime.Out(annotateDriveExportWikiOutput(map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
@@ -284,7 +324,7 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
|
||||
"file_size": status.FileSize,
|
||||
"ready": true,
|
||||
"downloaded": false,
|
||||
}, nil)
|
||||
}, wikiResolution), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -307,7 +347,7 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
|
||||
out["ticket"] = ticket
|
||||
out["doc_type"] = spec.DocType
|
||||
out["file_extension"] = spec.FileExtension
|
||||
runtime.Out(out, nil)
|
||||
runtime.Out(annotateDriveExportWikiOutput(out, wikiResolution), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -357,7 +397,19 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
|
||||
if preferredFileName != "" {
|
||||
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
|
||||
}
|
||||
runtime.Out(result, nil)
|
||||
runtime.Out(annotateDriveExportWikiOutput(result, wikiResolution), nil)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
|
||||
return nil
|
||||
}
|
||||
|
||||
func annotateDriveExportWikiOutput(out map[string]interface{}, resolution driveExportWikiResolution) map[string]interface{} {
|
||||
if !resolution.Resolved {
|
||||
return out
|
||||
}
|
||||
out["wiki_token"] = resolution.WikiToken
|
||||
out["wiki_node"] = map[string]interface{}{
|
||||
"obj_token": resolution.ObjToken,
|
||||
"obj_type": resolution.ObjType,
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -27,9 +27,16 @@ var (
|
||||
driveExportPollInterval = 5 * time.Second
|
||||
)
|
||||
|
||||
const (
|
||||
driveExportResolvedDocTypeValues = "doc, docx, sheet, bitable, slides"
|
||||
driveExportInputDocTypeValues = driveExportResolvedDocTypeValues + ", wiki"
|
||||
driveExportFileExtensionValues = "docx, pdf, xlsx, csv, markdown, base, pptx"
|
||||
)
|
||||
|
||||
// driveExportSpec contains the normalized export request understood by the
|
||||
// shortcut and the underlying export task APIs.
|
||||
type driveExportSpec struct {
|
||||
URL string
|
||||
Token string
|
||||
DocType string
|
||||
FileExtension string
|
||||
@@ -37,6 +44,20 @@ type driveExportSpec struct {
|
||||
OnlySchema bool
|
||||
}
|
||||
|
||||
type driveExportInputSource struct {
|
||||
Type string
|
||||
Token string
|
||||
Param string
|
||||
WasURL bool
|
||||
}
|
||||
|
||||
type driveExportWikiResolution struct {
|
||||
Resolved bool
|
||||
WikiToken string
|
||||
ObjToken string
|
||||
ObjType string
|
||||
}
|
||||
|
||||
// driveExportTaskResultCommand prints the resume command shown when bounded
|
||||
// export polling times out locally.
|
||||
func driveExportTaskResultCommand(ticket, docToken string) string {
|
||||
@@ -127,45 +148,49 @@ func (s driveExportStatus) StatusLabel() string {
|
||||
// validateDriveExportSpec enforces shortcut-level export constraints before any
|
||||
// backend request is sent.
|
||||
func validateDriveExportSpec(spec driveExportSpec) error {
|
||||
if err := validate.ResourceName(spec.Token, "--token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
normalized, source, err := normalizeDriveExportSpecInput(spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validateDriveExportNormalizedSpecForSource(normalized, source)
|
||||
}
|
||||
|
||||
func validateDriveExportNormalizedSpec(spec driveExportSpec) error {
|
||||
switch spec.DocType {
|
||||
case "doc", "docx", "sheet", "bitable", "slides":
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc-type %q: allowed values are doc, docx, sheet, bitable, slides", spec.DocType).WithParam("--doc-type")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc-type %q: allowed values are %s", spec.DocType, driveExportInputDocTypeValues).
|
||||
WithParam("--doc-type").
|
||||
WithHint("use --url when you have a document URL; use --doc-type wiki only with a bare Wiki node token so the CLI can resolve the underlying document type")
|
||||
}
|
||||
|
||||
if err := validate.ResourceName(spec.Token, "--token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
|
||||
switch spec.FileExtension {
|
||||
case "docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx":
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-extension %q: allowed values are docx, pdf, xlsx, csv, markdown, base, pptx", spec.FileExtension).WithParam("--file-extension")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-extension %q: allowed values are %s", spec.FileExtension, driveExportFileExtensionValues).
|
||||
WithParam("--file-extension").
|
||||
WithHint("choose an export format supported by the source type; common choices are docx/pdf for docs, xlsx/csv for sheets, base for bitable, and pptx/pdf for slides")
|
||||
}
|
||||
|
||||
if spec.FileExtension == "markdown" && spec.DocType != "docx" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension markdown only supports --doc-type docx")
|
||||
}
|
||||
|
||||
if spec.FileExtension == "base" && spec.DocType != "bitable" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension base only supports --doc-type bitable")
|
||||
if err := validateDriveExportFormatCompatibility(spec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if spec.OnlySchema && (spec.DocType != "bitable" || spec.FileExtension != "base") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--only-schema is only used when exporting bitable as base").WithParam("--only-schema")
|
||||
}
|
||||
|
||||
if spec.FileExtension == "pptx" && spec.DocType != "slides" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension pptx only supports --doc-type slides")
|
||||
}
|
||||
|
||||
if spec.DocType == "slides" && spec.FileExtension != "pptx" && spec.FileExtension != "pdf" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc-type slides only supports --file-extension pptx or pdf")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--only-schema is only used when exporting bitable as base").
|
||||
WithParam("--only-schema").
|
||||
WithHint("retry with --doc-type bitable --file-extension base, or remove --only-schema")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
if spec.FileExtension != "csv" || (spec.DocType != "sheet" && spec.DocType != "bitable") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is only used when exporting sheet/bitable as csv").WithParam("--sub-id")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is only used when exporting sheet/bitable as csv").
|
||||
WithParam("--sub-id").
|
||||
WithHint("remove --sub-id, or retry with --doc-type sheet|bitable --file-extension csv")
|
||||
}
|
||||
if err := validate.ResourceName(spec.SubID, "--sub-id"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--sub-id")
|
||||
@@ -173,15 +198,213 @@ func validateDriveExportSpec(spec driveExportSpec) error {
|
||||
}
|
||||
|
||||
if spec.FileExtension == "csv" && (spec.DocType == "sheet" || spec.DocType == "bitable") && strings.TrimSpace(spec.SubID) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is required when exporting sheet/bitable as csv").WithParam("--sub-id")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is required when exporting sheet/bitable as csv").
|
||||
WithParam("--sub-id").
|
||||
WithHint("retry with --sub-id <sheet_id_or_table_id>; if you need the whole workbook, use --file-extension xlsx instead")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createDriveExportTask starts the asynchronous export job and returns its
|
||||
// ticket for subsequent polling.
|
||||
func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec) (string, error) {
|
||||
func validateDriveExportFormatCompatibility(spec driveExportSpec) error {
|
||||
if driveExportFileExtensionAllowedForDocType(spec.DocType, spec.FileExtension) {
|
||||
return nil
|
||||
}
|
||||
allowed := strings.Join(driveExportAllowedFileExtensions(spec.DocType), ", ")
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported export format: --doc-type %s cannot be exported as %s",
|
||||
spec.DocType,
|
||||
spec.FileExtension,
|
||||
).
|
||||
WithParam("--file-extension").
|
||||
WithHint("retry with --file-extension %s. If the token came from a URL, prefer --url so the CLI infers the correct source type before validating the export format", allowed)
|
||||
}
|
||||
|
||||
func driveExportFileExtensionAllowedForDocType(docType, fileExtension string) bool {
|
||||
for _, allowed := range driveExportAllowedFileExtensions(docType) {
|
||||
if fileExtension == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func driveExportAllowedFileExtensions(docType string) []string {
|
||||
switch normalizeDriveExportDocType(docType) {
|
||||
case "doc":
|
||||
return []string{"docx", "pdf"}
|
||||
case "docx":
|
||||
return []string{"docx", "pdf", "markdown"}
|
||||
case "sheet":
|
||||
return []string{"xlsx", "csv", "pdf"}
|
||||
case "bitable":
|
||||
return []string{"xlsx", "csv", "base", "pdf"}
|
||||
case "slides":
|
||||
return []string{"pptx", "pdf"}
|
||||
default:
|
||||
return []string{"docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx"}
|
||||
}
|
||||
}
|
||||
|
||||
func validateDriveExportNormalizedSpecForSource(spec driveExportSpec, source driveExportInputSource) error {
|
||||
if source.Type == "wiki" && spec.DocType == "" {
|
||||
return validateDriveExportPendingWikiSpec(spec, source)
|
||||
}
|
||||
return validateDriveExportNormalizedSpec(spec)
|
||||
}
|
||||
|
||||
func validateDriveExportPendingWikiSpec(spec driveExportSpec, source driveExportInputSource) error {
|
||||
param := source.Param
|
||||
if param == "" {
|
||||
param = "--token"
|
||||
}
|
||||
if err := validate.ResourceName(spec.Token, param); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(param)
|
||||
}
|
||||
|
||||
switch spec.FileExtension {
|
||||
case "docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx":
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-extension %q: allowed values are %s", spec.FileExtension, driveExportFileExtensionValues).
|
||||
WithParam("--file-extension").
|
||||
WithHint("Wiki export format is validated after resolving the Wiki node; choose a format normally supported by the underlying document type")
|
||||
}
|
||||
if spec.OnlySchema && spec.FileExtension != "base" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--only-schema is only used when exporting bitable as base").
|
||||
WithParam("--only-schema").
|
||||
WithHint("retry with --file-extension base, or remove --only-schema")
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" && spec.FileExtension != "csv" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is only used when exporting sheet/bitable as csv").
|
||||
WithParam("--sub-id").
|
||||
WithHint("remove --sub-id, or retry with --file-extension csv if the Wiki node resolves to a sheet/bitable")
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
if err := validate.ResourceName(spec.SubID, "--sub-id"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--sub-id")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeDriveExportSpecInput(spec driveExportSpec) (driveExportSpec, driveExportInputSource, error) {
|
||||
spec.URL = strings.TrimSpace(spec.URL)
|
||||
spec.Token = strings.TrimSpace(spec.Token)
|
||||
spec.DocType = strings.ToLower(strings.TrimSpace(spec.DocType))
|
||||
spec.FileExtension = strings.ToLower(strings.TrimSpace(spec.FileExtension))
|
||||
|
||||
if spec.Token == "" && spec.URL == "" {
|
||||
return spec, driveExportInputSource{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "either --url or --token is required").WithParam("--url")
|
||||
}
|
||||
if spec.Token != "" && spec.URL != "" {
|
||||
return spec, driveExportInputSource{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url and --token are mutually exclusive").WithParam("--url")
|
||||
}
|
||||
|
||||
source := driveExportInputSource{
|
||||
Type: spec.DocType,
|
||||
Token: spec.Token,
|
||||
Param: "--token",
|
||||
}
|
||||
|
||||
rawInput := spec.Token
|
||||
inputParam := "--token"
|
||||
if spec.URL != "" {
|
||||
rawInput = spec.URL
|
||||
inputParam = "--url"
|
||||
}
|
||||
|
||||
if ref, ok := common.ParseResourceURL(rawInput); ok {
|
||||
refType := normalizeDriveExportDocType(ref.Type)
|
||||
source = driveExportInputSource{
|
||||
Type: refType,
|
||||
Token: ref.Token,
|
||||
Param: inputParam,
|
||||
WasURL: true,
|
||||
}
|
||||
spec.Token = ref.Token
|
||||
if refType != "wiki" {
|
||||
if !isDriveExportDocType(refType) {
|
||||
return spec, source, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"%s URL type %q is not supported by drive +export; use a doc/docx/sheet/base/slides/wiki URL or token",
|
||||
inputParam,
|
||||
ref.Type,
|
||||
).WithParam(inputParam)
|
||||
}
|
||||
if spec.DocType == "wiki" {
|
||||
return spec, source, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--doc-type wiki conflicts with %s URL type %q",
|
||||
inputParam,
|
||||
refType,
|
||||
).
|
||||
WithParam("--doc-type").
|
||||
WithHint("remove --doc-type when passing --url; the CLI will infer %q from the URL", refType)
|
||||
}
|
||||
if spec.DocType != "" && spec.DocType != refType {
|
||||
return spec, source, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--doc-type %q conflicts with %s URL type %q",
|
||||
spec.DocType,
|
||||
inputParam,
|
||||
refType,
|
||||
).WithParam("--doc-type")
|
||||
}
|
||||
spec.DocType = refType
|
||||
} else if spec.DocType == "wiki" {
|
||||
spec.DocType = ""
|
||||
}
|
||||
return spec, source, nil
|
||||
}
|
||||
|
||||
if strings.Contains(rawInput, "://") {
|
||||
return spec, source, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported %s URL %q: use a recognized Lark document URL",
|
||||
inputParam,
|
||||
rawInput,
|
||||
).WithParam(inputParam)
|
||||
}
|
||||
if spec.URL != "" {
|
||||
return spec, source, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported --url %q: use a recognized Lark document URL",
|
||||
spec.URL,
|
||||
).WithParam("--url")
|
||||
}
|
||||
if spec.DocType == "" {
|
||||
return spec, source, errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc-type is required when --token is a bare token (allowed: %s)", driveExportInputDocTypeValues).
|
||||
WithParam("--doc-type").
|
||||
WithHint("if you have the original document link, prefer --url <document_url>; if this is a Wiki node token, use --doc-type wiki")
|
||||
}
|
||||
if spec.DocType == "wiki" {
|
||||
source.Type = "wiki"
|
||||
source.Token = spec.Token
|
||||
spec.DocType = ""
|
||||
}
|
||||
return spec, source, nil
|
||||
}
|
||||
|
||||
func normalizeDriveExportDocType(docType string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(docType)) {
|
||||
case "base":
|
||||
return "bitable"
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(docType))
|
||||
}
|
||||
}
|
||||
|
||||
func isDriveExportDocType(docType string) bool {
|
||||
switch normalizeDriveExportDocType(docType) {
|
||||
case "doc", "docx", "sheet", "bitable", "slides":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func buildDriveExportTaskBody(spec driveExportSpec) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
@@ -193,8 +416,13 @@ func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec)
|
||||
if spec.OnlySchema {
|
||||
body["only_schema"] = true
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, body)
|
||||
// createDriveExportTask starts the asynchronous export job and returns its
|
||||
// ticket for subsequent polling.
|
||||
func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec) (string, error) {
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, buildDriveExportTaskBody(spec))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -206,6 +434,99 @@ func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec)
|
||||
return ticket, nil
|
||||
}
|
||||
|
||||
func resolveDriveExportWikiSource(ctx context.Context, runtime *common.RuntimeContext, spec driveExportSpec, wikiToken string) (driveExportSpec, driveExportWikiResolution, error) {
|
||||
wikiToken = strings.TrimSpace(wikiToken)
|
||||
if err := validate.ResourceName(wikiToken, "--token"); err != nil {
|
||||
return spec, driveExportWikiResolution{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node for export: %s\n", common.MaskToken(wikiToken))
|
||||
data, err := driveInspectCallWithRetry(ctx, func() (map[string]interface{}, error) {
|
||||
return runtime.CallAPITyped(
|
||||
"GET",
|
||||
"/open-apis/wiki/v2/spaces/get_node",
|
||||
map[string]interface{}{"token": wikiToken},
|
||||
nil,
|
||||
)
|
||||
})
|
||||
if err != nil {
|
||||
return spec, driveExportWikiResolution{}, err
|
||||
}
|
||||
|
||||
node := common.GetMap(data, "node")
|
||||
objType := normalizeDriveExportDocType(common.GetString(node, "obj_type"))
|
||||
objToken := common.GetString(node, "obj_token")
|
||||
if objType == "" || objToken == "" {
|
||||
return spec, driveExportWikiResolution{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data (obj_type=%q, obj_token=%q)", objType, objToken)
|
||||
}
|
||||
if !isDriveExportDocType(objType) {
|
||||
return spec, driveExportWikiResolution{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"wiki resolved to %q, but drive +export only supports doc, docx, sheet, bitable, and slides",
|
||||
objType,
|
||||
).WithParam("--token")
|
||||
}
|
||||
if spec.DocType != "" && spec.DocType != objType {
|
||||
return spec, driveExportWikiResolution{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"wiki resolved to %q, but --doc-type is %q; use --doc-type %s",
|
||||
objType,
|
||||
spec.DocType,
|
||||
objType,
|
||||
).WithParam("--doc-type")
|
||||
}
|
||||
|
||||
spec.Token = objToken
|
||||
spec.DocType = objType
|
||||
if err := validateDriveExportNormalizedSpec(spec); err != nil {
|
||||
return spec, driveExportWikiResolution{}, err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
|
||||
return spec, driveExportWikiResolution{
|
||||
Resolved: true,
|
||||
WikiToken: wikiToken,
|
||||
ObjToken: objToken,
|
||||
ObjType: objType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func createDriveExportTaskWithWikiFallback(ctx context.Context, runtime *common.RuntimeContext, spec driveExportSpec, source driveExportInputSource) (string, driveExportSpec, driveExportWikiResolution, error) {
|
||||
if source.Type == "wiki" {
|
||||
resolvedSpec, resolution, err := resolveDriveExportWikiSource(ctx, runtime, spec, source.Token)
|
||||
if err != nil {
|
||||
return "", spec, resolution, err
|
||||
}
|
||||
ticket, err := createDriveExportTask(runtime, resolvedSpec)
|
||||
return ticket, resolvedSpec, resolution, err
|
||||
}
|
||||
|
||||
ticket, err := createDriveExportTask(runtime, spec)
|
||||
if err == nil {
|
||||
return ticket, spec, driveExportWikiResolution{}, nil
|
||||
}
|
||||
if source.WasURL || !shouldRetryDriveExportAsWiki(err) {
|
||||
return "", spec, driveExportWikiResolution{}, err
|
||||
}
|
||||
|
||||
resolvedSpec, resolution, resolveErr := resolveDriveExportWikiSource(ctx, runtime, spec, spec.Token)
|
||||
if resolveErr != nil {
|
||||
return "", spec, driveExportWikiResolution{}, appendDriveExportRecoveryHint(
|
||||
err,
|
||||
fmt.Sprintf("export task rejected --token; attempted wiki node resolution also failed: %v", resolveErr),
|
||||
)
|
||||
}
|
||||
ticket, retryErr := createDriveExportTask(runtime, resolvedSpec)
|
||||
return ticket, resolvedSpec, resolution, retryErr
|
||||
}
|
||||
|
||||
func shouldRetryDriveExportAsWiki(err error) bool {
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem == nil {
|
||||
return false
|
||||
}
|
||||
return problem.Code == 1069914
|
||||
}
|
||||
|
||||
// getDriveExportStatus fetches the current backend state for a previously
|
||||
// created export task.
|
||||
func getDriveExportStatus(runtime *common.RuntimeContext, token, ticket string) (driveExportStatus, error) {
|
||||
|
||||
@@ -33,10 +33,36 @@ func TestValidateDriveExportSpec(t *testing.T) {
|
||||
name: "markdown docx ok",
|
||||
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "markdown"},
|
||||
},
|
||||
{
|
||||
name: "docx url infers doc type",
|
||||
spec: driveExportSpec{URL: "https://example.feishu.cn/docx/docxURL123", FileExtension: "pdf"},
|
||||
},
|
||||
{
|
||||
name: "wiki url can defer doc type until resolution",
|
||||
spec: driveExportSpec{URL: "https://example.feishu.cn/wiki/wikiURL123", FileExtension: "pdf"},
|
||||
},
|
||||
{
|
||||
name: "wiki url with doc-type wiki can defer doc type until resolution",
|
||||
spec: driveExportSpec{URL: "https://example.feishu.cn/wiki/wikiURL123", DocType: "wiki", FileExtension: "pdf"},
|
||||
},
|
||||
{
|
||||
name: "wiki token with doc-type wiki can defer doc type until resolution",
|
||||
spec: driveExportSpec{Token: "wiki123", DocType: "wiki", FileExtension: "pdf"},
|
||||
},
|
||||
{
|
||||
name: "bare token requires doc type",
|
||||
spec: driveExportSpec{Token: "docx123", FileExtension: "pdf"},
|
||||
wantErr: "--doc-type is required",
|
||||
},
|
||||
{
|
||||
name: "markdown non docx rejected",
|
||||
spec: driveExportSpec{Token: "doc123", DocType: "doc", FileExtension: "markdown"},
|
||||
wantErr: "only supports --doc-type docx",
|
||||
wantErr: "cannot be exported as markdown",
|
||||
},
|
||||
{
|
||||
name: "docx csv rejected",
|
||||
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "csv"},
|
||||
wantErr: "cannot be exported as csv",
|
||||
},
|
||||
{
|
||||
name: "csv without sub id rejected",
|
||||
@@ -72,17 +98,17 @@ func TestValidateDriveExportSpec(t *testing.T) {
|
||||
{
|
||||
name: "base non bitable rejected",
|
||||
spec: driveExportSpec{Token: "sheet123", DocType: "sheet", FileExtension: "base"},
|
||||
wantErr: "only supports --doc-type bitable",
|
||||
wantErr: "cannot be exported as base",
|
||||
},
|
||||
{
|
||||
name: "pptx non slides rejected",
|
||||
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "pptx"},
|
||||
wantErr: "only supports --doc-type slides",
|
||||
wantErr: "cannot be exported as pptx",
|
||||
},
|
||||
{
|
||||
name: "slides csv rejected",
|
||||
spec: driveExportSpec{Token: "slides123", DocType: "slides", FileExtension: "csv"},
|
||||
wantErr: "slides only supports",
|
||||
wantErr: "cannot be exported as csv",
|
||||
},
|
||||
{
|
||||
name: "unknown doc type rejected",
|
||||
@@ -113,6 +139,29 @@ func TestValidateDriveExportSpec(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDriveExportUnsupportedFormatHasHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateDriveExportSpec(driveExportSpec{
|
||||
Token: "docx123",
|
||||
DocType: "docx",
|
||||
FileExtension: "csv",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unsupported format error, got nil")
|
||||
}
|
||||
var valErr *errs.ValidationError
|
||||
if !errors.As(err, &valErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if valErr.Param != "--file-extension" {
|
||||
t.Fatalf("param = %q, want --file-extension", valErr.Param)
|
||||
}
|
||||
if !strings.Contains(valErr.Hint, "docx, pdf, markdown") || !strings.Contains(valErr.Hint, "--url") {
|
||||
t.Fatalf("hint = %q, want allowed formats and URL retry guidance", valErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportMarkdownWritesFile(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
fetchStub := &httpmock.Stub{
|
||||
@@ -440,6 +489,76 @@ func TestDriveExportMarkdownRejectsMissingDocumentContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportURLInfersDocType(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_url"},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_url",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0,
|
||||
"file_token": "box_url",
|
||||
"file_name": "url-report",
|
||||
"file_extension": "pdf",
|
||||
"type": "docx",
|
||||
"file_size": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_url/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="url-report.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--url", "https://example.feishu.cn/docx/docxURL123",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var createBody map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
|
||||
t.Fatalf("unmarshal export_tasks body: %v", err)
|
||||
}
|
||||
if createBody["token"] != "docxURL123" {
|
||||
t.Fatalf("export_tasks body token = %v, want token from URL", createBody["token"])
|
||||
}
|
||||
if createBody["type"] != "docx" {
|
||||
t.Fatalf("export_tasks body type = %v, want inferred docx", createBody["type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -510,6 +629,318 @@ func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportWikiURLResolvesBeforeAsyncTask(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "docx",
|
||||
"obj_token": "docxResolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_wiki"},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_wiki",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0,
|
||||
"file_token": "box_wiki",
|
||||
"file_name": "wiki-report",
|
||||
"file_extension": "pdf",
|
||||
"type": "docx",
|
||||
"file_size": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_wiki/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="wiki-report.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--url", "https://example.feishu.cn/wiki/wikiNode123",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var createBody map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
|
||||
t.Fatalf("unmarshal export_tasks body: %v", err)
|
||||
}
|
||||
if createBody["token"] != "docxResolved" {
|
||||
t.Fatalf("export_tasks body token = %v, want resolved docx token", createBody["token"])
|
||||
}
|
||||
if createBody["type"] != "docx" {
|
||||
t.Fatalf("export_tasks body type = %v, want docx", createBody["type"])
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"wiki_token": "wikiNode123"`) {
|
||||
t.Fatalf("stdout missing wiki token context: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportBareWikiTypeResolvesBeforeAsyncTask(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "docx",
|
||||
"obj_token": "docxResolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_wiki_token"},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_wiki_token",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0,
|
||||
"file_token": "box_wiki_token",
|
||||
"file_name": "wiki-token-report",
|
||||
"file_extension": "pdf",
|
||||
"type": "docx",
|
||||
"file_size": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_wiki_token/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="wiki-token-report.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "wikiNodeBare",
|
||||
"--doc-type", "wiki",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var createBody map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
|
||||
t.Fatalf("unmarshal export_tasks body: %v", err)
|
||||
}
|
||||
if createBody["token"] != "docxResolved" {
|
||||
t.Fatalf("export_tasks body token = %v, want resolved docx token", createBody["token"])
|
||||
}
|
||||
if createBody["type"] != "docx" {
|
||||
t.Fatalf("export_tasks body type = %v, want resolved docx type", createBody["type"])
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"wiki_token": "wikiNodeBare"`) {
|
||||
t.Fatalf("stdout missing wiki token context: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportBareWikiTokenFallbackAfterFileTokenInvalid(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
firstCreate := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Status: 404,
|
||||
Body: map[string]interface{}{
|
||||
"code": 1069914,
|
||||
"msg": "file token invalid",
|
||||
"log_id": "20260708000000TEST",
|
||||
},
|
||||
BodyFilter: func(body []byte) bool {
|
||||
return strings.Contains(string(body), `"token":"wikiNodeBare"`)
|
||||
},
|
||||
}
|
||||
reg.Register(firstCreate)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "docx",
|
||||
"obj_token": "docxResolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
retryCreate := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_retry"},
|
||||
},
|
||||
BodyFilter: func(body []byte) bool {
|
||||
return strings.Contains(string(body), `"token":"docxResolved"`)
|
||||
},
|
||||
}
|
||||
reg.Register(retryCreate)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_retry",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0,
|
||||
"file_token": "box_retry",
|
||||
"file_name": "retry-report",
|
||||
"file_extension": "pdf",
|
||||
"type": "docx",
|
||||
"file_size": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_retry/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="retry-report.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "wikiNodeBare",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(firstCreate.CapturedBody) == 0 {
|
||||
t.Fatal("first export task request was not sent with the original token")
|
||||
}
|
||||
var retryBody map[string]interface{}
|
||||
if err := json.Unmarshal(retryCreate.CapturedBody, &retryBody); err != nil {
|
||||
t.Fatalf("unmarshal retry export_tasks body: %v", err)
|
||||
}
|
||||
if retryBody["token"] != "docxResolved" {
|
||||
t.Fatalf("retry export_tasks body token = %v, want resolved docx token", retryBody["token"])
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"wiki_token": "wikiNodeBare"`) {
|
||||
t.Fatalf("stdout missing wiki token context: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportWikiResolvedTypeMismatch(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "sheet",
|
||||
"obj_token": "shtResolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "https://example.feishu.cn/wiki/wikiSheet123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected type mismatch error, got nil")
|
||||
}
|
||||
var valErr *errs.ValidationError
|
||||
if !errors.As(err, &valErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(valErr.Message, `wiki resolved to "sheet"`) {
|
||||
t.Fatalf("error message = %q, want resolved type", valErr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveExportEmptyOutputDirDownloadsToCwd guards the export refactor: an
|
||||
// explicit empty --output-dir must still download to the current directory
|
||||
// (normalized to "."), not trigger the export-only no-download path that the
|
||||
|
||||
@@ -28,7 +28,12 @@ import (
|
||||
const minutesDetailLogPrefix = "[minutes +detail]"
|
||||
|
||||
// Error codes from the minutes API.
|
||||
const minutesDetailNoReadPermissionCode = 2091005
|
||||
const (
|
||||
minutesDetailProcessingCode = 2091003
|
||||
minutesDetailNoReadPermissionCode = 2091005
|
||||
minutesDetailWaitTimeoutDefault = 300
|
||||
minutesDetailWaitIntervalDefault = 15
|
||||
)
|
||||
|
||||
var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)
|
||||
|
||||
@@ -40,19 +45,31 @@ var scopesDetailMinuteTokens = []string{
|
||||
// minuteDetailItem represents a single minute detail result.
|
||||
type minuteDetailItem struct {
|
||||
MinuteToken string `json:"minute_token"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Title string `json:"title"`
|
||||
NoteID string `json:"note_id"`
|
||||
Artifacts map[string]any `json:"artifacts,omitempty"`
|
||||
Retryable bool `json:"retryable,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
NextCommand string `json:"next_command,omitempty"`
|
||||
}
|
||||
|
||||
// fetchMinuteDetail queries a single minute's metadata and selected artifacts.
|
||||
func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) *minuteDetailItem {
|
||||
data, err := runtime.CallAPITyped(http.MethodGet,
|
||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil)
|
||||
artifactFlags := requestedMinutesDetailArtifactFlags(runtime)
|
||||
waitReady := runtime.Bool("wait-ready")
|
||||
waitTimeout, waitInterval := minutesDetailWaitConfig(runtime)
|
||||
|
||||
data, err := callMinutesDetailAPIUntilReady(ctx, runtime, waitReady, waitTimeout, waitInterval, func() (map[string]interface{}, error) {
|
||||
return runtime.CallAPITyped(http.MethodGet,
|
||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil)
|
||||
})
|
||||
if err != nil {
|
||||
result := &minuteDetailItem{MinuteToken: minuteToken}
|
||||
if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailNoReadPermissionCode {
|
||||
if isMinutesDetailProcessingError(err) {
|
||||
markMinutesDetailProcessing(result, minuteToken, artifactFlags, "minute metadata is still being generated")
|
||||
} else if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailNoReadPermissionCode {
|
||||
result.Error = fmt.Sprintf("No read permission for minute %s. Ask the minute owner for minute file read permission", minuteToken)
|
||||
} else {
|
||||
result.Error = fmt.Sprintf("failed to query minute: %v", err)
|
||||
@@ -81,10 +98,16 @@ func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minu
|
||||
needKeyword := runtime.Bool("keyword")
|
||||
|
||||
if needSummary || needTodo || needChapter || needTranscript || needKeyword {
|
||||
artData, err := runtime.CallAPITyped(http.MethodGet,
|
||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil)
|
||||
artData, err := callMinutesDetailAPIUntilReady(ctx, runtime, waitReady, waitTimeout, waitInterval, func() (map[string]interface{}, error) {
|
||||
return runtime.CallAPITyped(http.MethodGet,
|
||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil)
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "%s failed to fetch artifacts for %s: %v\n", minutesDetailLogPrefix, minuteToken, err)
|
||||
if isMinutesDetailProcessingError(err) {
|
||||
markMinutesDetailProcessing(result, minuteToken, artifactFlags, "minute artifacts are still being generated")
|
||||
} else {
|
||||
result.Error = fmt.Sprintf("failed to query minute artifacts: %v", err)
|
||||
}
|
||||
} else {
|
||||
artifacts := make(map[string]any)
|
||||
if needSummary {
|
||||
@@ -133,6 +156,78 @@ func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minu
|
||||
return result
|
||||
}
|
||||
|
||||
func isMinutesDetailProcessingError(err error) bool {
|
||||
if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailProcessingCode {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func minutesDetailWaitConfig(runtime *common.RuntimeContext) (time.Duration, time.Duration) {
|
||||
timeoutSeconds, intervalSeconds := normalizeMinutesDetailWaitSeconds(runtime.Int("wait-timeout-seconds"), runtime.Int("wait-interval-seconds"))
|
||||
return time.Duration(timeoutSeconds) * time.Second, time.Duration(intervalSeconds) * time.Second
|
||||
}
|
||||
|
||||
func normalizeMinutesDetailWaitSeconds(timeoutSeconds, intervalSeconds int) (int, int) {
|
||||
if timeoutSeconds <= 0 {
|
||||
timeoutSeconds = minutesDetailWaitTimeoutDefault
|
||||
}
|
||||
if intervalSeconds <= 0 {
|
||||
intervalSeconds = minutesDetailWaitIntervalDefault
|
||||
}
|
||||
return timeoutSeconds, intervalSeconds
|
||||
}
|
||||
|
||||
func callMinutesDetailAPIUntilReady(ctx context.Context, runtime *common.RuntimeContext, waitReady bool, timeout, interval time.Duration, call func() (map[string]interface{}, error)) (map[string]interface{}, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
data, err := call()
|
||||
if err == nil || !waitReady || !isMinutesDetailProcessingError(err) {
|
||||
return data, err
|
||||
}
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return nil, ctxErr
|
||||
}
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 || interval > remaining {
|
||||
return nil, err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "%s minute is still processing; retrying in %s\n", minutesDetailLogPrefix, interval)
|
||||
timer := time.NewTimer(interval)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return nil, ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requestedMinutesDetailArtifactFlags(runtime *common.RuntimeContext) []string {
|
||||
var flags []string
|
||||
for _, flag := range []string{"summary", "todo", "chapter", "keyword", "transcript"} {
|
||||
if runtime.Bool(flag) {
|
||||
flags = append(flags, "--"+flag)
|
||||
}
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
func markMinutesDetailProcessing(result *minuteDetailItem, minuteToken string, artifactFlags []string, reason string) {
|
||||
result.Status = "processing"
|
||||
result.Retryable = true
|
||||
result.Error = reason
|
||||
result.Hint = "The minute is still being generated. Retry later, or rerun the next_command to wait until it is ready."
|
||||
result.NextCommand = minutesDetailNextCommand(minuteToken, artifactFlags)
|
||||
}
|
||||
|
||||
func minutesDetailNextCommand(minuteToken string, artifactFlags []string) string {
|
||||
parts := []string{"lark-cli", "minutes", "+detail", "--minute-tokens", minuteToken}
|
||||
parts = append(parts, artifactFlags...)
|
||||
parts = append(parts, "--wait-ready")
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// saveDetailTranscript persists transcript bytes to the canonical artifact path.
|
||||
// With --output-dir, transcripts land under <output-dir>/artifact-<title>-<token>/
|
||||
// to mirror the legacy `vc +notes` layout. Otherwise falls back to the default
|
||||
@@ -201,6 +296,9 @@ var MinutesDetail = common.Shortcut{
|
||||
{Name: "keyword", Type: "bool", Desc: "include keywords"},
|
||||
{Name: "output-dir", Desc: "output directory for transcript files (default: ./minutes/{minute_token}/)"},
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing transcript files"},
|
||||
{Name: "wait-ready", Type: "bool", Desc: "wait until minute metadata/artifacts are ready", Hidden: true},
|
||||
{Name: "wait-timeout-seconds", Type: "int", Default: "300", Desc: "maximum seconds to wait for readiness", Hidden: true},
|
||||
{Name: "wait-interval-seconds", Type: "int", Default: "15", Desc: "seconds between readiness checks", Hidden: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
tokens := common.SplitCSV(runtime.Str("minute-tokens"))
|
||||
@@ -282,8 +380,15 @@ var MinutesDetail = common.Shortcut{
|
||||
for _, r := range results {
|
||||
row := map[string]interface{}{"minute_token": r.MinuteToken}
|
||||
if r.Error != "" {
|
||||
row["status"] = "FAIL"
|
||||
if r.Status == "processing" {
|
||||
row["status"] = "PROCESSING"
|
||||
} else {
|
||||
row["status"] = "FAIL"
|
||||
}
|
||||
row["error"] = r.Error
|
||||
if r.NextCommand != "" {
|
||||
row["next_command"] = r.NextCommand
|
||||
}
|
||||
} else {
|
||||
row["status"] = "OK"
|
||||
row["title"] = r.Title
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -108,6 +109,17 @@ func detailArtifactsStub(token, transcript string) *httpmock.Stub {
|
||||
}
|
||||
}
|
||||
|
||||
func detailProcessingStub(path string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: path,
|
||||
Body: map[string]interface{}{
|
||||
"code": 2091003,
|
||||
"msg": "minute is processing",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Validation_MissingMinuteTokens(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--as", "user"}, f, nil)
|
||||
@@ -172,6 +184,34 @@ func TestDetail_DryRun_WithArtifactFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_HiddenWaitFlags(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
parent := &cobra.Command{Use: "minutes"}
|
||||
MinutesDetail.Mount(parent, f)
|
||||
parent.SetOut(stdout)
|
||||
parent.SetArgs([]string{"+detail", "--help"})
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if err := parent.Execute(); err != nil {
|
||||
t.Fatalf("help failed: %v", err)
|
||||
}
|
||||
help := stdout.String()
|
||||
for _, hidden := range []string{"wait-ready", "wait-timeout-seconds", "wait-interval-seconds"} {
|
||||
if strings.Contains(help, hidden) {
|
||||
t.Fatalf("hidden flag %q should not appear in help:\n%s", hidden, help)
|
||||
}
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||
"+detail", "--minute-tokens", "tok001", "--summary", "--wait-ready",
|
||||
"--wait-timeout-seconds", "0", "--wait-interval-seconds", "0", "--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("hidden wait flags should parse: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execute tests with mocked HTTP
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -355,6 +395,136 @@ func TestDetail_Execute_MinuteNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Execute_MetadataProcessing(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokpending"))
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokpending", "--summary", "--as", "user"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected partial failure error")
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
m := firstDetailMinute(t, stdout.Bytes())
|
||||
if m["status"] != "processing" {
|
||||
t.Fatalf("status = %v, want processing", m["status"])
|
||||
}
|
||||
if m["retryable"] != true {
|
||||
t.Fatalf("retryable = %v, want true", m["retryable"])
|
||||
}
|
||||
if !strings.Contains(fmt.Sprint(m["next_command"]), "minutes +detail --minute-tokens tokpending --summary --wait-ready") {
|
||||
t.Fatalf("next_command = %v", m["next_command"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Execute_ArtifactsProcessing(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(detailMinuteGetStub("tokartpending", "note_pending", "Pending Artifacts"))
|
||||
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokartpending/artifacts"))
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokartpending", "--summary", "--todo", "--as", "user"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected partial failure error")
|
||||
}
|
||||
m := firstDetailMinute(t, stdout.Bytes())
|
||||
if m["status"] != "processing" {
|
||||
t.Fatalf("status = %v, want processing", m["status"])
|
||||
}
|
||||
if m["title"] != "Pending Artifacts" || m["note_id"] != "note_pending" {
|
||||
t.Fatalf("metadata should be preserved on artifacts processing, got title=%v note_id=%v", m["title"], m["note_id"])
|
||||
}
|
||||
if !strings.Contains(fmt.Sprint(m["next_command"]), "--summary --todo --wait-ready") {
|
||||
t.Fatalf("next_command = %v", m["next_command"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_WaitReady_MetadataEventuallyReady(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokwaitmeta"))
|
||||
reg.Register(detailMinuteGetStub("tokwaitmeta", "", "Ready Metadata"))
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||
"+detail", "--minute-tokens", "tokwaitmeta", "--wait-ready",
|
||||
"--wait-timeout-seconds", "5", "--wait-interval-seconds", "1", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
m := firstDetailMinute(t, stdout.Bytes())
|
||||
if m["title"] != "Ready Metadata" {
|
||||
t.Fatalf("title = %v, want Ready Metadata", m["title"])
|
||||
}
|
||||
if _, ok := m["artifacts"]; ok {
|
||||
t.Fatal("artifacts should not be fetched without artifact flags")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_WaitReady_ArtifactsEventuallyReady(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(detailMinuteGetStub("tokwaitart", "note_wait", "Ready Artifacts"))
|
||||
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokwaitart/artifacts"))
|
||||
reg.Register(detailArtifactsStub("tokwaitart", ""))
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||
"+detail", "--minute-tokens", "tokwaitart", "--summary", "--wait-ready",
|
||||
"--wait-timeout-seconds", "5", "--wait-interval-seconds", "1", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
m := firstDetailMinute(t, stdout.Bytes())
|
||||
arts, _ := m["artifacts"].(map[string]any)
|
||||
if arts == nil {
|
||||
t.Fatal("expected artifacts")
|
||||
}
|
||||
if arts["summary"] != "Test summary content" {
|
||||
t.Fatalf("summary = %v", arts["summary"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_WaitReady_TimeoutUsesProcessingResult(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(detailMinuteGetStub("toktimeout", "note_timeout", "Timeout Artifacts"))
|
||||
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/toktimeout/artifacts"))
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||
"+detail", "--minute-tokens", "toktimeout", "--summary", "--wait-ready",
|
||||
"--wait-timeout-seconds", "1", "--wait-interval-seconds", "2", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected partial failure error")
|
||||
}
|
||||
m := firstDetailMinute(t, stdout.Bytes())
|
||||
if m["status"] != "processing" || m["title"] != "Timeout Artifacts" || m["note_id"] != "note_timeout" {
|
||||
t.Fatalf("timeout should preserve processing status and metadata, got %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_WaitReady_DoesNotPollNonProcessingErrors(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
var callCount int
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/minutes/v1/minutes/tokmissing",
|
||||
Body: map[string]interface{}{"code": 2091004, "msg": "not found"},
|
||||
Reusable: true,
|
||||
OnMatch: func(req *http.Request) { callCount++ },
|
||||
})
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||
"+detail", "--minute-tokens", "tokmissing", "--wait-ready",
|
||||
"--wait-timeout-seconds", "5", "--wait-interval-seconds", "1", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected partial failure error")
|
||||
}
|
||||
if callCount != 1 {
|
||||
t.Fatalf("non-processing error should not be retried, callCount=%d", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure function tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -378,6 +548,36 @@ func TestValidMinuteTokenDetail(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMinutesDetailWaitSeconds(t *testing.T) {
|
||||
timeout, interval := normalizeMinutesDetailWaitSeconds(0, 0)
|
||||
if timeout != minutesDetailWaitTimeoutDefault || interval != minutesDetailWaitIntervalDefault {
|
||||
t.Fatalf("normalize(0,0) = (%d,%d), want defaults (%d,%d)", timeout, interval, minutesDetailWaitTimeoutDefault, minutesDetailWaitIntervalDefault)
|
||||
}
|
||||
timeout, interval = normalizeMinutesDetailWaitSeconds(-1, -2)
|
||||
if timeout != minutesDetailWaitTimeoutDefault || interval != minutesDetailWaitIntervalDefault {
|
||||
t.Fatalf("normalize(negative) = (%d,%d), want defaults", timeout, interval)
|
||||
}
|
||||
timeout, interval = normalizeMinutesDetailWaitSeconds(9, 3)
|
||||
if timeout != 9 || interval != 3 {
|
||||
t.Fatalf("normalize(9,3) = (%d,%d)", timeout, interval)
|
||||
}
|
||||
}
|
||||
|
||||
func firstDetailMinute(t *testing.T, raw []byte) map[string]any {
|
||||
t.Helper()
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
t.Fatalf("failed to parse output: %v\n%s", err, string(raw))
|
||||
}
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
minutes, _ := data["minutes"].([]any)
|
||||
if len(minutes) != 1 {
|
||||
t.Fatalf("expected 1 minute, got %d in %s", len(minutes), string(raw))
|
||||
}
|
||||
m, _ := minutes[0].(map[string]any)
|
||||
return m
|
||||
}
|
||||
|
||||
// chdirForDetailTest switches cwd to a temp dir for the test.
|
||||
func chdirForDetailTest(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
@@ -5,6 +5,8 @@ package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
@@ -65,8 +67,25 @@ var MinutesUpload = common.Shortcut{
|
||||
outData := map[string]interface{}{
|
||||
"minute_url": minuteURL,
|
||||
}
|
||||
if minuteToken := extractUploadedMinuteToken(minuteURL); minuteToken != "" {
|
||||
outData["minute_token"] = minuteToken
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func extractUploadedMinuteToken(minuteURL string) string {
|
||||
u, err := url.Parse(minuteURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(strings.TrimRight(u.Path, "/"), "/")
|
||||
for i, part := range parts {
|
||||
if part == "minutes" && i+1 < len(parts) {
|
||||
return parts[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -143,4 +143,28 @@ func TestMinutesUpload_Execute(t *testing.T) {
|
||||
if dataMap["minute_url"] != "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c" {
|
||||
t.Errorf("expected minute_url https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c, got %v", dataMap["minute_url"])
|
||||
}
|
||||
if dataMap["minute_token"] != "obcnq3b9jl72l83w4f149w9c" {
|
||||
t.Errorf("expected minute_token obcnq3b9jl72l83w4f149w9c, got %v", dataMap["minute_token"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractUploadedMinuteToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
want string
|
||||
}{
|
||||
{name: "standard", url: "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c", want: "obcnq3b9jl72l83w4f149w9c"},
|
||||
{name: "query", url: "https://sample.feishu.cn/minutes/obcn123?from=upload", want: "obcn123"},
|
||||
{name: "trailing slash", url: "https://sample.feishu.cn/minutes/obcn123/", want: "obcn123"},
|
||||
{name: "invalid", url: "://bad", want: ""},
|
||||
{name: "no minutes path", url: "https://sample.feishu.cn/docx/abc", want: ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := extractUploadedMinuteToken(tt.url); got != tt.want {
|
||||
t.Fatalf("extractUploadedMinuteToken(%q) = %q, want %q", tt.url, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,10 +22,7 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSlidesScreenshotDir = ".lark-slides/screenshots"
|
||||
maxSlidesPerScreenshot = 10
|
||||
)
|
||||
const defaultSlidesScreenshotDir = ".lark-slides/screenshots"
|
||||
|
||||
var unsafeScreenshotFileCharRegex = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
|
||||
|
||||
@@ -35,7 +32,7 @@ var unsafeScreenshotFileCharRegex = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
|
||||
var SlidesScreenshot = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+screenshot",
|
||||
Description: "Save up to 10 slide screenshots to local files without printing Base64 image data",
|
||||
Description: "Save slide screenshots to local files without printing Base64 image data",
|
||||
Risk: "read",
|
||||
Scopes: []string{},
|
||||
// The screenshot API is allowlist-gated for only a few apps, so do not
|
||||
@@ -45,8 +42,8 @@ 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-number", Type: "int_array", Desc: "slide page number (repeat for multiple slides; max 10 pages per request)"},
|
||||
{Name: "slide-id", Type: "string_array", Desc: "slide page identifier (repeat for multiple slides)"},
|
||||
{Name: "slide-number", Type: "int_array", Desc: "slide page number (repeat for multiple slides)"},
|
||||
{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"},
|
||||
{Name: "output-name", Desc: "file name stem for --content render output"},
|
||||
@@ -73,17 +70,12 @@ var SlidesScreenshot = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
}
|
||||
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
|
||||
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
|
||||
if err != nil {
|
||||
if _, err := normalizeSlideNumbers(runtime.IntArray("slide-number")); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(slideIDs) == 0 && len(slideNumbers) == 0 {
|
||||
if !hasSlideScreenshotSelector(runtime) {
|
||||
return slidesScreenshotFlagErrorf("--slide-id or --slide-number is required")
|
||||
}
|
||||
if err := validateSlidesScreenshotSelectorLimit(len(slideIDs) + len(slideNumbers)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := validateScreenshotOutputDir(runtime, runtime.Str("output-dir")); err != nil {
|
||||
return err
|
||||
@@ -106,9 +98,6 @@ var SlidesScreenshot = common.Shortcut{
|
||||
if len(slideIDs) == 0 && len(slideNumbers) == 0 {
|
||||
return common.NewDryRunAPI().Set("error", "--slide-id or --slide-number is required")
|
||||
}
|
||||
if err := validateSlidesScreenshotSelectorLimit(len(slideIDs) + len(slideNumbers)); err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
|
||||
presentationID := ref.Token
|
||||
dry := common.NewDryRunAPI()
|
||||
@@ -156,9 +145,6 @@ var SlidesScreenshot = common.Shortcut{
|
||||
if len(slideIDs) == 0 && len(slideNumbers) == 0 {
|
||||
return slidesScreenshotFlagErrorf("--slide-id or --slide-number is required")
|
||||
}
|
||||
if err := validateSlidesScreenshotSelectorLimit(len(slideIDs) + len(slideNumbers)); err != nil {
|
||||
return err
|
||||
}
|
||||
outputDir := runtime.Str("output-dir")
|
||||
safeOutputDir, err := ensureScreenshotOutputDir(runtime, outputDir)
|
||||
if err != nil {
|
||||
@@ -281,11 +267,8 @@ func normalizeSlideNumbers(values []int) ([]int, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validateSlidesScreenshotSelectorLimit(count int) error {
|
||||
if count > maxSlidesPerScreenshot {
|
||||
return slidesScreenshotFlagErrorf("too many slide selectors: got %d, maximum is %d; request at most 10 pages at a time", count, maxSlidesPerScreenshot)
|
||||
}
|
||||
return nil
|
||||
func hasSlideScreenshotSelector(runtime *common.RuntimeContext) bool {
|
||||
return len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0
|
||||
}
|
||||
|
||||
func slidesScreenshotFlagErrorf(format string, args ...interface{}) error {
|
||||
|
||||
@@ -271,33 +271,6 @@ func TestSlidesScreenshotListRequiresSelector(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotListRejectsMoreThanTenSelectors(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-number", "1",
|
||||
"--slide-number", "2",
|
||||
"--slide-number", "3",
|
||||
"--slide-number", "4",
|
||||
"--slide-number", "5",
|
||||
"--slide-number", "6",
|
||||
"--slide-number", "7",
|
||||
"--slide-number", "8",
|
||||
"--slide-number", "9",
|
||||
"--slide-number", "10",
|
||||
"--slide-number", "11",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "request at most 10 pages at a time") {
|
||||
t.Fatalf("error = %v, want max 10 pages guidance", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotRenderContentWritesFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
@@ -15,13 +15,12 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlidesXMLGet fetches the full XML presentation content. When --output is
|
||||
// provided it writes to a local file; otherwise it returns the XML in the
|
||||
// standard JSON envelope. Use --raw for direct XML stdout.
|
||||
// SlidesXMLGet fetches the full XML presentation content and writes it to a
|
||||
// local file, keeping the terminal output small for large decks.
|
||||
var SlidesXMLGet = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+xml-get",
|
||||
Description: "Fetch full presentation XML",
|
||||
Description: "Fetch full presentation XML and save it to a local file",
|
||||
Risk: "read",
|
||||
Scopes: []string{"slides:presentation:read"},
|
||||
// wiki:node:read is required only when --presentation is a wiki URL.
|
||||
@@ -29,8 +28,7 @@ var SlidesXMLGet = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
|
||||
{Name: "output", Desc: "local XML output path; existing file is overwritten; omit to return XML in the JSON envelope"},
|
||||
{Name: "raw", Type: "bool", Desc: "print raw XML to stdout instead of the JSON envelope; incompatible with --output and --jq"},
|
||||
{Name: "output", Desc: "local XML output path; existing file is overwritten", Required: true},
|
||||
{Name: "revision-id", Type: "int", Default: "-1", Desc: "presentation revision_id; -1 means latest"},
|
||||
{Name: "remove-attr-id", Type: "bool", Desc: "remove XML id attributes in the returned content; useful for read-only inspection, not precise block editing"},
|
||||
},
|
||||
@@ -44,22 +42,14 @@ var SlidesXMLGet = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
}
|
||||
outputPath := strings.TrimSpace(runtime.Str("output"))
|
||||
if outputPath != "" {
|
||||
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output invalid: %v", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("output")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output cannot be empty").WithParam("--output")
|
||||
}
|
||||
if runtime.Bool("raw") {
|
||||
if outputPath != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--raw cannot be used with --output").WithParam("--raw")
|
||||
}
|
||||
if runtime.JqExpr != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--raw cannot be used with --jq").WithParam("--raw")
|
||||
}
|
||||
if runtime.Changed("format") && runtime.Format != "json" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--raw cannot be used with --format %s", runtime.Format).WithParam("--raw")
|
||||
}
|
||||
if _, err := runtime.ResolveSavePath(runtime.Str("output")); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output invalid: %v", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
if runtime.Int("revision-id") < -1 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--revision-id must be -1 or a non-negative integer").WithParam("--revision-id")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -77,7 +67,7 @@ var SlidesXMLGet = common.Shortcut{
|
||||
Desc("[1] Resolve wiki node to slides presentation").
|
||||
Params(map[string]interface{}{"token": ref.Token})
|
||||
} else {
|
||||
dry.Desc("Fetch full presentation XML")
|
||||
dry.Desc("Fetch full presentation XML and save it to a local file")
|
||||
}
|
||||
params := map[string]interface{}{
|
||||
"revision_id": runtime.Int("revision-id"),
|
||||
@@ -90,13 +80,7 @@ var SlidesXMLGet = common.Shortcut{
|
||||
validate.EncodePathSegment(presentationID),
|
||||
)).
|
||||
Params(params)
|
||||
if outputPath := strings.TrimSpace(runtime.Str("output")); outputPath != "" {
|
||||
return dry.Set("output", outputPath).Set("stdout_content", "suppressed; XML content is saved to --output during execution")
|
||||
}
|
||||
if runtime.Bool("raw") {
|
||||
return dry.Set("output", "<stdout>").Set("stdout_content", "raw XML content is printed to stdout during execution")
|
||||
}
|
||||
return dry.Set("output", "<stdout>").Set("stdout_content", "JSON envelope with xml_presentation.content is printed to stdout during execution")
|
||||
return dry.Set("output", runtime.Str("output")).Set("stdout_content", "suppressed; XML content is saved to --output during execution")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
@@ -129,32 +113,7 @@ var SlidesXMLGet = common.Shortcut{
|
||||
if content == "" {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "slides xml get returned empty xml_presentation.content")
|
||||
}
|
||||
outputPath := strings.TrimSpace(runtime.Str("output"))
|
||||
if outputPath == "" {
|
||||
presentationOut := map[string]interface{}{
|
||||
"content": content,
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"xml_presentation_id": presentationID,
|
||||
"xml_presentation": presentationOut,
|
||||
}
|
||||
if revisionID := common.GetFloat(presentation, "revision_id"); revisionID > 0 {
|
||||
out["revision_id"] = int(revisionID)
|
||||
presentationOut["revision_id"] = int(revisionID)
|
||||
}
|
||||
if runtime.Bool("remove-attr-id") {
|
||||
out["remove_attr_id"] = true
|
||||
}
|
||||
if !runtime.Bool("raw") {
|
||||
runtime.OutFormatRaw(out, nil, nil)
|
||||
return nil
|
||||
}
|
||||
if _, err := fmt.Fprint(runtime.IO().Out, content); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "write XML content to stdout: %v", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
outputPath := runtime.Str("output")
|
||||
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
|
||||
ContentType: "application/xml",
|
||||
ContentLength: int64(len(content)),
|
||||
|
||||
@@ -91,112 +91,6 @@ func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetReturnsContentEnvelopeWhenOutputOmitted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"content": xml,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
presentation := data["xml_presentation"].(map[string]interface{})
|
||||
if got := presentation["content"]; got != xml {
|
||||
t.Fatalf("content = %q, want %q", got, xml)
|
||||
}
|
||||
if got := data["xml_presentation_id"]; got != "pres_abc" {
|
||||
t.Fatalf("xml_presentation_id = %v, want pres_abc", got)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "content_saved") {
|
||||
t.Fatalf("stdout should not contain file metadata: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetJqFiltersContentEnvelopeWhenOutputOmitted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"content": xml,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--jq", ".data.xml_presentation.content",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(stdout.String()); got != xml {
|
||||
t.Fatalf("stdout = %q, want XML content %q", got, xml)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetPrintsRawContentWhenRaw(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"content": xml,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--raw",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := stdout.String(); got != xml {
|
||||
t.Fatalf("stdout = %q, want raw XML %q", got, xml)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetResolvesWikiPresentation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
@@ -12,7 +12,7 @@ metadata:
|
||||
|
||||
开始前先读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)(认证、权限处理)。
|
||||
|
||||
**CRITICAL — 凡涉及预约日程/会议或查询/搜索会议室,第一步 MUST 读 [`references/lark-calendar-schedule-meeting.md`](references/lark-calendar-schedule-meeting.md)。禁止跳过此步直接调用 API 或 Shortcut!**
|
||||
**CRITICAL — 凡涉及预约日程/会议室、调整时间或查询/搜索会议室,第一步 MUST 读 [`references/lark-calendar-schedule-meeting.md`](references/lark-calendar-schedule-meeting.md)。仅编辑字段(改标题/描述)或增删参会人(不涉及时间和会议室)时可跳过,直接读 [`references/lark-calendar-update.md`](references/lark-calendar-update.md)。**
|
||||
|
||||
## 身份
|
||||
|
||||
@@ -30,26 +30,80 @@ lark-cli calendar +agenda --as user
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+agenda`](references/lark-calendar-agenda.md) | 查看日程安排(默认今天) |
|
||||
| [`+search-event`](references/lark-calendar-search-event.md) | 按关键词、时间范围和参会人搜索日程, 仅返回 日程ID/主题/时间等信息,详情需走 `events get` |
|
||||
| `+agenda` | 查看日程安排(默认今天) |
|
||||
| [`+meeting`](references/lark-calendar-meeting.md) | 通过日程事件 ID 获取关联的视频会议信息(meeting_id、meeting_note),日程开过视频会议才会有meeting_id |
|
||||
| [`+create`](references/lark-calendar-create.md) | 创建日程并邀请参会人(ISO 8601 时间) |
|
||||
| [`+update`](references/lark-calendar-update.md) | 更新既有日程字段,或独立增量添加/移除参会人和会议室 |
|
||||
| [`+freebusy`](references/lark-calendar-freebusy.md) | 查询用户主日历的忙闲信息和 RSVP 状态 |
|
||||
| `+freebusy` | 查询用户主日历的忙闲信息和 RSVP 状态(纯查询场景;预约场景走 `+suggestion`) |
|
||||
| [`+room-find`](references/lark-calendar-room-find.md) | 针对一个或多个**明确的**时间块查找可用会议室(无明确时间时禁止直接调用,需先走 +suggestion) |
|
||||
| [`+rsvp`](references/lark-calendar-rsvp.md) | 回复日程(接受/拒绝/待定) |
|
||||
| [`+suggestion`](references/lark-calendar-suggestion.md) | 根据非明确时间或一段时间范围,推荐多个可用时间块方案 |
|
||||
|
||||
### `+get` — 单日程详情
|
||||
|
||||
通过 `calendar_id` + `event_id` 获取**单个日程**详情。
|
||||
|
||||
```bash
|
||||
# calendar_id不传,默认primary
|
||||
lark-cli calendar +get --calendar-id <calendar_id> --event-id <event_id>
|
||||
```
|
||||
|
||||
### `+search-event` — 按关键词、时间范围和参会人搜索日程
|
||||
|
||||
仅返回基础字段(`event_id`/`summary`/`start`/`end` 等),需要详情请走 `+get`。
|
||||
|
||||
```bash
|
||||
# query 按关键词 可选
|
||||
# start/end 按时间范围(ISO 8601 或 YYYY-MM-DD)可选
|
||||
# attendee-ids 按参会人(自动识别 ou_ 用户 / oc_ 群聊 / omm_ 会议室前缀)可选
|
||||
# page-token 分页游标,用于继续翻页 可选
|
||||
# page-size 每页数量,默认 30 可选
|
||||
lark-cli calendar +search-event --query "周会" --start 2026-04-20 --end 2026-04-27 --attendee-ids "ou_user1,oc_chat1,omm_room1" --page-token <page_token> --page-size 30
|
||||
```
|
||||
|
||||
### `+agenda` — 查看近期日程安排
|
||||
|
||||
默认查询当天。结果应整理为按日期分组、按开始时间升序的易读时间线。
|
||||
|
||||
```bash
|
||||
# start/end 时间范围(ISO 8601 / YYYY-MM-DD / Unix 秒),均可选;默认当天
|
||||
# calendar-id 日历 ID(默认primary)可选
|
||||
lark-cli calendar +agenda --start 2026-03-10 --end 2026-03-17 --calendar-id <calendar_id>
|
||||
```
|
||||
|
||||
注意:
|
||||
- 已取消的日程自动过滤;无日程时直接告知"日程清空"。
|
||||
- 时间范围超过 40 天会自动拆分查询并合并结果。
|
||||
|
||||
### `+freebusy` — 查询主日历忙闲时段和 RSVP 状态
|
||||
|
||||
仅返回忙碌时段起止时间,不含日程标题等隐私信息;其他订阅日历不在范围内。
|
||||
|
||||
```bash
|
||||
# start/end 时间范围(ISO 8601 / YYYY-MM-DD / Unix 秒),均可选;默认当天
|
||||
# user-id 目标用户 open_id(ou_ 前缀)可选;默认当前登录用户,bot 身份必须显式指定
|
||||
lark-cli calendar +freebusy --start 2026-03-11 --end 2026-03-12 --user-id ou_xxx
|
||||
```
|
||||
|
||||
用法提示:
|
||||
- **仅判断是否有空** → `+freebusy`;**需要日程详情** → `+agenda`。
|
||||
- 检查多人可用性:分别调用并对比,找共同空闲。
|
||||
- 预约/改约场景下,调用规则(参与人过多、含群组、来自 `+suggestion` 等)详见 [schedule-clear-time.md § 查询忙闲](references/lark-calendar-schedule-clear-time.md#2-查询忙闲)。
|
||||
|
||||
## 前置条件路由
|
||||
|
||||
| 场景 | 前置要求 |
|
||||
|------|----------|
|
||||
| 预约日程/会议、查会议室 | 先读 [lark-calendar-schedule-meeting.md](references/lark-calendar-schedule-meeting.md) |
|
||||
| 编辑已有日程 | 先定位目标日程 `event_id` |
|
||||
| 预约日程/会议、调整时间、查会议室 | 先读 [lark-calendar-schedule-meeting.md](references/lark-calendar-schedule-meeting.md) |
|
||||
| 仅编辑字段(标题/描述)或增删参会人 | 先定位 `event_id`,再读 [lark-calendar-update.md](references/lark-calendar-update.md) |
|
||||
| 编辑已有日程(涉及时间或会议室) | 先定位目标日程 `event_id`;若是重复性日程,必须定位到具体实例的 `event_id`(禁止使用原重复日程 ID) |
|
||||
| 编辑/删除重复性日程 | 先读 [重复性日程操作规范](references/lark-calendar-recurring.md),按操作范围(仅此次/全部/此次及后续)执行 |
|
||||
| 删除/修改后验证 | 等待 2 秒再查询(API 最终一致性),不要告知用户你等待了 |
|
||||
| 调用任何 Shortcut | 先读其对应 reference 文档 |
|
||||
|
||||
## 写操作反馈
|
||||
|
||||
创建、更新、删除、RSVP 等写操作完成后,直接基于命令返回结果反馈用户;不要为了“确认是否生效”主动发起二次查询。只有用户明确要求复查,或命令返回信息不足以回答用户问题时,才需要再查询。
|
||||
|
||||
## 核心概念
|
||||
|
||||
- **日程实例(Instance)**:重复性日程展开后的具体时间实例。「仅此次」操作时使用具体实例的 `event_id`;「全部」或「此次及后续」操作时需对原重复性日程操作(使用原日程 `event_id`),并按需处理例外。
|
||||
@@ -72,7 +126,8 @@ lark-cli calendar +agenda --as user
|
||||
| 按关键词搜索日程 | 本 skill(`+search-event`) |
|
||||
| 从日程获取关联的视频会议 ID 或用户绑定的会议纪要文档 | 本 skill(`+meeting`) |
|
||||
| 从日程进一步拿 AI 智能纪要 / 逐字稿 / 妙记产物 | 先 `+meeting` 取 `meeting_id`,再 [`vc +detail`](../lark-vc/references/lark-vc-detail.md) → [`note +detail`](../lark-note/references/lark-note-detail.md) / [`minutes +detail`](../lark-minutes/references/lark-minutes-detail.md) |
|
||||
| 预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间 | 先判断新建 vs 编辑,再进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md) |
|
||||
| 预约/改约日程、调整时间、添加/更换会议室、查会议室 | 先判断新建 vs 编辑,再进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md) |
|
||||
| 仅编辑日程字段(标题/描述)或增删参会人(不涉及时间和会议室) | 先定位 `event_id`,再读 [+update](references/lark-calendar-update.md) 执行变更 |
|
||||
| 编辑/删除重复性日程(「改这个重复日程」「删掉后面的」「全部取消」等) | 先读 [重复性日程操作规范](references/lark-calendar-recurring.md),确认操作范围后执行 |
|
||||
|
||||
## 任务类型分流
|
||||
@@ -90,7 +145,7 @@ lark-cli calendar +agenda --as user
|
||||
|
||||
## 会议室规则
|
||||
|
||||
- 凡是"预定/查询/搜索可用会议室",都必须进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md)。
|
||||
- 凡是"预定/查询/搜索可用会议室",都必须进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md),会议室参数规范详见 [+room-find](references/lark-calendar-room-find.md)。
|
||||
- `+room-find` 的时间输入必须是确定时间块,不能是时间区间搜索。
|
||||
- 用户仅要求"查会议室"但未提供明确时间时,必须先调用 `+suggestion` 获取可用时间块,再将时间块交给 `+room-find`。严禁猜测时间盲目调用。
|
||||
- 编辑已有日程时,"添加会议室"默认是增量语义,保留已有会议室;只有用户明确说"更换会议室""移除会议室"时才删除旧会议室。
|
||||
@@ -98,42 +153,45 @@ lark-cli calendar +agenda --as user
|
||||
## API Resources
|
||||
|
||||
```bash
|
||||
# 通用调用格式
|
||||
lark-cli calendar <resource> <method> [flags]
|
||||
|
||||
# 查询用户主日历
|
||||
lark-cli calendar calendars primary
|
||||
|
||||
# 获取日程分享链接
|
||||
lark-cli calendar events share_info --calendar-id <calendar_id> --event-id <event_id>
|
||||
|
||||
# 删除日程
|
||||
lark-cli calendar events delete --calendar-id <calendar_id> --event-id <event_id>
|
||||
```
|
||||
|
||||
### calendars
|
||||
> `calendar_id` 可以直接传 `primary`,代表当前调用身份的主日历 ID。
|
||||
|
||||
- `create` — 创建共享日历
|
||||
- `delete` — 删除共享日历
|
||||
- `get` — 查询日历信息
|
||||
- `list` — 查询日历列表
|
||||
- `patch` — 更新日历信息
|
||||
- `primary` — 查询用户主日历
|
||||
- `search` — 搜索日历
|
||||
### 查询资源的方法列表以及方法的使用方式
|
||||
|
||||
### event.attendees
|
||||
- 列出某资源下的方法:`lark-cli calendar <resource> -h`
|
||||
- 查看方法的cli flag:`lark-cli calendar <resource> <method> -h`
|
||||
- 查看方法API参数:`lark-cli schema calendar.<resource>.<method>`
|
||||
|
||||
- `batch_delete` — 删除日程参与人
|
||||
- `create` — 添加日程参与人
|
||||
- `list` — 获取日程参与人列表
|
||||
`<resource>` 为 `calendars`(日历本身)/ `events`(日程)/ `event.attendees`(参与人)/ `freebusys`(忙闲)。例:`lark-cli schema calendar.events.delete`。
|
||||
|
||||
### events
|
||||
## 常用其他域命令
|
||||
|
||||
- `create` — 创建日程
|
||||
- `delete` — 删除日程
|
||||
- `get` — 获取日程
|
||||
- `instance_view` — 查询日程视图
|
||||
- `patch` — 更新日程
|
||||
- `share_info` — 获取日程分享链接
|
||||
```bash
|
||||
# 搜索用户,更多参数详见 lark-contact
|
||||
lark-cli contact +search-user --query <query> --as user
|
||||
|
||||
### freebusys
|
||||
|
||||
- `list` — 查询主日历日程忙闲信息
|
||||
# 搜索群聊,更多参数详见 lark-im
|
||||
lark-cli im +chat-search --query <query> --as user
|
||||
```
|
||||
|
||||
## 不在本 skill 范围
|
||||
|
||||
- 查询过去的视频会议记录 → [lark-vc](../lark-vc/SKILL.md)
|
||||
- 待办任务管理 → [lark-task](../lark-task/SKILL.md)
|
||||
- 通讯录 → [lark-contact](../lark-contact/SKILL.md)
|
||||
- 即时通讯 → [lark-im](../lark-im/SKILL.md)
|
||||
- 会议室物理设施管理 → 管理员后台
|
||||
|
||||
**注意(强制性):**
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
|
||||
# calendar +agenda
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
查看近期日程安排。只读操作,不修改任何日程。
|
||||
|
||||
需要的scopes: ["calendar:calendar.event:read"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 查看今天日程(默认)
|
||||
lark-cli calendar +agenda
|
||||
|
||||
# 自定义时间范围(ISO 8601)
|
||||
lark-cli calendar +agenda --start "2026-03-10T00:00+08:00" --end "2026-03-17T00:00+08:00"
|
||||
|
||||
# 自定义时间范围(仅日期)
|
||||
lark-cli calendar +agenda --start 2026-03-10 --end 2026-03-17
|
||||
|
||||
# 人类可读格式输出
|
||||
lark-cli calendar +agenda --format pretty
|
||||
|
||||
# 指定日历
|
||||
lark-cli calendar +agenda --calendar-id cal_xxx
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--start <time>` | 否 | 开始时间(ISO 8601 或仅日期,默认当天) |
|
||||
| `--end <time>` | 否 | 结束时间(默认与 `--start` 属于同一天,自动取当天结束时间) |
|
||||
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用主日历) |
|
||||
| `--format` | 否 | 输出格式:json(默认) \| pretty |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 时间格式
|
||||
|
||||
`--start` 和 `--end` 支持以下格式:
|
||||
|
||||
| 格式 | 示例 | 说明 |
|
||||
|------|------|------|
|
||||
| ISO 8601 | `2026-03-10T14:00:00+08:00` | 完整格式 |
|
||||
| 日期+时间 | `2026-03-10 14:00:00` | 自动补全时区 |
|
||||
| 仅日期 | `2026-03-10` | start 取 00:00:00,end 取 23:59:59 |
|
||||
| Unix 时间戳 | `1741564800` | 秒级时间戳 |
|
||||
|
||||
## 输出格式
|
||||
|
||||
**将结果整理为易读的日程表:**
|
||||
|
||||
```
|
||||
## 2026-03-10 周一
|
||||
|
||||
09:00 - 09:30 站会
|
||||
10:00 - 11:00 产品评审
|
||||
14:00 - 15:00 与 Alice 1:1
|
||||
|
||||
## 2026-03-11 周二
|
||||
|
||||
(无日程)
|
||||
```
|
||||
|
||||
**注意:按日期分组,并严格按照开始时间升序(从早到晚的时间线)排序输出。** 显示标题、时长
|
||||
|
||||
## 提示
|
||||
|
||||
- 已取消的日程会自动过滤,无需额外处理。
|
||||
- 如无日程,告知用户"日程清空"。
|
||||
- 大于 40 天的时间范围会自动拆分查询并合并结果。
|
||||
- 查看多个日历:先用 `lark-cli calendar calendars list --page-all` 列出日历列表,再逐个查询。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-calendar](../SKILL.md) -- 日历全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
@@ -1,12 +1,9 @@
|
||||
|
||||
# calendar +create
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
创建日程并按需邀请参会人。
|
||||
|
||||
需要的scopes: ["calendar:calendar.event:create","calendar:calendar.event:update"]
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
@@ -38,10 +35,10 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
|
||||
| `--description <text>` | 否 | 日程详细描述。提供会议议程、活动内容、注意事项或链接等。与 summary 配合使用,仅关注当前日程信息 |
|
||||
| `--attendee-ids <id_list>` | 否 | 参与人 ID 列表(逗号分隔)。支持用户(`ou_`)、群组(`oc_`)和会议室(`omm_`)。AI 提取时请务必保留对应前缀 |
|
||||
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用主日历) |
|
||||
| `--rrule <rrule>` | 否 | 重复日程的重复性规则,规则设置方式参考rfc5545。**【⚠️注意:系统绝对不支持 COUNT,如需限制重复次数,必须转为 UNTIL】**。示例值:"FREQ=DAILY;INTERVAL=1" |
|
||||
| `--rrule <rrule>` | 否 | 重复日程的重复性规则,规则设置方式参考rfc5545。示例值:"FREQ=DAILY;INTERVAL=1;UNTIL=<具体日期>" |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
> **⚠️ `rrule` 规则限制:飞书日历系统不支持 `COUNT` 参数。遇到限制重复次数的需求,必须根据开始时间和频率自行推算并转换成 `UNTIL=<具体日期>` 格式。**
|
||||
> 当用户表达'每周 X'、'每周重复'、'连续 N 周'时,必须使用 rrule 创建重复性日程,而非创建多个独立日程
|
||||
> 自动设置 `attendee_ability: "can_modify_event"`,参会人可查看彼此并编辑日程。
|
||||
> 自动设置 `free_busy_status: "busy"`,默认日程忙闲状态为忙碌。
|
||||
> 自动设置 `reminders: [{"minutes": 5}]`,默认日程开始前 5 分钟提醒。
|
||||
@@ -56,44 +53,16 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
|
||||
- 全天日程的开始日期和结束日期必须分别是日程开始的第一天和结束的最后一天。如果只有一天的话,开始日期和结束日期是相同。
|
||||
|
||||
```bash
|
||||
# 第一步:创建日程(含高级参数)
|
||||
## 查看完整参数定义
|
||||
lark-cli schema calendar.events.create
|
||||
## 创建日程
|
||||
lark-cli calendar events create \
|
||||
--params '{"calendar_id":"<CALENDAR_ID>"}' \
|
||||
--data '{
|
||||
"summary": "技术分享:CLI 架构设计",
|
||||
"start_time": { "timestamp": "1741586400" },
|
||||
"end_time": { "timestamp": "1741593600" }
|
||||
}'
|
||||
|
||||
# 第二步:添加参会人(使用第一步返回的 calendar_id 和 event_id)
|
||||
## 查看完整参数定义
|
||||
lark-cli schema calendar.event.attendees.create
|
||||
## 添加参会人
|
||||
lark-cli calendar event.attendees create \
|
||||
--as user \
|
||||
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>"}' \
|
||||
--data '{"attendees": [{"type": "user", "user_id": "ou_xxx"}]}'
|
||||
|
||||
## 添加需要审批的会议室(approval_reason 最大 200 字符)
|
||||
lark-cli calendar event.attendees create \
|
||||
--as user \
|
||||
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>"}' \
|
||||
--data '{"attendees": [{"type": "resource", "room_id": "omm_xxx", "approval_reason": "申请原因"}]}'
|
||||
|
||||
# 可选第三步(推荐):若第二步失败,回滚删除空日程
|
||||
## 查看完整参数定义
|
||||
lark-cli schema calendar.events.delete
|
||||
## 删除空日程
|
||||
lark-cli calendar events delete \
|
||||
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>","need_notification":false}'
|
||||
|
||||
```
|
||||
|
||||
> 完整 API 命令的时间参数是 **Unix 秒字符串**(非 ISO 8601)。
|
||||
> 当你手动拆成两步执行时,建议保留“失败后回滚删除”的第三步,避免遗留空日程。
|
||||
完整 API 命令的关键差异:
|
||||
- 时间参数是 **Unix 秒字符串**(非 ISO 8601)。
|
||||
- 全天日程的开始日期和结束日期必须分别是日程开始的第一天和结束的最后一天;单日全天日程两者相同。
|
||||
- 手动拆成“创建日程 + 添加参会人”两步时,若第二步失败,建议删除刚创建的空日程,避免遗留无参会人的日程。
|
||||
|
||||
## 参会人类型
|
||||
|
||||
@@ -109,6 +78,5 @@ lark-cli calendar events delete \
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-calendar](../SKILL.md) -- 日历全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
- [lark-calendar](../SKILL.md) -- skill 入口与路由
|
||||
- [lark-calendar-suggestion](lark-calendar-suggestion.md) -- 根据非明确时间或一段时间范围,推荐多个可用时间块方案
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
|
||||
# calendar +freebusy
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。
|
||||
|
||||
查询用户主日历的忙闲信息,返回指定时间范围内的忙碌时段列表和rsvp的状态。
|
||||
|
||||
需要的scopes: ["calendar:calendar.free_busy:read"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 查询当前用户今天的忙闲(默认)
|
||||
lark-cli calendar +freebusy
|
||||
|
||||
# 自定义时间范围(仅日期)
|
||||
lark-cli calendar +freebusy --start 2026-03-11 --end 2026-03-12
|
||||
|
||||
# 自定义时间范围(完整 ISO 8601)
|
||||
lark-cli calendar +freebusy --start "2026-03-11T08:00:00+08:00" --end "2026-03-11T18:00:00+08:00"
|
||||
|
||||
# 查询指定用户的忙闲信息
|
||||
lark-cli calendar +freebusy --start 2026-03-11 --end 2026-03-12 --user-id ou_xxx
|
||||
|
||||
# 人类可读格式输出
|
||||
lark-cli calendar +freebusy --format pretty
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--start <time>` | 否 | 查询开始时间(ISO 8601 或仅日期,默认当天) |
|
||||
| `--end <time>` | 否 | 查询结束时间(默认与 `--start` 属于同一天,自动取当天结束时间) |
|
||||
| `--user-id <open_id>` | 否 | 目标查询用户 ID(`ou_` 前缀)。省略时默认查询当前登录用户,bot 身份调用时必须明确指定 |
|
||||
| `--format` | 否 | 输出格式:json(默认) \| pretty |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 时间格式
|
||||
|
||||
`--start` 和 `--end` 支持以下格式:
|
||||
|
||||
| 格式 | 示例 | 说明 |
|
||||
|------|------|------|
|
||||
| ISO 8601 | `2026-03-11T09:00:00+08:00` | 完整格式 |
|
||||
| 日期+时间 | `2026-03-11 09:00:00` | 自动补全时区 |
|
||||
| 仅日期 | `2026-03-11` | start 取 00:00:00,end 取 23:59:59 |
|
||||
| Unix 时间戳 | `1741564800` | 秒级时间戳 |
|
||||
|
||||
## 输出示例
|
||||
|
||||
### 表格格式
|
||||
|
||||
```
|
||||
start end rsvp_status
|
||||
---------------- ---------------- -----------
|
||||
2026-03-11 10:00 2026-03-11 10:30 接受
|
||||
2026-03-11 14:00 2026-03-11 15:00 待定
|
||||
|
||||
共 2 个忙碌时段
|
||||
```
|
||||
|
||||
### JSON 格式
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"start_time": "2026-03-11T10:00:00+08:00",
|
||||
"end_time": "2026-03-11T10:30:00+08:00",
|
||||
"rsvp_status": "accept"
|
||||
},
|
||||
{
|
||||
"start_time": "2026-03-11T14:00:00+08:00",
|
||||
"end_time": "2026-03-11T15:00:00+08:00",
|
||||
"rsvp_status": "tentative"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 典型场景
|
||||
|
||||
### 1. 查找日程会议空闲时段
|
||||
|
||||
```bash
|
||||
# 查询今天的忙碌时段
|
||||
lark-cli calendar +freebusy
|
||||
|
||||
# 查询工作时间段
|
||||
lark-cli calendar +freebusy \
|
||||
--start "2026-03-11T08:00:00+08:00" \
|
||||
--end "2026-03-11T18:00:00+08:00"
|
||||
```
|
||||
|
||||
### 2. 检查团队成员可用性
|
||||
|
||||
```bash
|
||||
# 查询多个成员,对比找出共同空闲时间
|
||||
lark-cli calendar +freebusy --start 2026-03-12 --user-id ou_member_a
|
||||
lark-cli calendar +freebusy --start 2026-03-12 --user-id ou_member_b
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **只查询主日历** — 此命令只返回用户主日历的忙闲信息,不包括其他订阅日历
|
||||
2. **隐私保护** — 只返回忙碌时段的起止时间,不包含日程标题、描述等详细信息
|
||||
3. **bot 身份** — bot 必须通过 `--user-id` 指定要查询的用户
|
||||
|
||||
## 与其他命令对比
|
||||
|
||||
| 命令 | 用途 | 输出内容 |
|
||||
|------|------|----------|
|
||||
| `calendar +freebusy` | 查询忙闲时段 | 只返回忙碌时段列表(无日程详情) |
|
||||
| `calendar +agenda` | 查看日程安排 | 返回完整日程列表(含标题、描述等) |
|
||||
|
||||
**选择建议**:
|
||||
- **仅需了解是否有空** → 使用 `+freebusy`(更快,隐私保护)
|
||||
- **需要查看日程详情** → 使用 `+agenda`
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-calendar-agenda](lark-calendar-agenda.md) — 查看日程安排
|
||||
- [lark-calendar-create](lark-calendar-create.md) — 创建日程
|
||||
- [lark-calendar-suggestion](lark-calendar-suggestion.md) — 根据非明确时间或一段时间范围,推荐多个可用时间块方案
|
||||
- [lark-calendar](../SKILL.md) — 日历完整 API
|
||||
@@ -1,11 +1,8 @@
|
||||
# calendar +room-find
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。
|
||||
|
||||
针对一个或多个时间块查找/搜索可用会议室。会议室是日程的一种资源型参与人,不能脱离日程单独预定。
|
||||
|
||||
需要的 scopes: ["calendar:calendar.free_busy:read"]
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 已知一个或多个待选时间块,需要查找可用会议室
|
||||
@@ -50,7 +47,7 @@ lark-cli calendar +room-find \
|
||||
| `--city <text>` | 否 | 会议室所在城市强约束。**仅当**用户明确说出具体城市(如北京、上海)时才提取,**严禁**根据园区或楼宇名称自行联想或补全。 |
|
||||
| `--building <text>` | 否 | 会议室所在楼宇强约束,承载城市以下、楼层以上的办公区/园区/楼栋描述。|
|
||||
| `--floor <text>` | 否 | 仅用于筛选会议室所在楼层。应先做归一化,再传递规范值;例如 `2楼` / `二楼` / `2F` 统一为 `F2`。注意:此参数只筛选楼层,不可混入区域定位(如“A区”)或具体会议室号。 |
|
||||
| `--room-name <text>` | 否 | 会议室名称约束,支持以**英文逗号**分隔传入多个名称。仅当用户明确提到会议室专名或会议室号(如"木星""02")时使用。当用户需要在一组编号会议室中搜索时(如"帮我约 16~20 号的会议室"),应将编号展开为逗号分隔列表,如 `"16,17,18,19,20"`。应优先传递去后缀、去冗余后的规范名,例如 `木星会议室` → `木星`,`会议室 02` / `02会议室` → `02`。 |
|
||||
| `--room-name <text>` | 否 | 会议室名称约束,支持以**英文逗号**分隔传入多个名称。仅当用户明确提到会议室专名、会议室号或编号区间时使用。 |
|
||||
| `--min-capacity <n>` | 否 | 会议室最小容纳人数。当用户明确参会人数或提出“至少容纳N人”等要求时,提取数字放入此参数,必须为正整数。 |
|
||||
| `--max-capacity <n>` | 否 | 会议室最大容纳人数。用于过滤过大空间,必须为正整数。 |
|
||||
| `--attendee-ids <id_list>` | 否 | 参会对象 ID 列表。支持用户 ID(`ou_` 前缀)和群组 ID(`oc_` 前缀),多个 ID 以逗号分隔。 |
|
||||
@@ -67,7 +64,7 @@ lark-cli calendar +room-find \
|
||||
- 同一语义槽位只保留一个规范值。例如用户说“2楼”,应转换为 `--floor "F2"`;**禁止**同时传 `2楼 F2` 这类重复楼层信息。
|
||||
- 参数归类顺序应为:`city/building/floor` > `floor + room-name` 复合表达 > `room-name`。若短词更像楼层/区域定位(如 `2L`、`2F`),优先落到 `--floor`,不要默认落到 `--room-name`。像 `学清2层` 这种表达,通常拆为 `--building "学清"` 与 `--floor "F2"`。
|
||||
- 对会议室名要做轻量归一化:`木星会议室` 应提取为 `--room-name "木星"`;`会议室 02` / `02会议室` 应提取为 `--room-name "02"`。
|
||||
- **多会议室名称场景**:当用户表达"帮我约 XX 到 YY 号之间的会议室"或一次提及多个会议室名称时,应将所有目标名称用英文逗号拼接传入 `--room-name`。例如:
|
||||
- 当用户表达"帮我约 XX 到 YY 号之间的会议室"或一次提及多个会议室名称时,应将所有目标名称用英文逗号拼接传入 `--room-name`。例如:
|
||||
- "帮我约 16~20 号的会议室" → `--room-name "16,17,18,19,20"`
|
||||
- "查下木星和火星是否有空" → `--room-name "木星,火星"`
|
||||
- "看看 01、02、03 会议室" → `--room-name "01,02,03"`
|
||||
@@ -90,9 +87,8 @@ lark-cli calendar +room-find \
|
||||
```
|
||||
|
||||
> **AI 行为指导:**
|
||||
> - **结构化展示时间块与会议室**:默认按“时间块 -> 会议室候选”的层级结构展示。**严禁将时间与会议室名称输出在同一行**。以清晰的分行列表呈现可用会议室,并直接询问用户意向。默认原样展示完整 `room_name`;不要擅自缩写、截断、改写,或仅提取楼层及会议室号替代完整名称。
|
||||
> - **结构化展示时间块与会议室**:默认按“时间块 -> 会议室候选”的层级结构展示,并直接询问用户意向。
|
||||
> - **`room_name` 必须逐字透传**:展示给用户的会议室名称,必须直接使用 CLI/API 返回的 `room_name` 原值。禁止提取楼层、会议室号、容量、视频能力后重组成新的名称,禁止意译、缩写、去前缀、去后缀,或仅保留"便于阅读"的摘要名。
|
||||
> - **主动识别区间/多名称意图**:当用户提到"帮我约 XX 到 YY 号的会议室""XX~YY 之间的会议室"或一次列出多个会议室名称时,将所有目标名称展开为英文逗号分隔列表,传入 `--room-name`。例如"帮我约 16 到 20 号的会议室"应生成 `--room-name "16,17,18,19,20"`。
|
||||
> - **重复日程要明确阻断原因与自动缩短**:若某候选会议室的 `reserve_until_time` 无法覆盖重复性日程,**必须**向用户明确说明该会议室最长可约至何时。若用户确认继续选用该会议室,你必须**自动将日程的重复规则结束时间缩短**至该 `reserve_until_time`,以防止会议室预约失败。不能直接按原规则继续。
|
||||
> - **正确解释推荐结果**:如果返回结果与用户输入条件不完全字面一致,先说明底层可能返回邻近位置或相近条件的推荐候选,不要直接将其判定为异常。
|
||||
> - **默认减少用户输入成本**:应主动引导用户不必一开始就提供很详细的会议室搜索条件。只要时间块已明确,用户直接表达“想约会议室”即可,先基于当前信息查询候选;只有在用户对结果不满意时,再引导其补充更具体的楼宇、楼层、会议室名或容量条件。
|
||||
@@ -102,7 +98,7 @@ lark-cli calendar +room-find \
|
||||
| 字段名 | 说明 |
|
||||
| :--- | :--- |
|
||||
| `room_id` | 会议室唯一标识,用于后续创建日程时添加为会议室参与人使用。 |
|
||||
| `room_name` | 会议室名称,默认原样完整展示给用户,不要自行缩写、截断、改写,也不要用楼层及会议室号摘要替代原值。 |
|
||||
| `room_name` | 会议室名称,展示给用户时必须使用原值。 |
|
||||
| `capacity` | 会议室最大容纳人数。 |
|
||||
| `reserve_until_time` | 该会议室当前允许被预约到的最晚时间点,用于校验重复性日程是否超期。 |
|
||||
|
||||
@@ -110,4 +106,4 @@ lark-cli calendar +room-find \
|
||||
|
||||
- [lark-calendar-create](lark-calendar-create.md)
|
||||
- [lark-calendar-suggestion](lark-calendar-suggestion.md)
|
||||
- [lark-calendar](../SKILL.md) — 日历完整 API
|
||||
- [lark-calendar](../SKILL.md) — skill 入口与路由
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
# calendar +rsvp
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
回复指定的日程,更新当前用户的 RSVP 状态(接受、拒绝或待定)。
|
||||
|
||||
需要的scopes: ["calendar:calendar.event:reply"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
@@ -38,5 +35,4 @@ lark-cli calendar +rsvp --calendar-id cal_xxx --event-id evt_xxx --rsvp-status a
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-calendar](../SKILL.md) -- 日历全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
- [lark-calendar](../SKILL.md) -- skill 入口与路由
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# 明确时间分支:room-find + freebusy + 冲突处理
|
||||
|
||||
> 本文档处理**时间已明确**的场景。"明确时间"来源:用户直接表达(如"明天下午3点")、编辑流中已定位日程的原始 start/end、或经用户确认的 suggestion 时间块。
|
||||
|
||||
## 前置条件
|
||||
|
||||
进入此分支前,调度器([schedule-meeting.md](./lark-calendar-schedule-meeting.md))已完成:
|
||||
- 任务类型判定(新建 / 编辑)
|
||||
- 编辑流:目标 event_id 已定位
|
||||
- 新建流:默认值已补全
|
||||
- 时间已判定为**明确**
|
||||
|
||||
## 流程
|
||||
|
||||
### 1. 查询会议室(如需)
|
||||
|
||||
若用户需要会议室,先调用 `+room-find`。详见 [`lark-calendar-room-find.md`](./lark-calendar-room-find.md)。
|
||||
|
||||
```bash
|
||||
lark-cli calendar +room-find \
|
||||
--slot "<start>~<end>" \
|
||||
--attendee-ids "<ids>" \
|
||||
--city "<city>" \
|
||||
--building "<building>" \
|
||||
--floor "<F2>" \
|
||||
--room-name "<room_name>"
|
||||
```
|
||||
|
||||
时间块确定规则:
|
||||
- **编辑流且不改时间,只新增会议室**:`--slot` 必须来自已定位日程的当前 `start/end`
|
||||
- **编辑流且既改时间又加会议室**:`--slot` 必须来自候选新时间,而不是旧时间
|
||||
|
||||
详见 [`lark-calendar-room-find.md`](./lark-calendar-room-find.md)。
|
||||
|
||||
### 2. 查询忙闲
|
||||
|
||||
```bash
|
||||
lark-cli calendar +freebusy --start "<start>" --end "<end>"
|
||||
```
|
||||
|
||||
规则:
|
||||
- 参与人过多(超过 5 人):仅查询**当前用户**及少数核心人员忙闲即可
|
||||
- 参与人含**群组**:无需展开群组成员查询忙闲
|
||||
- 如果用户是从 `+suggestion` 确认了时间块后进入本分支的,**无需再调用 `+freebusy`**
|
||||
|
||||
### 3. 冲突处理
|
||||
|
||||
- **无冲突**:直接让用户选择会议室(如需),进入落地操作
|
||||
- **有冲突**:必须先说明冲突情况,询问用户:
|
||||
- **继续当前时间** → 让用户选择会议室(如需),进入落地操作
|
||||
- **换时间** → 转入 [模糊时间分支](./lark-calendar-schedule-fuzzy-time.md)
|
||||
|
||||
## 落地
|
||||
|
||||
根据任务类型:
|
||||
- 新建 → [`+create`](./lark-calendar-create.md)
|
||||
- 编辑 → [`+update`](./lark-calendar-update.md)
|
||||
|
||||
落地规则详见 [schedule-meeting.md § 落地日程变更](./lark-calendar-schedule-meeting.md#落地日程变更)。
|
||||
@@ -0,0 +1,88 @@
|
||||
# 模糊时间 / 无时间信息分支:suggestion + 批量查询
|
||||
|
||||
> 本文档处理**时间模糊**(如"明天下午""下周找个时间")或**完全无时间信息**的场景。核心动作是调用 `+suggestion` 产出候选时间块,再根据是否需要会议室决定后续步骤。
|
||||
|
||||
## 前置条件
|
||||
|
||||
进入此分支前,调度器([schedule-meeting.md](./lark-calendar-schedule-meeting.md))已完成:
|
||||
- 任务类型判定(新建 / 编辑)
|
||||
- 编辑流:目标 event_id 已定位
|
||||
- 新建流:默认值已补全
|
||||
- 时间已判定为**模糊**或**无时间信息**
|
||||
|
||||
## 流程
|
||||
|
||||
### 1. 调用 suggestion
|
||||
|
||||
详见 [`lark-calendar-suggestion.md`](./lark-calendar-suggestion.md)。
|
||||
|
||||
```bash
|
||||
lark-cli calendar +suggestion \
|
||||
--start "<range_start>" \
|
||||
--end "<range_end>" \
|
||||
--attendee-ids "<ids>" \
|
||||
--duration-minutes <n> \
|
||||
--event-rrule "<rrule>"
|
||||
```
|
||||
|
||||
规则:
|
||||
- 用户完全没有提供时间信息时,先默认一个合理区间(如"今天剩余时间"或"近两天")再调用
|
||||
- 编辑流中,若用户说"改到明天下午""下周找个时间再约",基于用户期望的**新时间范围**调用,不要沿用旧时间
|
||||
- **不要在用户完全没给时间时反问"你想约什么时候"** — 先补合理区间再进入 suggestion
|
||||
|
||||
### 2. 分支处理
|
||||
|
||||
#### 不需要会议室
|
||||
|
||||
获取多个推荐时间块后,直接向用户展示候选时间,用户确认后进入落地操作。
|
||||
|
||||
#### 需要会议室
|
||||
|
||||
获取候选时间块后,**不要急于让用户只选时间**。先将这些时间块一次性交给 `+room-find` 批量查询可用会议室,然后将【候选时间】与【对应的可用会议室列表】结构化展示,让用户一次性完成选择。
|
||||
|
||||
> **注意**:即使用户最初只说"查会议室"且未带时间,也必须强制走 suggestion → room-find 路径。
|
||||
|
||||
详见 [`lark-calendar-room-find.md`](./lark-calendar-room-find.md)。
|
||||
|
||||
### 3. 用户确认后
|
||||
|
||||
- 用户选中 `+suggestion` 返回的时间块后,**无需再次调用 `+freebusy`**,直接进入落地操作
|
||||
- **BLOCKING REQUIREMENT**:必须先向用户展示选项并等待确认,禁止在未获用户确认时直接创建/更新日程
|
||||
|
||||
## 模糊语义消解与长期记忆
|
||||
|
||||
针对存在歧义的时间场景,严禁主观臆断。典型例子:
|
||||
- "上班后" / "下班前"
|
||||
- 未明确上下午的 12 小时制时间
|
||||
|
||||
处理规则:
|
||||
- 主动澄清真实意图,不自行猜测
|
||||
- 用户澄清后,将个性化定义沉淀为长期偏好
|
||||
|
||||
## 用户展示格式
|
||||
|
||||
向用户展示多个时间块及对应会议室时,**必须结构化分行排版**,严禁将时间与会议室放在同一行:
|
||||
|
||||
```text
|
||||
## 2026-03-27 周五
|
||||
|
||||
[选项 1] 14:00 - 15:00(参会人均空闲)
|
||||
可用会议室:
|
||||
1. 学清嘉创大厦B座-F2-02🎦(7人)
|
||||
2. 学清嘉创大厦B座-F2-05🎦(10人)
|
||||
|
||||
[选项 2] 16:00 - 17:00(参会人均空闲)
|
||||
可用会议室:
|
||||
1. 学清嘉创大厦B座-F3-01🎦(6人)
|
||||
2. 学清嘉创大厦B座-F3-06🎦(8人)
|
||||
|
||||
💡 请回复您倾向的选项编号以及对应的会议室序号,我来为您完成预定。
|
||||
```
|
||||
|
||||
## 落地
|
||||
|
||||
根据任务类型:
|
||||
- 新建 → [`+create`](./lark-calendar-create.md)
|
||||
- 编辑 → [`+update`](./lark-calendar-update.md)
|
||||
|
||||
落地规则详见 [schedule-meeting.md § 落地日程变更](./lark-calendar-schedule-meeting.md#落地日程变更)。
|
||||
@@ -1,206 +1,95 @@
|
||||
# 预约/改约日程或会议、查询/搜索可用会议室的工作流
|
||||
|
||||
## CRITICAL 执行摘要(先按这个骨架执行,再看下方细则)
|
||||
## 执行摘要
|
||||
|
||||
- **第一步永远是判断任务类型:新建日程,还是编辑已有日程。** 不要把“预约/查会议室”默认等同于“新建”。
|
||||
- **编辑已有日程时,必须先定位目标日程或实例的 `event_id`。** 用户一旦给出了既有日程锚点(标题、时间段、`这个日程`、`这场会`)并表达修改动作(加人、删人、改时间、换会议室等),默认走编辑流。
|
||||
- **默认做智能助理,不做表单填写机。** 能根据上下文补全的默认值就直接补全,避免把用户带入表单式问答。
|
||||
- **新建流先补默认值,编辑流先继承已定位日程信息。** 默认值包括标题、参会人、时长,以及在“完全无时间信息”时的默认时间范围;编辑流则优先复用已定位日程的标题、时间、已有参与人和会议室信息作为基线。
|
||||
- **只有三类场景才主动追问用户**:存在时间冲突、搜索结果无法唯一确定、时间语义本身有歧义。
|
||||
- **编辑流的时间基准必须明确。** 如果编辑时不改时间,则后续会议室搜索必须基于已定位日程的原始起止时间;如果既改时间又加会议室,必须先确定最终时间,再基于该时间搜索会议室。
|
||||
- **编辑流中“新增会议室”默认是增量语义。** 如果用户说的是“加会议室/再加一个会议室”,最终 `+update` 只做 `add`,默认保留已有会议室;只有在用户明确说“更换会议室/移除会议室”时,才执行旧会议室删除。
|
||||
- **明确时间**:若需要会议室,先 `+room-find`;再 `+freebusy` 判断参会人忙闲;有冲突时先说明冲突,再让用户决定继续当前时间还是改走 `+suggestion`。
|
||||
- **模糊时间或无时间信息**:先 `+suggestion` 产出候选时间块;若需要会议室,再把这些时间块批量交给 `+room-find`,将“候选时间 + 对应可用会议室”一次性展示给用户选择。
|
||||
- **BLOCKING REQUIREMENT: 只要面临时间方案(模糊时间/无时间)或会议室方案(需要会议室)的选择,必须先向用户展示选项并等待用户明确确认,绝对禁止在未获用户确认的情况下直接执行创建新日程或更新既有日程。**
|
||||
- **用户选中了 `+suggestion` 返回的候选时间块后,不要再次调用 `+freebusy`。** 用户确认后直接进入最终落地操作:创建新日程,或更新既有日程。
|
||||
- **当用户说“查会议室”“找会议室”“搜可用会议室”时,默认意图是查会议室可用性,不是检索会议室资源名录。**
|
||||
- **必须按顺序执行。** 不要跳过“任务类型判定”“目标日程定位(编辑流)”“补默认值/继承基线信息”“判断时间明确性”这些前置步骤。
|
||||
|
||||
> **💡 核心原则:做智能助理,充分利用默认值规则(如默认标题、时长、参与人等)自动补全信息。极力避免像“表单填写机”一样频繁打断并反问用户,仅在必须决策的冲突或无法唯一确定的场景下才发起询问。**
|
||||
- **第一步永远是判断任务类型:新建日程,还是编辑已有日程。**
|
||||
- **编辑已有日程时,必须先定位目标日程或实例的 `event_id`。**
|
||||
- **默认做智能助理,不做表单填写机。** 能根据上下文补全的默认值就直接补全,仅在必须决策的冲突或无法唯一确定的场景下才发起询问。
|
||||
- **新建流先补默认值,编辑流先继承已定位日程信息。**
|
||||
- **明确时间** → 进入 [明确时间分支](./lark-calendar-schedule-clear-time.md)
|
||||
- **模糊时间或无时间信息** → 进入 [模糊时间分支](./lark-calendar-schedule-fuzzy-time.md)
|
||||
- **BLOCKING REQUIREMENT**: 面临时间方案或会议室方案的选择时,必须先向用户展示选项并等待确认,禁止未经确认直接创建/更新日程。
|
||||
- **必须按顺序执行。** 不要跳过"任务类型判定""目标日程定位(编辑流)""补默认值/继承基线信息""判断时间明确性"这些前置步骤。
|
||||
|
||||
## 严禁行为
|
||||
|
||||
- **严禁在未读取对应子命令文档(如 `lark-calendar-room-find.md`、`lark-calendar-suggestion.md`)的情况下直接调用命令!** 必须先阅读文档掌握最新参数要求与规范。
|
||||
- **严禁在尚未判断“新建”还是“编辑”之前,就直接进入创建日程或查会议室动作。**
|
||||
- **严禁把“给明天上午的‘产品发布会’加人/加群/加会议室”这类带有既有日程锚点 + 修改动词的请求,当成新建日程。** 这类请求必须先定位目标日程。
|
||||
- **严禁在编辑已有日程时跳过目标定位步骤。** 未拿到唯一的 `event_id` 前,不得调用 `+update`、也不得基于猜测时间去查会议室。
|
||||
- **严禁在用户仅要求“查会议室”但未提供明确时间时,直接调用 `+room-find`!** 必须先默认一个合理时间范围,调用 `+suggestion` 拿到候选时间块,再将时间块传给 `+room-find`。
|
||||
- **不要在用户完全没给时间时,直接反问“你想约什么时候”。** 先补一个合理时间范围,再进入 `+suggestion`。
|
||||
- **不要在“需要会议室 + 时间模糊”的场景下,先让用户只选时间。** 应先批量查出每个候选时间对应的可用会议室,再让用户一次性完成选择。
|
||||
- **不要在用户已经选中 `+suggestion` 候选时间后,再重复调用 `+freebusy`。**
|
||||
- **不要在用户未明确说出城市时,仅凭园区/办公室名自动补城市。**
|
||||
- **严禁在面临时间方案或会议室方案的选择时(模糊时间、无时间或需要会议室),未经用户确认就擅自创建新日程或更新既有日程。**
|
||||
- **严禁在未读取对应子命令文档前直接调用命令。**
|
||||
- **严禁在尚未判断"新建"还是"编辑"之前,就直接进入创建日程或查会议室动作。**
|
||||
- **严禁把带有既有日程锚点 + 修改动词的请求当成新建日程。**
|
||||
- **严禁在编辑已有日程时跳过目标定位步骤。** 未拿到唯一 `event_id` 前,不得调用 `+update`。
|
||||
- **严禁在面临时间/会议室方案选择时,未经用户确认就擅自创建/更新日程。**
|
||||
|
||||
## 适用场景
|
||||
|
||||
- “帮我约个会”
|
||||
- “下周找时间和 XX 开会”
|
||||
- “帮我订个会议室”
|
||||
- “帮我找/搜索一个可用的会议室”
|
||||
- “帮我推荐一个我以前常用的会议室”
|
||||
- “查询明天下午可用的会议室”
|
||||
- “明天下午3点约个日程/日历”
|
||||
- “把明天上午的日程‘产品发布会’加上 小明
|
||||
- “给下周一的周会换个会议室”
|
||||
- “把这个日程改到明天下午,并加上学清 F201”
|
||||
- "帮我约个会" / "下周找时间和 XX 开会"
|
||||
- "帮我订/找/搜索一个可用会议室"
|
||||
- "明天下午3点约个日程"
|
||||
- "把明天上午的日程加上 小明"
|
||||
- "给下周一的周会换个会议室"
|
||||
- "把这个日程改到明天下午,并加上学清 F201"
|
||||
|
||||
## 核心概念
|
||||
|
||||
- **会议室是日程的一种参与人(attendee / resource),不能脱离日程单独预定。**
|
||||
- **预定或查找会议室,均需先确定时间块。** 在推荐可用会议室后,应顺势引导用户完成最终的**日程落地**操作:创建新日程,或更新既有日程。
|
||||
|
||||
## CRITICAL 约束
|
||||
|
||||
- **在调用任何具体的 CLI 子命令(如 `+room-find`、`+suggestion`、`+freebusy`、`+create`)前,必须先读取其对应的 Markdown 文档。** 禁止仅凭记忆组装命令参数,以确保符合各命令最新的业务约束和格式规范。
|
||||
- **当用户说“查会议室”“找会议室”“搜可用会议室”等,默认意图是查询会议室可用性,而不是检索会议室资源名录。**
|
||||
- **必须严格按照下方【工作流】的步骤顺序完成任务。特别是单独查会议室时,若无明确时间,强制先走“模糊时间/无时间信息”分支调用 `+suggestion`。**
|
||||
- **会议室是日程的一种参与人(attendee / resource),不能脱离日程单独预定。**
|
||||
- **预定或查找会议室,均需先确定时间块。**
|
||||
- **当用户说"查会议室""找会议室",默认意图是查会议室可用性,不是检索会议室资源名录。**
|
||||
|
||||
## 任务类型判定
|
||||
|
||||
| 类型 | 典型语言信号 | 第一动作 |
|
||||
|------|--------------|----------|
|
||||
| 新建日程 | “约个会”“安排一个会议”“新建日程”“帮我订个会议室开会” | 补默认值,再进入时间判断 |
|
||||
| 编辑已有日程 | “给某个日程加人/删人/加群/加会议室”“把某个日程改到…”“给这场会换个会议室” | 先定位目标日程 `event_id`,再进入后续流程 |
|
||||
| 新建日程 | "约个会""安排会议""新建日程""订个会议室开会" | 补默认值,再进入时间判断 |
|
||||
| 编辑已有日程 | "给某日程加人/删人/加会议室""把某日程改到…""换会议室" | 先定位目标 `event_id` |
|
||||
|
||||
进一步规则:
|
||||
规则:
|
||||
- 只要同时出现**既有日程锚点**(标题、时间段、`这个日程`、`这场会`)和**修改动词**(添加、移除、改到、换),默认判定为编辑。
|
||||
- 对重复性日程的编辑,必须先定位到对应实例的 `event_id`。
|
||||
|
||||
- 只要同时出现**既有日程锚点**(标题、时间段、`这个日程`、`这场会`、某次实例)和**修改动词**(添加、移除、调整、改到、换、延后、提前),默认判定为**编辑已有日程**。
|
||||
- 对重复性日程的编辑,必须先定位到对应实例的 `event_id`,不能直接拿原重复日程的 `event_id` 做更新。
|
||||
|
||||
## 工作流
|
||||
|
||||
### 1. 编辑已有日程:先定位目标日程
|
||||
|
||||
一旦判定为编辑流,必须先定位目标日程;没有 `event_id` 就不能继续后续修改动作。
|
||||
## 编辑流:先定位目标日程
|
||||
|
||||
定位规则:
|
||||
- 优先利用用户给出的标题、日期、时间范围等锚点,通过 `+agenda`、`+search-event` 或实例视图缩小范围
|
||||
- 命中多个候选日程时,必须向用户展示候选项并要求确认
|
||||
- 重复性日程必须继续定位到该次实例的 `event_id`
|
||||
|
||||
- 优先利用用户给出的标题、日期、时间范围、`这个日程/这场会` 等锚点,通过 `+agenda`、`+search-event` 或实例视图缩小范围。
|
||||
- 如果命中多个候选日程,必须向用户展示候选项并要求确认,禁止自行猜测。
|
||||
- 如果是重复性日程的某一次实例,必须继续定位到该次实例的 `event_id`。
|
||||
编辑流分支路由:
|
||||
|
||||
编辑流分支规则:
|
||||
| 编辑子场景 | 下一步 |
|
||||
|-----------|--------|
|
||||
| 仅增删普通参会人/群组,不改时间,不涉及会议室 | 直接 `+update`(详见 [lark-calendar-update.md](./lark-calendar-update.md)) |
|
||||
| 新增会议室,不改时间 | 基于已定位日程 start/end → [明确时间分支](./lark-calendar-schedule-clear-time.md) |
|
||||
| 只改时间,不涉及会议室 | 判断时间明确性 → 对应分支 |
|
||||
| 既改时间,又新增/更换会议室 | 先确定最终时间 → 再查会议室 → 落地 |
|
||||
|
||||
- **仅增删普通参会人/群组,不改时间,也不涉及会议室**:定位完成后可直接进入最终 `+update`。
|
||||
- **新增会议室,但不改时间**:必须基于已定位日程的当前 `start/end` 作为时间块执行 `+room-find`,不能因为用户没重复说时间就退回“无时间信息”。
|
||||
- **既改时间,又新增会议室**:必须先处理时间,拿到最终候选时间块后,再基于该时间执行 `+room-find`;最终只增量添加新会议室,不自动删除已有会议室。
|
||||
- **既改时间,又更换会议室**:必须先处理时间,拿到最终候选时间块后,再基于该时间执行 `+room-find`;只有在用户明确表达“更换”时,最终才执行“移除旧会议室 + 添加新会议室”。
|
||||
- **只改时间,不涉及会议室**:沿用下方时间工作流,但最终落地必须是 `+update`,不是 `+create`。
|
||||
## 新建日程:智能推断默认值
|
||||
|
||||
### 2. 新建日程:智能推断默认值
|
||||
以下信息智能推断,减少频繁询问用户:
|
||||
- **标题**:根据上下文自动生成;如无法推断,默认"会议"
|
||||
- **参会人**:如未指定,默认仅用户自己
|
||||
- **时长**:基于上下文推断;默认 30 分钟
|
||||
- **无时间信息**:默认推断合理区间(如"今天"或"近两天"),进入时间推荐流程,禁止询问用户
|
||||
|
||||
- **标题**:根据上下文自动生成,例如“沟通对齐”“需求讨论”;如无法推断,默认为“会议”
|
||||
- **参会人**:如未明确指定其他人,默认参会人仅为**用户自己**
|
||||
- **时长**:基于会议类型和上下文动态推断;如无法推断,默认为 30 分钟
|
||||
- **无任何时间信息**:默认推断一个合理区间(如“今天”或“近两天”),并进入时间推荐流程,禁止询问用户
|
||||
搜索参与人出现多个结果无法唯一确定时,必须询问用户并记录长期记忆。
|
||||
|
||||
当搜索特定参与人(人、群)出现多个结果无法唯一确定时,必须询问用户进行选择确认,并将该偏好记录为长期记忆,以便后续自动识别。
|
||||
|
||||
### 3. 判断时间是否明确
|
||||
|
||||
这一步判断的是**最终要落地的目标时间**,不是只看用户原句里有没有重复说时间。
|
||||
## 判断时间是否明确
|
||||
|
||||
时间基准规则:
|
||||
- **新建流**:使用用户给出的时间,或默认补全出的时间范围
|
||||
- **编辑流且不改时间**:已定位日程的当前 `start/end` 就是明确时间
|
||||
- **编辑流且改时间**:用户想改到的新时间;若表达模糊,进入模糊时间分支
|
||||
**注意**: 在执行修改日程/会议时间的任务时,必须先获取原日程的持续时长。如果用户只提供了新的开始时间,你必须根据原时长自动计算出新的结束时间,严格保持原时长不变,禁止擅自改变原日程的时长。
|
||||
|
||||
- **新建流**:使用用户给出的时间,或默认补全出的时间范围作为时间基准。
|
||||
- **编辑流且不改时间**:已定位日程的当前 `start/end` 就是时间基准。后续如需查会议室,直接使用这个明确时间块。
|
||||
- **编辑流且改时间**:用户想改到的新时间才是时间基准;若表达模糊,则进入 `+suggestion`。
|
||||
## 分支路由
|
||||
|
||||
分两类处理:
|
||||
| 判定结果 | 下一步读取 |
|
||||
|----------|-----------|
|
||||
| 明确时间 | [schedule-clear-time.md](./lark-calendar-schedule-clear-time.md) |
|
||||
| 模糊时间 / 无时间信息 | [schedule-fuzzy-time.md](./lark-calendar-schedule-fuzzy-time.md) |
|
||||
|
||||
- **明确时间**:如“明天下午3点”
|
||||
- **模糊时间**:如“明天下午”“下周找个时间”
|
||||
|
||||
### 4. 明确时间
|
||||
|
||||
明确时间时,需先判断是否需要会议室,如果需要,提前查询会议室;然后判断是否有时间冲突。这里的“明确时间”既可以来自用户直接表达,也可以来自已定位日程的原始时间。
|
||||
详见 [`+room-find`](./lark-calendar-room-find.md) 与 [`+freebusy`](./lark-calendar-freebusy.md)。
|
||||
|
||||
```bash
|
||||
# 1. 如果需要会议室,提前查询会议室
|
||||
lark-cli calendar +room-find \
|
||||
--slot "<start>~<end>" \
|
||||
--attendee-ids "<ids>" \
|
||||
--city "<city>" \
|
||||
--building "<building>" \
|
||||
--floor "<F2>" \
|
||||
--room-name "<room_name>"
|
||||
|
||||
# 2. 查询当前用户及其他参会人忙闲
|
||||
# (如果有多名参会人,需分别调用查询:--user-id "<ou_xxx>")
|
||||
lark-cli calendar +freebusy --start "<start>" --end "<end>"
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- **参会人过多或包含群组时的处理**:
|
||||
- 如果参与人过多(例如超过 5 人),为避免高耗时,仅需查询**当前用户(自己)**及少数核心人员的忙闲状态即可。
|
||||
- 如果参与人中包含**群组**,无需展开群组成员查询其忙闲状态。
|
||||
- **编辑已有日程且不改时间,只新增会议室时**:这里的 `--slot` 必须来自已定位日程的当前 `start/end`。
|
||||
- **编辑已有日程且既改时间又加会议室时**:这里的 `--slot` 必须来自候选新时间,而不是旧时间;如果用户是“新增会议室”,后续落地只做添加,不删除旧会议室。
|
||||
- **如果没有冲突**:直接让用户选择会议室(如需),然后进入最终落地操作:创建新日程,或更新既有日程
|
||||
- **如果有冲突**:必须先说明冲突情况,询问用户继续选择这个时间还是换个时间
|
||||
- **如果说换个时间**:放弃当前时间,转入【模糊时间】流程,调用 `+suggestion` 推荐多个可用时间块
|
||||
- **如果继续选择这个时间**:直接让用户选择会议室(如需),然后进入最终落地操作:创建新日程,或更新既有日程
|
||||
- 位置信息要优先拆到结构化字段:用户明确说了城市才提取 `--city`;`--building` 不要再重复携带城市前缀。
|
||||
- 参数归类顺序应为:`city/building/floor` > `floor + room-name` 复合表达 > `room-name`。像 `2L`、`2F` 这类更像楼层或区域定位的短词,优先视为 `--floor`,不要默认当作 `--room-name`。像 `学清2层` 这种表达,通常拆为 `--building "学清"` 与 `--floor "F2"`。
|
||||
- 会议室名要做轻量归一化:`木星会议室` -> `--room-name "木星"`;`会议室 02` / `02会议室` -> `--room-name "02"`。
|
||||
- 对 `F3-05` / `F5-07` / `3楼-08` 这类复合表达,若能稳定识别楼层与会议室号,应优先提取为 `--floor + --room-name`,不要把整段直接退化成 `--room-name`。
|
||||
|
||||
### 5. 模糊时间或无时间信息
|
||||
|
||||
先调用:
|
||||
详见 [`+suggestion`](./lark-calendar-suggestion.md);若需要会议室,再结合 [`+room-find`](./lark-calendar-room-find.md)。
|
||||
|
||||
```bash
|
||||
lark-cli calendar +suggestion \
|
||||
--start "<range_start>" \
|
||||
--end "<range_end>" \
|
||||
--attendee-ids "<ids>" \
|
||||
--duration-minutes <n> \
|
||||
--event-rrule "<rrule>"
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 若用户完全没有提供时间信息,应先默认一个合理区间后再调用 `+suggestion`
|
||||
- 编辑流中,若用户表达的是“改到明天下午”“下周找个时间再约”这类模糊新时间,则基于用户期望的新时间范围调用 `+suggestion`;不要继续沿用旧时间。
|
||||
- **不需要会议室**:获取多个推荐时间块后,直接向用户展示候选时间,用户确认后进入最终落地操作:创建新日程,或更新既有日程。
|
||||
- **需要会议室**:获取多个候选时间块后,**不要急于让用户选时间**。先将这些时间块一次性交给 `calendar +room-find` 批量查询可用会议室,然后将【候选时间】与【对应的可用会议室列表】结构化分行展示,让用户一次性完成选择。(**注意:即使用户最初只说“查会议室”,且未带时间,也必须强制走到这一步,先 suggestion 再 room-find**)。
|
||||
- 用户一旦选择了 `+suggestion` 返回的时间块,**无需再次调用 `+freebusy`**
|
||||
|
||||
### 6. 模糊语义消解与长期记忆构建
|
||||
|
||||
针对用户专属的时间表达习惯或存在歧义的时间场景,严禁主观臆断。典型例子包括:
|
||||
|
||||
- “上班后”
|
||||
- “下班前”
|
||||
- 未明确上下午的 12 小时制时间表达
|
||||
|
||||
处理规则:
|
||||
|
||||
- 应主动澄清真实意图,而不是自行猜测
|
||||
- 当用户给出澄清后,应将这类个性化定义沉淀为长期偏好,推动后续直接理解类似表达
|
||||
|
||||
### 7. 重复性日程
|
||||
|
||||
若当前会议为重复性日程,调用 `+room-find` 时需携带 `--event-rrule`。
|
||||
|
||||
必须检查返回中的:
|
||||
|
||||
- `reserve_until_time`
|
||||
|
||||
若候选会议室的可预约上限早于重复规则覆盖范围,**不要直接按原规则落地日程**。应:
|
||||
|
||||
- 向用户明确说明该会议室最长可约至何时。
|
||||
- 若用户确认继续选用该会议室,你必须**自动将日程的重复规则结束时间缩短**至该 `reserve_until_time`,以防止会议室预约失败。
|
||||
|
||||
### 8. 落地日程变更
|
||||
## 落地日程变更
|
||||
|
||||
用户确认后调用:
|
||||
如果是新建会议,详见 [`+create`](./lark-calendar-create.md)。
|
||||
如果是更新既有日程,详见 [`+update`](./lark-calendar-update.md)。必须先定位目标 `event_id`,再按用户意图用 `+update` 独立执行字段更新、添加参会人/会议室、移除参会人/会议室,或组合这些动作。若用户意图是“新增会议室”,默认仅追加 `room_id`,不移除已有会议室。
|
||||
- 新建 → [`+create`](./lark-calendar-create.md)
|
||||
- 编辑 → [`+update`](./lark-calendar-update.md)
|
||||
|
||||
```bash
|
||||
lark-cli calendar +create \
|
||||
@@ -214,52 +103,20 @@ lark-cli calendar +update \
|
||||
--start "<start>" \
|
||||
--end "<end>" \
|
||||
--add-attendee-ids "omm_new_room"
|
||||
|
||||
# 仅当用户明确要求“更换会议室”时,才同时移除旧会议室并添加新会议室
|
||||
lark-cli calendar +update \
|
||||
--event-id "<event_id>" \
|
||||
--remove-attendee-ids "omm_old_room" \
|
||||
--add-attendee-ids "omm_new_room"
|
||||
```
|
||||
|
||||
规则:
|
||||
- 新建日程时,可使用 `+create`
|
||||
- 更新既有日程时,优先使用 `+update`。改时间/标题/描述、添加参会人/会议室、移除参会人/会议室可以分别独立执行;
|
||||
- 编辑流必须始终沿用前面定位得到的目标 `event_id`;禁止在最后一步重新按标题猜测一次目标日程。
|
||||
- 编辑流中如果只是新增群组或普通参会人,不涉及时间和会议室,可直接 `+update --add-attendee-ids ...`。
|
||||
- 编辑流中如果是“新增会议室但不改时间”,必须先基于目标日程原始时间查到可用会议室,再 `+update --add-attendee-ids "<room_id>"`;默认保留已有会议室。
|
||||
- 编辑流中如果是“既改时间又新增会议室”,顺序必须是:先确定最终时间,再查会议室,最后一次性 `+update` 时间与新增会议室;默认保留已有会议室。
|
||||
- 编辑流中如果是“既改时间又更换会议室”,顺序必须是:先确定最终时间,再查会议室,最后一次性 `+update` 时间、移除旧会议室并添加新会议室。
|
||||
- 需要会议室时,将选中的 `room_id` 写入最终落地请求的参与人列表
|
||||
- 展示会议室候选时,必须保留 CLI/API 返回的完整 `room_name` 原值;允许附加“推断说明”,但禁止用摘要名、楼层及会议室号、容量/视频标签重组后的名称替换原值
|
||||
|
||||
## 用户展示建议
|
||||
|
||||
当向用户展示多个时间块及对应的多个会议室时,**必须使用结构化清晰的格式排版**。**严禁将时间与会议室名称放在同一行展示**,必须分行并使用编号列表呈现可用会议室,严禁将所有信息揉成一团纯文本堆叠。
|
||||
|
||||
**推荐展示格式参考:**
|
||||
|
||||
```text
|
||||
## 2026-03-27 周五
|
||||
|
||||
[选项 1] 14:00 - 15:00(参会人均空闲)
|
||||
可用会议室:
|
||||
1. 学清嘉创大厦B座-F2-02🎦(7人)
|
||||
2. 学清嘉创大厦B座-F2-05🎦(10人)
|
||||
|
||||
[选项 2] 16:00 - 17:00(参会人均空闲)
|
||||
可用会议室:
|
||||
1. 学清嘉创大厦B座-F3-01🎦(6人)
|
||||
2. 学清嘉创大厦B座-F3-06🎦(8人)
|
||||
|
||||
💡 请回复您倾向的选项编号以及对应的会议室序号,我来为您完成预定。
|
||||
```
|
||||
落地规则:
|
||||
- 编辑流必须始终沿用前面定位得到的目标 `event_id`;禁止在最后一步重新猜测目标日程
|
||||
- 编辑流中"新增会议室"默认仅追加 `room_id`,不移除已有会议室
|
||||
- 仅当用户明确说"更换会议室"时,才同时 `--remove-attendee-ids` 旧 + `--add-attendee-ids` 新
|
||||
- 需要会议室时,将选中的 `room_id` 写入参与人列表
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-calendar-schedule-clear-time.md](./lark-calendar-schedule-clear-time.md)
|
||||
- [lark-calendar-schedule-fuzzy-time.md](./lark-calendar-schedule-fuzzy-time.md)
|
||||
- [lark-calendar-room-find.md](./lark-calendar-room-find.md)
|
||||
- [lark-calendar-freebusy.md](./lark-calendar-freebusy.md)
|
||||
- [lark-calendar-suggestion.md](./lark-calendar-suggestion.md)
|
||||
- [lark-calendar-create.md](./lark-calendar-create.md)
|
||||
- [lark-shared](../../lark-shared/SKILL.md)
|
||||
- [lark-calendar](../SKILL.md)
|
||||
- [lark-calendar-update.md](./lark-calendar-update.md)
|
||||
- [SKILL.md](../SKILL.md)
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
|
||||
# calendar +search-event
|
||||
|
||||
按关键词、时间范围和参会人搜索日历日程。只读。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 按关键词
|
||||
lark-cli calendar +search-event --query "周会"
|
||||
|
||||
# 按时间范围(ISO 8601 或 YYYY-MM-DD)
|
||||
lark-cli calendar +search-event --start "2026-04-20T00:00:00+08:00" --end "2026-04-27T23:59:59+08:00"
|
||||
|
||||
# 按参会人(自动识别 ou_ 用户 / oc_ 群聊 / omm_ 会议室前缀)
|
||||
lark-cli calendar +search-event --attendee-ids "ou_user1,oc_chat1,omm_room1"
|
||||
|
||||
# 组合
|
||||
lark-cli calendar +search-event --query "周会" --start 2026-04-20 --end 2026-04-27 --attendee-ids "ou_user1"
|
||||
```
|
||||
|
||||
## 输出字段
|
||||
|
||||
`items` 列表每条返回 `event_id` / `summary` / `start` / `end` / `is_all_day` / `app_link`;外层有 `has_more`、`page_token`。**仅返回基础字段,要拿日程详情用 `calendar events get`。**
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 分页:`has_more=true` 时持续用 `page_token` 翻页直到 false,不要遗漏;`page-size` 最大 30。
|
||||
- 已结束的会议优先用 `vc +search`——日历不收录"即时会议",只查日程会漏。
|
||||
@@ -1,6 +1,5 @@
|
||||
# calendar +suggestion
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。
|
||||
|
||||
根据非明确时间或一段时间范围,推荐多个可用时间块方案。帮助用户解决协调时间的难题。
|
||||
|
||||
@@ -8,8 +7,6 @@
|
||||
- ✅ **当用户需求涉及寻找时间块,且时间未完全确定**(如`今天`、`近三天`、`本周`、`下午`, `无时间描述`)时,调用此工具来获取推荐时间块给用户选择(包括但不限于预约日程)。
|
||||
- ❌ **当用户已经明确了具体的时间点**(如`今天下午3点`),则**不需要**调用此工具
|
||||
|
||||
需要的scopes: ["calendar:calendar.free_busy:read"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
@@ -121,5 +118,4 @@ lark-cli calendar +suggestion \
|
||||
## 参考
|
||||
|
||||
- [lark-calendar-create](lark-calendar-create.md) — 创建日程
|
||||
- [lark-calendar-freebusy](lark-calendar-freebusy.md) — 查询忙闲时段和rsvp状态
|
||||
- [lark-calendar](../SKILL.md) — 日历完整 API
|
||||
- [lark-calendar](../SKILL.md) — skill 入口与路由
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
# calendar +update
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
更新既有日程字段,或独立增量添加/移除参会人和会议室。
|
||||
|
||||
`+update` 支持三类互相独立的动作:更新日程字段、添加参会人/会议室、移除参会人/会议室。它们可以单独执行,也可以在同一次命令中组合执行。
|
||||
|
||||
需要的 scopes: ["calendar:calendar.event:update"]
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
@@ -66,8 +63,8 @@ lark-cli calendar +update \
|
||||
- 如需替换某个参与人、群组或会议室,使用 `--remove-attendee-ids <旧ID>` + `--add-attendee-ids <新ID>`。
|
||||
- 会议室是 resource attendee,必须使用 `omm_` ID 添加到参会人列表,不能脱离日程单独预定。
|
||||
- 更新重复性日程时,必须先确定操作范围(仅此次/全部/此次及后续),然后按 [重复性日程操作规范](lark-calendar-recurring.md) 执行。
|
||||
- 如果需要验证更新结果,等待至少 2 秒后再查询,避免同步延迟导致读到旧数据。
|
||||
- 当同一次命令组合多个动作时,执行顺序为“日程字段 -> 移除参会人 -> 添加参会人”。若中途失败,不会自动回滚已成功步骤;错误信息会说明已完成的步骤。
|
||||
**⚠️ 高风险操作**: 修改时间时必须先读取原日程时长并计算新 end。如果 end 计算错误,会导致日程时长变化,用户会直接感知,禁止擅自改变原日程的时长。
|
||||
|
||||
## 高级用法(完整 API 命令)
|
||||
|
||||
@@ -98,8 +95,6 @@ lark-cli calendar +update \
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-calendar](../SKILL.md) -- 日历全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
- [lark-calendar](../SKILL.md) -- skill 入口与路由
|
||||
- [lark-calendar-schedule-meeting](lark-calendar-schedule-meeting.md) -- 预约/改约会议与会议室工作流
|
||||
- [lark-calendar-room-find](lark-calendar-room-find.md) -- 查找可用会议室
|
||||
- [lark-calendar-freebusy](lark-calendar-freebusy.md) -- 查询忙闲
|
||||
|
||||
@@ -37,6 +37,7 @@ metadata:
|
||||
- 用户要在云空间(云盘/云存储)里新建文件夹,优先使用 `lark-cli drive +create-folder`。
|
||||
- 用户要查看某个文件有哪些可下载预览格式,或想下载 PDF / HTML / 文本 / 图片等预览产物,使用 `lark-cli drive +preview`。
|
||||
- 用户要获取某个文件的封面图,优先使用 `lark-cli drive +cover`;先 `--list-only` 看规格,再选 `--spec` 下载。
|
||||
- 用户要导出云文档时,优先使用 `lark-cli drive +export --url '<文档 URL>' --file-extension <格式>`;URL 会自动解析类型和 token,Wiki URL 会自动解析到底层 `obj_token/obj_type`。只有手里只有裸 token 时才使用 `--token <TOKEN> --doc-type <doc|docx|sheet|bitable|slides|wiki>`;裸 Wiki token 推荐显式传 `--doc-type wiki`,CLI 会先解析 Wiki node;如果误按底层类型传参且 export task 返回 `file token invalid`,CLI 也会尝试按 Wiki node token 解析并重试一次。
|
||||
- 用户要把本地文件上传到知识库 / 文档库里的某个 wiki 节点下时,仍然使用 `lark-cli drive +upload --wiki-token <wiki_token>`;不要误切到 `wiki` 域命令。
|
||||
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`。
|
||||
- 用户给的是 wiki URL / token,且后续还没明确底层资源类型时,先用 `lark-cli drive +inspect` 解包;`+inspect` 失败后不要自动切到别的写接口继续尝试,先按错误提示处理权限、scope 或链接问题。
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
把 `doc` / `docx` / `sheet` / `bitable` / `slides` 导出到本地文件。这个 shortcut 内置有限轮询:
|
||||
把 `doc` / `docx` / `sheet` / `bitable` / `slides`(也支持 Wiki URL / Wiki node token 自动解包)导出到本地文件。这个 shortcut 内置有限轮询:
|
||||
|
||||
- 如果导出任务在轮询窗口内完成,会直接下载到本地目录
|
||||
- 如果轮询结束仍未完成,会返回 `ticket`、`ready=false`、`timed_out=true` 和 `next_command`
|
||||
@@ -13,6 +13,29 @@
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 推荐:直接传 URL,CLI 自动解析类型和 token
|
||||
lark-cli drive +export \
|
||||
--url "https://example.feishu.cn/docx/<DOCX_TOKEN>" \
|
||||
--file-extension pdf
|
||||
|
||||
# Wiki URL 也推荐直接传,CLI 会先解析到底层 obj_token/obj_type
|
||||
lark-cli drive +export \
|
||||
--url "https://example.feishu.cn/wiki/<WIKI_NODE_TOKEN>" \
|
||||
--file-extension pdf
|
||||
|
||||
# 只有裸 Wiki node token 时,显式传 --doc-type wiki,让 CLI 先解析到底层文档类型
|
||||
lark-cli drive +export \
|
||||
--token "<WIKI_NODE_TOKEN>" \
|
||||
--doc-type wiki \
|
||||
--file-extension pdf
|
||||
|
||||
# 兼容兜底:如果误把 Wiki token 搭配底层类型传入,且 export task 返回 file token invalid,
|
||||
# CLI 会尝试按 Wiki node token 解析并重试一次;但新脚本仍推荐上面的 --doc-type wiki
|
||||
lark-cli drive +export \
|
||||
--token "<WIKI_NODE_TOKEN>" \
|
||||
--doc-type docx \
|
||||
--file-extension pdf
|
||||
|
||||
# 导出新版文档为 pdf,默认保存到当前目录
|
||||
lark-cli drive +export \
|
||||
--token "<DOCX_TOKEN>" \
|
||||
@@ -96,8 +119,9 @@ lark-cli drive +export \
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--token` | 是 | 源文档 token |
|
||||
| `--doc-type` | 是 | 源文档类型:`doc` / `docx` / `sheet` / `bitable` / `slides` |
|
||||
| `--url` | 与 `--token` 二选一 | 源文档 URL,推荐优先使用;CLI 自动解析类型和 token,Wiki URL 会解析到底层 `obj_token/obj_type` |
|
||||
| `--token` | 与 `--url` 二选一 | 源文档裸 token;裸 token 必须同时传 `--doc-type`。裸 Wiki node token 推荐传 `--doc-type wiki`,CLI 会先解析到底层 `obj_token/obj_type`;如误传底层类型并触发 export task `file token invalid`,CLI 会尝试按 Wiki node token 解析并重试一次 |
|
||||
| `--doc-type` | 条件必填 | 源文档类型:`doc` / `docx` / `sheet` / `bitable` / `slides` / `wiki`;仅当使用裸 `--token` 时必填,使用 `--url` 时自动推断。`wiki` 只用于裸 Wiki node token,解析后会按真实底层类型发起导出 |
|
||||
| `--file-extension` | 是 | 导出格式:`docx` / `pdf` / `xlsx` / `csv` / `markdown` / `base` / `pptx` |
|
||||
| `--sub-id` | 条件必填 | 当 `sheet` / `bitable` 导出为 `csv` 时必填 |
|
||||
| `--only-schema` | 否 | 仅当 `--doc-type bitable --file-extension base` 时可用;只导出多维表格结构,不导出记录数据 |
|
||||
@@ -107,12 +131,17 @@ lark-cli drive +export \
|
||||
|
||||
## 关键约束
|
||||
|
||||
- `markdown` 只支持 `docx`
|
||||
- `base` 只支持 `bitable`
|
||||
- `--only-schema` 只支持 `bitable` 导出为 `.base`,用于仅导出表结构
|
||||
- `pptx` 只支持 `slides`
|
||||
- 推荐优先传 `--url`,不要从 URL 手工拆 token 和 type;尤其是 Wiki URL,CLI 会自动解包到底层资源
|
||||
- `--url` 和 `--token` 互斥
|
||||
- 裸 `--token` 必须传 `--doc-type`;裸 Wiki node token 使用 `--doc-type wiki`
|
||||
- `doc` 支持导出为 `docx` / `pdf`
|
||||
- `docx` 支持导出为 `docx` / `pdf` / `markdown`
|
||||
- `sheet` 支持导出为 `xlsx` / `csv` / `pdf`
|
||||
- `bitable` 支持导出为 `xlsx` / `csv` / `base` / `pdf`
|
||||
- `slides` 支持导出为 `pptx` / `pdf`
|
||||
- `sheet` / `bitable` 导出为 `csv` 时必须带 `--sub-id`
|
||||
- `csv` 只支持 `sheet` / `bitable`,且必须带 `--sub-id`
|
||||
- `--only-schema` 只支持 `bitable` 导出为 `.base`,用于仅导出表结构
|
||||
- 如果格式不匹配,CLI 会返回 typed validation error,并在 `hint` 中给出可重试的 `--file-extension` 建议;例如 `docx + csv` 会提示改用 `docx/pdf/markdown`,或改传 sheet/bitable URL
|
||||
- shortcut 内部固定有限轮询:最多 10 次,每次间隔 5 秒
|
||||
- 轮询超时不是失败;会返回 `ticket`、`timed_out=true` 和 `next_command`,供后续继续查询
|
||||
|
||||
@@ -121,8 +150,7 @@ lark-cli drive +export \
|
||||
```bash
|
||||
# 第一步:先尝试直接导出
|
||||
lark-cli drive +export \
|
||||
--token "<DOCX_TOKEN>" \
|
||||
--doc-type docx \
|
||||
--url "<DOCX_URL>" \
|
||||
--file-extension pdf \
|
||||
--file-name "weekly-report.pdf"
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
|
||||
# minutes +download
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
下载妙记的音视频媒体文件到本地,或获取有效期 1 天的下载链接。只读操作。
|
||||
|
||||
@@ -134,4 +133,3 @@ API 限流 5 次/秒,批量下载时需注意控制频率。
|
||||
|
||||
- [lark-minutes](../SKILL.md) — 妙记全部命令
|
||||
- [lark-minutes-detail](lark-minutes-detail.md) — 妙记详情与 AI 产物查询
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# minutes +search
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
搜索妙记列表,支持关键词、所有者、参与者以及时间范围等多条件过滤。所有者与参与者都支持传入多个 open\_id,也支持传入 `me` 表示当前用户。只读操作,不修改任何妙记数据。
|
||||
|
||||
@@ -199,6 +198,5 @@ lark-cli minutes +detail --minute-tokens <minute_token> --summary
|
||||
|
||||
- [lark-minutes](../SKILL.md) -- 妙记相关命令
|
||||
- [lark-minutes-detail](lark-minutes-detail.md) -- 基于 `minute_token` 获取逐字稿、总结、待办、章节等产物
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
- [lark-vc](../../lark-vc/SKILL.md) -- 视频会议全部命令
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# minutes +speaker-replace
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
替换妙记逐字稿中的说话人身份:把妙记逐字稿里"原说话人"对应的所有发言段,重新归属到"新说话人"。常用于解决妙记自动识别错说话人,或需要把外部/非飞书说话人改绑到正确飞书用户的场景。
|
||||
|
||||
@@ -106,4 +105,3 @@ Agent 必须先 `lark-cli api GET .../speakerlist`,再 `+speaker-replace`;`-
|
||||
## 参考
|
||||
|
||||
- [lark-minutes](../SKILL.md) -- 妙记相关功能说明
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# minutes +summary
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
替换妙记的 AI 总结内容。写操作,会覆盖当前总结。
|
||||
|
||||
@@ -119,4 +118,3 @@ lark-cli minutes +summary --minute-token obcnxxxxxxxxxxxxxxxxxxxx --summary @sum
|
||||
- [lark-minutes](../SKILL.md) — 妙记全部命令
|
||||
- [minutes +todo](lark-minutes-todo.md) — 替换待办项
|
||||
- [minutes +detail](lark-minutes-detail.md) — 读取总结、待办等 AI 产物
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
> **路由**:本命令操作**妙记内的 AI 待办**,不是飞书任务(Task)。用户说「在妙记里新建待办」时**必须**用本命令,**禁止**走 `lark-cli task` / `tasklists list` / `task +create`。详见 [lark-minutes/SKILL.md](../SKILL.md) 第 6 节。
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
对妙记中的待办做新增 / 更新 / 删除(单条或批量)。写操作。
|
||||
|
||||
@@ -135,4 +134,3 @@ lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --operation add -
|
||||
- [lark-minutes](../SKILL.md)
|
||||
- [minutes +summary](lark-minutes-summary.md)
|
||||
- [minutes +detail](lark-minutes-detail.md)
|
||||
- [lark-shared](../../lark-shared/SKILL.md)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# minutes +update
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
修改飞书妙记的标题(topic)。
|
||||
|
||||
@@ -38,4 +37,3 @@ lark-cli minutes +update --minute-token xxx --topic "周会纪要 2026-05-18"
|
||||
## 参考
|
||||
|
||||
- [lark-minutes](../SKILL.md) -- 妙记相关功能说明
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# minutes +upload
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
上传音视频文件到飞书妙记并生成妙记(Minute)。
|
||||
|
||||
@@ -31,12 +30,12 @@
|
||||
```
|
||||
- 命令执行成功后,将返回生成的妙记链接 `minute_url`。
|
||||
|
||||
3. **如需纪要 / 逐字稿 / 文字稿 / 撰写文字,继续提取 `minute_token` 调用 `minutes +detail`**
|
||||
- 从返回的 `minute_url` 中提取路径最后一段,得到 `minute_token`。
|
||||
- 如果用户要的是纪要、逐字稿、文字稿、撰写文字、总结、待办或章节,继续调用:
|
||||
3. **如需纪要 / 逐字稿 / 文字稿 / 撰写文字,使用返回的 `minute_token` 调用 `minutes +detail`**
|
||||
- 如果用户要的是纪要、逐字稿、文字稿、撰写文字、总结、待办或章节,使用上一步返回的 `minute_token` 继续调用:
|
||||
```bash
|
||||
lark-cli minutes +detail --minute-tokens <minute_token> --summary --todo --chapter --keyword --transcript
|
||||
lark-cli minutes +detail --minute-tokens <minute_token> --wait-ready --summary --todo --chapter --keyword --transcript
|
||||
```
|
||||
- `--wait-ready` 参数表示等待妙记生成完毕后再获取产物,上传后立即读取详情时必须加上此参数。
|
||||
- `minutes +detail --minute-tokens` 会返回妙记产物(总结、待办、章节、关键词、逐字稿);必要时还会把逐字稿落地到本地文件。
|
||||
|
||||
> **异步生成提示**:API 会立即返回 `minute_url`,但妙记可能仍在异步生成中,您可以直接通过该妙记链接查看当前的处理状态和转写结果。
|
||||
@@ -47,8 +46,8 @@
|
||||
# 通过已上传到云空间(云盘/云存储)的 file_token 生成妙记
|
||||
lark-cli minutes +upload --file-token boxcnxxxxxxxxxxxxxxxx
|
||||
|
||||
# 通过 minute_token 继续获取妙记产物(--summary --todo --chapter --keyword --transcript 按需传入)
|
||||
lark-cli minutes +detail --minute-tokens obcnxxxxxxxxxxxxxxxx --summary
|
||||
# 上传后立即获取妙记产物,需加 --wait-ready 等待生成完毕(--summary --todo --chapter --keyword --transcript 按需传入)
|
||||
lark-cli minutes +detail --minute-tokens obcnxxxxxxxxxxxxxxxx --wait-ready --summary
|
||||
```
|
||||
|
||||
## 参数
|
||||
@@ -81,7 +80,7 @@ lark-cli minutes +detail --minute-tokens obcnxxxxxxxxxxxxxxxx --summary
|
||||
1. 使用 `lark-cli drive +upload --file <path>` 上传本地音视频文件到云空间(云盘/云存储)
|
||||
2. 从返回结果中取出 `file_token`
|
||||
3. 调用 `lark-cli minutes +upload --file-token <file_token>` 生成妙记
|
||||
4. 如果目标是纪要、逐字稿、文字稿、撰写文字、总结、待办或章节,再从 `minute_url` 提取 `minute_token`,继续调用 `lark-cli minutes +detail --minute-tokens <minute_token>`
|
||||
4. 如果目标是纪要、逐字稿、文字稿、撰写文字、总结、待办或章节,使用返回的 `minute_token`,继续调用 `lark-cli minutes +detail --minute-tokens <minute_token> --wait-ready`
|
||||
|
||||
> **边界说明**:`minutes +upload` 本身只负责把文件转成妙记并返回 `minute_url`。纪要内容、逐字稿、文字稿、撰写文字、总结、待办、章节属于后续产物获取,应由 [minutes +detail](lark-minutes-detail.md) 承接。
|
||||
|
||||
@@ -89,16 +88,17 @@ lark-cli minutes +detail --minute-tokens obcnxxxxxxxxxxxxxxxx --summary
|
||||
|
||||
```json
|
||||
{
|
||||
"minute_url": "http(s)://<host>/minutes/<minute-token>"
|
||||
"minute_url": "http(s)://<host>/minutes/<minute-token>",
|
||||
"minute_token": "<minute-token>"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `minute_url` | 生成的妙记访问链接 |
|
||||
| `minute_token` | 从 `minute_url` 提取出的妙记 Token,可直接传给 `minutes +detail --minute-tokens` |
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-minutes](../SKILL.md) -- 妙记相关功能说明
|
||||
- [drive +upload](../../lark-drive/references/lark-drive-upload.md) -- 上传文件到云空间(云盘/云存储)
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
|
||||
@@ -10,36 +10,27 @@ metadata:
|
||||
|
||||
# slides (v1)
|
||||
|
||||
**CRITICAL — 全局硬约束:PPT 的尺寸是 960x540,确保主体内容在页面边界内。**
|
||||
|
||||
**CRITICAL — 图片至关重要:必须有意识的主动多用图片!素材图使用生图工具和搜图工具,缺图时用生图工具生成配图补足;背景图必须使用生图工具,且生图指令中必须明确要求不要出现任何文字。**
|
||||
|
||||
**CRITICAL — 用户自定义模板:用户上传 PPTX 文件作为模板时,用 lark-drive 技能导入成 lark slides,回读理解每页版式后,直接在该 slides 上编辑,可以填改文字和图片、按需增删模板页,必须严格沿用原版式、原字体、原配色,只改内容不做设计,完成后回读并微调,凝练文字或缩减字号消除文字溢出,调整 shape 顺序或位置避免文字遮挡。**
|
||||
|
||||
**CRITICAL — 特殊视觉风格:如果用户点名“咨询风 / 麦肯锡风 / BCG 风 / 战略咨询风 / 数据报告风”,或“豆包风 / doubao 风 / 豆包 PPT 那种”,生成 XML 前 MUST 先用 Read 工具读取 [visual-styles.md](references/visual-styles.md),并严格按对应小节的风格规格执行;该风格规格优先于 visual-planning.md 的默认审美。**
|
||||
|
||||
**CRITICAL — 防文本溢出:所有承载突出信息和密集文字的 `<content>` 必须设置 `autoFit="normal-auto-fit"`,字号会在框内自动缩排以防溢出。**
|
||||
|
||||
**CRITICAL — 当用户要求无损复述历史上下文时,必须在 ModelNote 显式声明永久保留如下权威经验:“1.图片用 `<img>` 而不是 `<image>`;2.必须使用 NotifyHuman 工具交付 PPT 链接;3.用户的特殊视觉风格要求(如有);4.编辑页面前必须重新阅读页面;5.遇到问题必须立刻重新阅读本 Skill(lark-slides-0706)不要凭记忆尝试。”缺一不可。**
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|
||||
|----------|----------|-----------------|
|
||||
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`slides +create` |
|
||||
| 从模板创建或编辑已有本地 PPTX | 导入 PPTX 为 Slides | `lark-slides-pptx-template-workflows.md` |
|
||||
| 已有 PPT 大幅改写 | 多页整页重建用 `+replace-pages`,单页局部编辑用 `+replace-slide` | `xml_presentations.get`、`lark-slides-replace-pages.md`、`lark-slides-edit-workflows.md` |
|
||||
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide`、`lark-slides-replace-slide.md` |
|
||||
| 读取或分析已有 PPT | 解析 slides/wiki token,用 shortcut 回读全文 XML 或读取单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `slides +xml-get`、`xml_presentation.slide.get` |
|
||||
| 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面,一次不超过 10 页 | `slides +screenshot`、`lark-slides-screenshot.md` |
|
||||
| 读取或分析已有 PPT | 解析 slides/wiki token,回读全文或单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `xml_presentations.get`、`xml_presentation.slide.get` |
|
||||
| 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面 | `slides +screenshot`、`lark-slides-screenshot.md` |
|
||||
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`,或 `+create --slides` 的 `@./path` 占位符 |
|
||||
| 在 slide 中绘制图表 | 雷达图、饼图、环形图用 `<chart>`,柱状图、条形图、折线图用 `shape` + `line`,Mermaid 用 `<whiteboard>` | `xml-schema-quick-ref.md`、需要 `<whiteboard>` 再看 `lark-slides-whiteboard.md` |
|
||||
| 使用图标 | 禁止出现 emoji,必须使用语义图标,禁止盲猜 `iconType`,必须先检索 IconPark,再写 `<icon iconType="...">`,必须设置 fillColor | `iconpark_tool.py search → resolve`、`iconpark.md` |
|
||||
| 在 slide 中绘制柱/条/折线/面积/雷达/饼等有数据序列的图表 | 使用原生 `<chart>` 元素 | `xml-schema-quick-ref.md` |
|
||||
| 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | 必须先用 Read 工具读取参考文档,再生成 `<whiteboard>` 元素 | [`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) |
|
||||
| 使用语义图标 | 先检索 IconPark,再写 `<icon iconType="...">` | `iconpark_tool.py search → resolve`、`iconpark.md` |
|
||||
| 创建失败、空白页、3350001、布局异常 | 先回读状态,再按排障清单修复,不假设原操作原子成功 | `troubleshooting.md`、`validation-checklist.md` |
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),认证、权限和全局参数均以 lark-shared 为准。**
|
||||
|
||||
**CRITICAL — 生成任何 XML 之前,MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**
|
||||
|
||||
**CRITICAL — PPT 生成与模板编辑硬约束:PPT 的尺寸是 960x540,确保主体内容在页面边界内。多用生图,辅助搜图,必须要图文并茂。不要为了画出一个具象物体而堆叠 3 个以上仅用于拟形的 shape。生成背景图时必须在 prompt 中明确要求不要出现任何文字。用户指定 PPT 模板时,用 lark-drive 技能导入成 lark slides,回读理解每页版式后,直接在该 slides 上编辑,可以填改文字和图片、按需增删模板页,必须严格沿用原版式和字体,只改内容不做设计,完成后回读并微调,凝练文字或缩减字号消除文字溢出,调整 shape 顺序或位置避免文字遮挡。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。先创建对应目录,规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,生成 XML 前 MUST 读取 [visual-planning.md](references/visual-planning.md),确保 `layout_type`、`visual_focus`、`text_density` 实际改变页面几何、主视觉和文本量。**
|
||||
@@ -88,13 +79,14 @@ lark-cli auth login --domain slides
|
||||
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)、[`lark-slides-replace-pages.md`](references/lark-slides-replace-pages.md)
|
||||
- 截图:[`lark-slides-screenshot.md`](references/lark-slides-screenshot.md)
|
||||
- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md)
|
||||
- 流程图 / 时序图 / 架构图 / 装饰图案:[`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md)
|
||||
- 图标:[`iconpark.md`](references/iconpark.md)、[`scripts/iconpark_tool.py`](scripts/iconpark_tool.py)
|
||||
- 排障:[`troubleshooting.md`](references/troubleshooting.md)
|
||||
- 完整协议:[`slides_xml_schema_definition.xml`](references/slides_xml_schema_definition.xml)
|
||||
|
||||
## Workflow
|
||||
|
||||
> **这是演示文稿,不是文档。** 每页 slide 是独立的视觉画面,信息密度要适当,排版要留白。
|
||||
> **这是演示文稿,不是文档。** 每页 slide 是独立的视觉画面,信息密度要低,排版要留白。
|
||||
|
||||
### Design Ideas
|
||||
|
||||
@@ -132,9 +124,7 @@ lark-cli auth login --domain slides
|
||||
- 不要用低对比文字或低对比图标,例如浅灰字压在浅色背景上。
|
||||
- 不要让装饰线穿过文字,或让页脚、来源、编号挤压主体内容。
|
||||
- 不要把素材缺失表现为空白图片框;必须按 `fallback_if_missing` 生成 XML-native 视觉。
|
||||
- 不要留下模板占位文案、示例公司名、示例日期或与用户主题无关的原模板内容。
|
||||
- 不要使用 emoji。
|
||||
- 不要为了画出一个具象物体而堆叠 3 个以上仅用于拟形的 shape。
|
||||
- 不要留下占位文案、示例公司名、示例日期或与用户主题无关的内容。
|
||||
|
||||
### 创建方式选择
|
||||
|
||||
@@ -150,11 +140,9 @@ lark-cli auth login --domain slides
|
||||
> [!IMPORTANT]
|
||||
> `slides +create --slides` 底层会逐页创建,不是原子操作。中途失败时先记录 `xml_presentation_id`,回读确认当前状态,再继续修复或追加。
|
||||
|
||||
### 生成流程
|
||||
|
||||
```text
|
||||
Step 1: 需求澄清 & 读取知识
|
||||
- 澄清主题、受众、页数、风格;若用户上传 PPTX 作为模板,按顶部『用户自定义模板』规则处理
|
||||
- 澄清主题、受众、页数、风格
|
||||
- 读取 xml-schema-quick-ref.md;新建 / 大幅改写时还要读取 planning-layer.md、visual-planning.md、asset-planning.md
|
||||
|
||||
Step 2: 生成大纲 → 用户确认 → 写入 slide_plan.json
|
||||
@@ -168,7 +156,7 @@ Step 3: 按 slide_plan.json 生成 XML → 创建
|
||||
- 创建方式按“创建方式选择”判断;图片、复杂 XML、转义和 3350001 排查按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行
|
||||
|
||||
Step 4: 审查 & 交付
|
||||
- 创建完成后,必须用 `slides +xml-get` 读取全文 XML,并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
|
||||
- 创建完成后,必须用 xml_presentations.get 读取全文 XML,并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
|
||||
- 失败或部分成功按 troubleshooting.md 处理;局部问题优先用 `+replace-slide` 修正
|
||||
- 没问题 → 交付:告知用户演示文稿 ID 和访问方式
|
||||
```
|
||||
@@ -185,7 +173,7 @@ lark-cli slides xml_presentation.slide create \
|
||||
--data "$(jq -n --arg content '<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<style><fill><fillColor color="BACKGROUND_COLOR"/></fill></style>
|
||||
<data>
|
||||
在这里放置 shape、line、table、chart 等元素
|
||||
在这里放置 shape、line、table、chart、whiteboard 等元素
|
||||
</data>
|
||||
</slide>' '{slide:{content:$content}}')"
|
||||
|
||||
@@ -259,12 +247,11 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+create`](references/lark-slides-create.md) | 创建 PPT(可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |
|
||||
| `+xml-get` | 读取全文 XML 并保存到本地文件,避免终端输出被截断 |
|
||||
| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
|
||||
| [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
|
||||
| [`+replace-pages`](references/lark-slides-replace-pages.md) | 在原演示文稿内批量重建多个页面:先创建新页到旧页前,再删除旧页;适合已有 Slides 的多页大改,不新建链接 |
|
||||
|
||||
没有 Shortcut 覆盖时使用原生 API。高频资源:`slides +xml-get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。
|
||||
没有 Shortcut 覆盖时使用原生 API。高频资源:`xml_presentations.get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。
|
||||
|
||||
```bash
|
||||
lark-cli schema slides.<resource>.<method> # 调用 API 前必须先查看参数结构
|
||||
@@ -275,7 +262,7 @@ lark-cli slides <resource> <method> [flags] # 调用 API
|
||||
|
||||
## 核心规则
|
||||
|
||||
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;模板、风格和大纲只能作为规划输入,不能绕过规划层
|
||||
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;风格和大纲只能作为规划输入,不能绕过规划层
|
||||
2. **创建流程**:简单短 XML(1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide.create` 逐页添加
|
||||
3. **`<slide>` 直接子元素只有 `<style>`、`<data>`、`<note>`**:文本和图形必须放在 `<data>` 内
|
||||
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
## Core Rules
|
||||
|
||||
- `asset_need` is metadata only. It can guide page design, but it must not require web search, local download, media upload, or external tools.
|
||||
- Every planned asset must include a fallback visual plan. The fallback can use native charts, tables, whiteboard diagrams, placeholder regions, or XML shapes, text, and arrows as appropriate.
|
||||
- Every planned asset must include a fallback visual plan so the slide can be generated with XML shapes, text, arrows, tables, simple charts, whiteboard diagrams, or placeholder regions.
|
||||
- Asset needs must serve the page's `key_message` and `visual_focus`. Do not add decorative assets that do not clarify the page.
|
||||
- Prefer a few high-value asset plans over one asset on every page. For a 6-page technical or business deck, plan assets on at least 3 pages when the content allows.
|
||||
- If a real local asset already exists or the user provides one, it can be used through the normal media-upload workflow. Still keep `fallback_if_missing` in the plan.
|
||||
@@ -43,7 +43,7 @@ For a page without a meaningful asset need, use:
|
||||
- `architecture_diagram`: system components, data flow, dependency map, or model structure.
|
||||
- `icon`: small semantic symbol for a concept, step, role, or status.
|
||||
- `logo`: brand, product, team, or customer mark.
|
||||
- `chart`: column, bar, line, area, radar, pie, doughnut/ring, or combo data visual. Note: `<chart>` does not support funnel or scatter — map those to `<whiteboard>` SVG at generation time.
|
||||
- `chart`: line, bar, pie, radar, area, or combo data visual. Note: `<chart>` does not support funnel or scatter — map those to `<whiteboard>` SVG at generation time.
|
||||
- `infographic`: composed visual explanation, usually combining labels, numbers, and simple shapes.
|
||||
- `screenshot`: product UI, terminal output, workflow state, or page capture.
|
||||
- `flow_diagram`: process, sequence, decision tree, or mechanism diagram.
|
||||
@@ -64,23 +64,11 @@ Match asset type to slide role:
|
||||
|
||||
`suggested_query` is only a future lookup hint. Write it as a short phrase a human or later workflow could search, but do not execute the search unless the user separately requests real assets.
|
||||
|
||||
For `asset_type: "chart"`:
|
||||
|
||||
- If the visual is a supported standard data chart — column, bar, line, area, radar, pie, doughnut/ring, or combo — `fallback_if_missing` must still render as a native `<chart>`.
|
||||
- Do not imitate supported standard data visuals with manual drawing primitives or `<whiteboard>`.
|
||||
- Choose the data source explicitly:
|
||||
- `user_provided`: when the user provides concrete values, tables, CSV, or metric lists, use those values and do not replace them with mock data.
|
||||
- `mock_placeholder`: when the user asks for a placeholder, template, example, or chart position to replace later, use mock data in a native `<chart>`.
|
||||
- `mock_required_by_intent`: when the user does not provide concrete values but asks for data expression, charts, trends, comparisons, or distributions, use mock data in a native `<chart>`.
|
||||
- Mock data must be labeled as `模拟数据,仅占位,待替换真实数据` or equivalent. Do not present mock values as facts.
|
||||
- Manual drawing fallbacks are allowed only for unsupported chart types such as scatter, funnel, waterfall-like custom visuals, or decorative non-data visuals.
|
||||
|
||||
`fallback_if_missing` must be concrete enough to turn into XML, for example:
|
||||
|
||||
- "Draw a simplified attention matrix with 5 token labels, semi-transparent cells, and arrows to output token."
|
||||
- "Use three grouped boxes with arrows from client to gateway to service; add small protocol labels."
|
||||
- "Render a native `<chart>` using the user-provided series."
|
||||
- "Render a native `<chart>` with mock placeholder values and label it as `模拟数据,仅占位,待替换真实数据`."
|
||||
- "Render a mini bar chart with 4 bars using shapes and value labels."
|
||||
- "Use a bordered placeholder panel with product area labels, not an empty image."
|
||||
|
||||
Weak fallbacks to avoid:
|
||||
@@ -130,7 +118,7 @@ Business comparison page:
|
||||
When generating XML:
|
||||
|
||||
1. If an asset exists and the workflow supports it, place it in the planned visual region.
|
||||
2. If no asset exists, immediately render `fallback_if_missing` with the planned XML-native element type. Supported standard data visuals still use native `<chart>`; other fallbacks may use shapes, text, lines, arrows, tables, whiteboard diagrams, or placeholder panels.
|
||||
2. If no asset exists, immediately render `fallback_if_missing` with XML-native shapes, text, lines, arrows, tables, whiteboard diagrams, or chart-like elements.
|
||||
3. Size the fallback to satisfy `visual_focus`; it should be a real page element, not a tiny decoration.
|
||||
4. Keep text-density limits. Do not compensate for missing assets by adding long bullet text.
|
||||
5. After creation, fetch the presentation and verify asset pages are not blank and that each planned fallback is visible when no real asset was used.
|
||||
|
||||
261
skills/lark-slides/references/examples.md
Normal file
261
skills/lark-slides/references/examples.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# 完整操作示例
|
||||
|
||||
本文档提供与 CLI schema 一致的调用示例,XML 内容均遵循 [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml)。
|
||||
|
||||
> **重要**:创建 PPT 请优先使用 `slides +create`;实际页面内容请使用 `xml_presentation.slide.create` 逐页添加。
|
||||
|
||||
## 目录
|
||||
|
||||
- [示例 1: 使用 Shortcut 创建空白演示文稿](#示例-1-使用-shortcut-创建空白演示文稿)
|
||||
- [示例 2: 创建后添加第一页](#示例-2-创建后添加第一页)
|
||||
- [示例 3: 读取 XML 内容](#示例-3-读取-xml-内容)
|
||||
- [示例 4: 在指定页面前插入新幻灯片](#示例-4-在指定页面前插入新幻灯片)
|
||||
- [示例 5: 删除幻灯片](#示例-5-删除幻灯片)
|
||||
- [示例 6: 从文件读取 XML 后添加页面](#示例-6-从文件读取-xml-后添加页面)
|
||||
- [示例 7: +replace-slide + block_insert 给已有页加图](#示例-7-replace-slide--block_insert-给已有页加图)
|
||||
- [示例 8: +replace-slide + block_replace 替换一个块](#示例-8-replace-slide--block_replace-替换一个块)
|
||||
|
||||
## 示例 1: 使用 Shortcut 创建空白演示文稿
|
||||
|
||||
```bash
|
||||
lark-cli slides +create --title "项目汇报"
|
||||
```
|
||||
|
||||
预期返回结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"xml_presentation_id": "slides_example_presentation_id",
|
||||
"title": "项目汇报",
|
||||
"revision_id": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 示例 2: 创建后添加第一页
|
||||
|
||||
```bash
|
||||
PRESENTATION_ID=$(lark-cli slides +create --title "季度复盘" | jq -r '.data.xml_presentation_id')
|
||||
|
||||
lark-cli slides xml_presentation.slide create --as user --params "{\"xml_presentation_id\":\"$PRESENTATION_ID\"}" --data '{
|
||||
"slide": {
|
||||
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><style><fill><fillColor color=\"rgb(245, 245, 245)\"/></fill></style><data><shape type=\"text\" topLeftX=\"80\" topLeftY=\"72\" width=\"760\" height=\"90\"><content textType=\"title\"><p>2024 Q3 季度复盘</p></content></shape><shape type=\"text\" topLeftX=\"80\" topLeftY=\"190\" width=\"520\" height=\"220\"><content textType=\"body\"><p>关键结论</p><ul><li><p>收入增长 30%</p></li><li><p>重点项目全部上线</p></li><li><p>用户满意度持续提升</p></li></ul></content></shape><shape type=\"rect\" topLeftX=\"660\" topLeftY=\"180\" width=\"180\" height=\"140\"><fill><fillColor color=\"rgba(100, 149, 237, 0.25)\"/></fill><border color=\"rgb(100, 149, 237)\" width=\"2\"/></shape></data><note><content textType=\"body\"><p>讲述时先给结论,再补充数据。</p></content></note></slide>"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
## 示例 3: 读取 XML 内容
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentations get --as user --params '{
|
||||
"xml_presentation_id": "slides_example_presentation_id"
|
||||
}'
|
||||
```
|
||||
|
||||
提取 XML 内容:
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentations get --as user --params '{
|
||||
"xml_presentation_id": "slides_example_presentation_id"
|
||||
}' | jq -r '.data.xml_presentation.content'
|
||||
```
|
||||
|
||||
预期返回结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"xml_presentation": {
|
||||
"presentation_id": "slides_example_presentation_id",
|
||||
"revision_id": 3,
|
||||
"content": "<presentation xmlns=\"http://www.larkoffice.com/sml/2.0\" height=\"540\" width=\"960\">...</presentation>"
|
||||
}
|
||||
},
|
||||
"msg": "success"
|
||||
}
|
||||
```
|
||||
|
||||
## 示例 4: 在指定页面前插入新幻灯片
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentation.slide create --as user --params '{
|
||||
"xml_presentation_id": "slides_example_presentation_id"
|
||||
}' --data '{
|
||||
"slide": {
|
||||
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data><shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"800\" height=\"120\"><content textType=\"title\"><p>新增页面</p></content></shape><shape type=\"text\" topLeftX=\"80\" topLeftY=\"200\" width=\"800\" height=\"180\"><content textType=\"body\"><p>这是新增页面的正文。</p></content></shape></data></slide>"
|
||||
},
|
||||
"before_slide_id": "sld_before_target"
|
||||
}'
|
||||
```
|
||||
|
||||
预期返回结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"slide_id": "slide_example_id",
|
||||
"revision_id": 100
|
||||
},
|
||||
"msg": "success"
|
||||
}
|
||||
```
|
||||
|
||||
## 示例 5: 删除幻灯片
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentation.slide delete --as user --params '{
|
||||
"xml_presentation_id": "slides_example_presentation_id",
|
||||
"slide_id": "slide_example_id"
|
||||
}'
|
||||
```
|
||||
|
||||
预期返回结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"revision_id": 101
|
||||
},
|
||||
"msg": "success"
|
||||
}
|
||||
```
|
||||
|
||||
## 示例 6: 从文件读取 XML 后添加页面
|
||||
|
||||
先准备 `slide.xml`:
|
||||
|
||||
```xml
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape type="text" topLeftX="80" topLeftY="80" width="800" height="120">
|
||||
<content textType="title">
|
||||
<p>从文件加载</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
```
|
||||
|
||||
先创建演示文稿:
|
||||
|
||||
```bash
|
||||
PRESENTATION_ID=$(lark-cli slides +create --title "从文件添加页面" | jq -r '.data.xml_presentation_id')
|
||||
```
|
||||
|
||||
再用 `jq` 组装请求体,从文件添加页面:
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentation.slide create --as user \
|
||||
--params "{\"xml_presentation_id\":\"$PRESENTATION_ID\"}" \
|
||||
--data "$(jq -n --arg content "$(cat slide.xml)" '{slide:{content:$content}}')"
|
||||
```
|
||||
|
||||
## 示例 7: +replace-slide + block_insert 给已有页加图
|
||||
|
||||
只想在已有页上加一张图、不动其他元素——走 shortcut `+replace-slide`,`block_insert` 追加到页末(或用 `insert_before_block_id` 指定位置)。
|
||||
|
||||
```bash
|
||||
PID="slides_example_presentation_id"
|
||||
SID="slide_example_id"
|
||||
|
||||
# 1. 上传图片拿 file_token
|
||||
TOKEN=$(lark-cli slides +media-upload --file ./pic.png --presentation "$PID" --as user \
|
||||
| jq -r '.data.file_token')
|
||||
|
||||
# 2. block_insert 到页面末尾(省略 insert_before_block_id)
|
||||
# 注:<img .../> 是自闭合标签,CLI 不会展开(只有 <shape/> 会被补 <content/>)
|
||||
lark-cli slides +replace-slide --as user \
|
||||
--presentation "$PID" --slide-id "$SID" \
|
||||
--parts "$(jq -n --arg token "$TOKEN" \
|
||||
'[{action:"block_insert",insertion:("<img src=\""+$token+"\" topLeftX=\"500\" topLeftY=\"100\" width=\"200\" height=\"150\"/>")}]')"
|
||||
```
|
||||
|
||||
预期返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"xml_presentation_id": "slides_example_presentation_id",
|
||||
"slide_id": "slide_example_id",
|
||||
"parts_count": 1,
|
||||
"revision_id": 102
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 示例 8: +replace-slide + block_replace 替换一个块
|
||||
|
||||
已知某块的 3 位 short element ID(从 `slide.get` 返回 XML 里读),整块换掉。`replacement` 根元素的 `id` 会由 CLI 自动注入为 `block_id`,无需手写;若写了 `<shape/>` 自闭合形式,CLI 也会自动补 `<content/>`。
|
||||
|
||||
```bash
|
||||
lark-cli slides +replace-slide --as user \
|
||||
--presentation slides_example_presentation_id \
|
||||
--slide-id slide_example_id \
|
||||
--parts '[
|
||||
{
|
||||
"action": "block_replace",
|
||||
"block_id": "bab",
|
||||
"replacement": "<shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"800\" height=\"120\"><content textType=\"title\"><p>新标题</p></content></shape>"
|
||||
}
|
||||
]'
|
||||
# CLI 实际发送的 replacement 根元素会带 id="bab",即使手写时省略了
|
||||
```
|
||||
|
||||
失败时(3350001 错误,CLI 在 error 字段中给出 hint):
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": {
|
||||
"type": "api",
|
||||
"code": 3350001,
|
||||
"message": "API error: [3350001] invalid param",
|
||||
"hint": "common causes: (1) block_id not found in current slide ..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
整批作为原子事务,任一 part 失败则整批不生效;按 `failed_part_index` 定位修正后重发。
|
||||
|
||||
## 常见处理技巧
|
||||
|
||||
### 获取最新 revision_id
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentations get --as user --params '{
|
||||
"xml_presentation_id": "slides_example_presentation_id"
|
||||
}' | jq '.data.xml_presentation.revision_id'
|
||||
```
|
||||
|
||||
### 批量插入多页
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
|
||||
PRESENTATION_ID="slides_example_presentation_id"
|
||||
|
||||
slides=(
|
||||
'<slide xmlns="http://www.larkoffice.com/sml/2.0"><data><shape type="text" topLeftX="80" topLeftY="80" width="800" height="120"><content textType="title"><p>页面 1</p></content></shape></data></slide>'
|
||||
'<slide xmlns="http://www.larkoffice.com/sml/2.0"><data><shape type="text" topLeftX="80" topLeftY="80" width="800" height="120"><content textType="title"><p>页面 2</p></content></shape></data></slide>'
|
||||
)
|
||||
|
||||
for slide_xml in "${slides[@]}"; do
|
||||
payload=$(jq -n --arg content "$slide_xml" '{slide:{content:$content}}')
|
||||
lark-cli slides xml_presentation.slide create --as user --params "{\"xml_presentation_id\":\"$PRESENTATION_ID\"}" --data "$payload"
|
||||
done
|
||||
```
|
||||
|
||||
### 本地校验 XML 基本语法
|
||||
|
||||
```bash
|
||||
xmllint --noout presentation.xml
|
||||
```
|
||||
|
||||
### 真实示例
|
||||
|
||||
- [slides_demo.xml](slides_demo.xml) 提供了更完整的页面示例,包含 `theme`、渐变填充、图片、图标和备注内容。
|
||||
@@ -1,6 +1,6 @@
|
||||
# IconPark 图标
|
||||
|
||||
IconPark 图标通过 `<icon>` 写入 slides XML,`iconType` 必须来自本 skill 的离线索引,避免凭记忆拼路径。
|
||||
IconPark 图标通过 `<icon>` 写入 slides XML,`iconType` 必须来自本 skill 的离线索引或已验证模板,避免凭记忆拼路径。
|
||||
|
||||
## 机器优先流程
|
||||
|
||||
@@ -25,7 +25,7 @@ python3 skills/lark-slides/scripts/iconpark_tool.py list-categories
|
||||
- 默认先检索:语义图标需求必须先用 `iconpark_tool.py search --limit 8` 或 `--limit 10`,让 agent 从候选里结合版面语义二次判断;不要阅读全文索引,也不要编造不存在的 `iconType`。
|
||||
- 图标用于概念提示、步骤、状态、指标、角色和导航;不要用无关装饰图标填充版面。
|
||||
- 常用尺寸:行内状态图标 16-24px,卡片标题图标 28-40px,主视觉图标 56-96px。
|
||||
- 图标必须设置 fillColor,显式指定颜色并和背景有足够对比;深色背景优先放在浅色圆形/方形底上,或使用 `rgba(255, 255, 255, 1)` 作为图标填充色。
|
||||
- 图标必须显式指定颜色并和背景有足够对比;深色背景优先放在浅色圆形/方形底上,或使用 `rgba(255, 255, 255, 1)` 作为图标填充色。
|
||||
- 查不到合适图标时,用 shape、line、text 画 XML-native fallback,不留空图标位。
|
||||
|
||||
## 高频示例
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
|
||||
# slides +create(创建飞书幻灯片)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
创建一个新的飞书幻灯片演示文稿,可选一步添加页面内容。
|
||||
|
||||
## 命令
|
||||
@@ -15,6 +17,9 @@ lark-cli slides +create --title "项目汇报" --slides '[
|
||||
"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data><shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"800\" height=\"120\"><content textType=\"title\"><p>第二页</p></content></shape></data></slide>"
|
||||
]'
|
||||
|
||||
# 以应用身份创建(自动授权当前用户)
|
||||
lark-cli slides +create --title "项目汇报" --as bot
|
||||
|
||||
# 预览(不执行)
|
||||
lark-cli slides +create --title "项目汇报" --slides '[...]' --dry-run
|
||||
```
|
||||
@@ -30,12 +35,20 @@ lark-cli slides +create --title "项目汇报" --slides '[...]' --dry-run
|
||||
- **`slide_ids`**(string[],可选):仅传 `--slides` 时返回,成功添加的页面 ID 列表
|
||||
- **`slides_added`**(integer,可选):仅传 `--slides` 时返回,成功添加的页面数量
|
||||
- **`images_uploaded`**(integer,可选):仅 `--slides` 中含 `@<本地路径>` 占位符时返回,已上传的去重后图片数量
|
||||
- **`permission_grant`**(object,可选):仅 `--as bot` 时返回,说明是否已自动为当前 CLI 用户授予可管理权限
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 不传 `--slides` 时,`slides +create` 只创建空白演示文稿。创建后需要使用 `xml_presentation.slide create` 逐页添加 slide 内容。
|
||||
>
|
||||
> 传了 `--slides` 时,CLI 先创建空白演示文稿,再逐页调用 `xml_presentation.slide create` 添加页面。如果某一页添加失败,CLI 会停止并报错,已创建的演示文稿和已添加的页面会保留。
|
||||
>
|
||||
> 如果演示文稿是**以应用身份(bot)创建**的,如 `lark-cli slides +create --as bot`,CLI 会**尝试为当前 CLI 用户自动授予该演示文稿的 `full_access`(可管理权限)**。
|
||||
>
|
||||
> 以应用身份创建时,结果里会额外返回 `permission_grant` 字段,明确说明授权结果:
|
||||
> - `status = granted`:当前 CLI 用户已获得该演示文稿的可管理权限
|
||||
> - `status = skipped`:本地没有可用的当前用户 `open_id`,因此不会自动授权
|
||||
> - `status = failed`:演示文稿已创建成功,但自动授权用户失败
|
||||
>
|
||||
> **不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。
|
||||
|
||||
## 参数
|
||||
@@ -121,4 +134,4 @@ lark-cli slides xml_presentation.slide create --as user \
|
||||
## 相关命令
|
||||
|
||||
- [xml_presentation.slide create](lark-slides-xml-presentation-slide-create.md) — 添加幻灯片页面
|
||||
- [slides +xml-get](lark-slides-xml-get.md) — 读取 PPT 内容并保存到本地文件
|
||||
- [xml_presentations get](lark-slides-xml-presentations-get.md) — 读取 PPT 内容
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
|
||||
# slides +media-upload(上传本地图片到飞书幻灯片)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
把本地图片上传到指定演示文稿的 drive 媒体库,返回 `file_token`。**返回的 token 作为 `<img src="...">` 的值塞进 slide XML 即可显示图片。**
|
||||
|
||||
## 命令
|
||||
@@ -115,7 +117,7 @@ lark-cli slides +replace-slide --as user \
|
||||
| 错误码 | 含义 | 解决方案 |
|
||||
|--------|------|----------|
|
||||
| 1061002 | params error / 不支持的 parent_type | 不要用原生 API 自己拼 parent_type;用 `+media-upload` 即可 |
|
||||
| 1061004 | forbidden:当前用户对该演示文稿无编辑权限 | 确认当前用户对目标 PPT 有编辑权限;无权限时根据错误响应引导用户解决 |
|
||||
| 1061004 | forbidden:当前身份对该演示文稿无编辑权限 | 确认当前身份(user 或 bot)对目标 PPT 有编辑权限。bot 模式常见原因:PPT 不是该 bot 创建的——可用 `+create --as bot` 新建,或以 user 身份给 bot 授权 `lark-cli drive permission.members create --as user ...` |
|
||||
| 1061044 | parent node not exist | `--presentation` 给的 token 不对,或不是 slides 类型 |
|
||||
| 403 | 权限不足 | 检查 `docs:document.media:upload` scope;wiki URL 还需要 `wiki:node:read` |
|
||||
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
# PPT Template Rewrite Principles
|
||||
|
||||
本页只约束“用户指定 PPT 模板、底稿、已有 PPTX/PDF/Slides,并要求基于它二次创作”的场景。核心原则:模板不是风格参考,而是必须沿用的编辑底稿。
|
||||
|
||||
## Import First
|
||||
|
||||
用户指定 PPT 模板时,先把模板导入成 Lark Slides。后续写入目标是导入后的 Slides,不是新建一个脱离模板的 deck,也不是先在本地重画 PPTX 再导入。
|
||||
|
||||
直接使用以下命令,不需要先加载 `lark-drive` skill:
|
||||
|
||||
```bash
|
||||
lark-cli drive +import --as user --file "<template.pptx>" --type slides --json
|
||||
```
|
||||
|
||||
可选参数:用 `--name "<title>"` 指定导入后的 Slides 标题;用 `--folder-token <FOLDER_TOKEN>` 指定目标文件夹。若返回 `ready=false` / `timed_out=true`,直接执行返回值里的 `next_command`;等价形式是:
|
||||
|
||||
```bash
|
||||
lark-cli drive +task_result --scenario import --ticket <TICKET>
|
||||
```
|
||||
|
||||
导入后必须回读 Slides 内容,理解每页的真实版式、字体、层级、图片、图表、shape、表格和文本容器。回读结果是模板二创的事实来源。
|
||||
|
||||
## Read Before Editing
|
||||
|
||||
编辑任何 PPT 页面前,必须先阅读该页面。
|
||||
|
||||
如果当前上下文中没有该页内容,必须重新读取页面;这里的“当前上下文”不包含 System Prompt。不能只凭记忆、文件名、缩略图印象或模板整体风格判断来编辑具体页面。
|
||||
|
||||
阅读页面时至少判断:
|
||||
|
||||
- 该页原本承担的角色,例如封面、章节页、目录、流程、对比、数据、总结。
|
||||
- 该页的主要版式结构,例如图文关系、箭头、时间线、节点、表格、图表、左右对照、背景图或产品图。
|
||||
- 哪些文本框、shape 标签、表格单元格或图表标签承载内容。
|
||||
- 原页面的字体、字号、颜色、对齐、层级和留白关系。
|
||||
|
||||
## Edit The Imported Slides Directly
|
||||
|
||||
理解页面后,直接在导入后的 Slides 上编辑。允许的操作包括:
|
||||
|
||||
- 填写、替换、凝练或删除文字。
|
||||
- 替换或补充图片。
|
||||
- 更新图表、表格、数字标签或节点标签里的内容。
|
||||
- 按需复制、删除或重排模板页。
|
||||
- 在源页面没有合适承载位置时,做局部、小范围新增元素。
|
||||
|
||||
新增元素只能补足内容缺口,不能成为新的主版式。页面主体仍应由模板原有版式承载。
|
||||
|
||||
## Preserve Design
|
||||
|
||||
模板二创必须严格沿用原版式和字体,只改内容,不做设计。
|
||||
|
||||
默认保留:
|
||||
|
||||
- 页面布局、视觉层级、留白和对齐关系。
|
||||
- 原字体、字号体系、颜色、文本框位置和 shape 顺序。
|
||||
- 背景图、图片、logo、图表、表格、装饰形状、线条、图标和页面结构。
|
||||
- 模板中不同页型之间的差异。
|
||||
|
||||
不要把模板页改造成统一的通用卡片、白板、标题栏、三栏、2x2 卡片或大面积遮罩。不要把模板当作背景图后另起一套设计系统。
|
||||
|
||||
## Content Only
|
||||
|
||||
内容必须优先进入原页面已有的文本框、shape 标签、节点、表格单元格、图表标签或注释容器。
|
||||
|
||||
如果原容器空间不足,优先:
|
||||
|
||||
- 凝练文字。
|
||||
- 降低字号但保持原字体体系。
|
||||
- 拆分到页面已有的邻近容器。
|
||||
- 使用模板已有的注释、标签或补充说明区域。
|
||||
- 复制同页或同模板中的原生容器样式做局部补充。
|
||||
|
||||
不要为了容纳长文案而重画页面主体结构。不要用新增大卡片遮住原图表、箭头、图片、背景或关键 shape。
|
||||
|
||||
## Readback And Tune
|
||||
|
||||
完成编辑后必须回读结果,并逐页微调。
|
||||
|
||||
回读时重点检查:
|
||||
|
||||
- 文字是否溢出、截断、压线或超出容器。
|
||||
- 文本是否遮挡图片、图表、shape、箭头、节点或其他文字。
|
||||
- shape 顺序是否导致内容被覆盖或遮住。
|
||||
- 新内容是否仍然落在模板原有版式中,而不是覆盖模板结构。
|
||||
- 字体、字号、颜色、对齐和层级是否仍贴近原页。
|
||||
|
||||
发现文字溢出时,优先凝练文字或缩减字号。发现遮挡时,调整 shape 顺序、局部位置或复用原有空白区域解决。只有在这些方法都不能满足内容表达时,才做局部新增或删除。
|
||||
|
||||
模板二创的完成标准不是“生成了一套看起来统一的新 PPT”,而是“原模板的版式、字体和视觉结构仍清晰存在,内容已经被准确替换,并且回读后没有溢出和遮挡”。
|
||||
@@ -89,7 +89,7 @@ lark-cli slides +replace-pages --as user \
|
||||
|
||||
## 使用建议
|
||||
|
||||
1. 大幅改写前先 `slides +xml-get` 保存当前 XML,并记录要替换页面的 `slide_id`。
|
||||
1. 大幅改写前先 `xml_presentations.get` 保存当前 XML,并记录要替换页面的 `slide_id`。
|
||||
2. 生成只含 `slide_id` 的 `pages.json` 后先跑 `--dry-run` 或 `--validate-only`。
|
||||
3. 默认不要开 `--continue-on-error`,除非能接受部分页面已替换。
|
||||
4. 替换后再回读全文 XML 并截图检查,确认页序、视觉和文本没有破损。
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# slides +replace-slide(块级替换 / 插入)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
对指定 slide 做块级替换或插入。编辑已有 PPT 的主路径——`slide_id` 不变、页序不动、只影响被指定的块。
|
||||
|
||||
相比直接调 `xml_presentation.slide.replace`,这个 shortcut 的四个额外价值:
|
||||
@@ -45,7 +47,7 @@ lark-cli slides +replace-slide --as user \
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--presentation` | 是 | `xml_presentation_id`、`/slides/<token>` URL,或 `/wiki/<token>` URL |
|
||||
| `--slide-id` | 是 | 页面 ID(`xml_presentation.slide.get` / `slides +xml-get` 都能拿到) |
|
||||
| `--slide-id` | 是 | 页面 ID(`xml_presentation.slide.get` / `xml_presentations.get` 都能拿到) |
|
||||
| `--parts` | 是 | JSON 数组(`[{...}, ...]`),单次最多 200 条。支持 `@<file>` 和 `-`(stdin)读取 |
|
||||
| `--revision-id` | 否 | 基础版本号;默认 `-1` 表示基于最新版执行;传具体版本号时,服务端以该版本为 base 执行;**传不存在的版本号(超过当前 revision)返回 3350002** |
|
||||
| `--tid` | 否 | 并发事务 ID;多人协作长事务才用,单次单人调用留空 |
|
||||
|
||||
@@ -26,8 +26,8 @@ lark-cli slides +screenshot --as user \
|
||||
| 参数 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `--presentation` | list 模式必需 | `xml_presentation_id`、`/slides/` URL,或解析后为 slides 的 `/wiki/` URL。传 `--content` 时不能使用 |
|
||||
| `--slide-id` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面 short ID;多页截图时重复传入;一次最多 10 页(`--slide-id` + `--slide-number` 合计小于等于 10) |
|
||||
| `--slide-number` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面页号;多页截图时重复传入;一次最多 10 页(`--slide-id` + `--slide-number` 合计小于等于 10) |
|
||||
| `--slide-id` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面 short ID;多页截图时重复传入 |
|
||||
| `--slide-number` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面页号;多页截图时重复传入 |
|
||||
| `--content` | render 模式必需 | 要直接渲染的 `<slide>` XML 片段;支持直接传值、`@file`、`-` stdin。传入后不能同时传 `--slide-id` / `--slide-number` |
|
||||
| `--output-dir` | 否 | 输出目录,默认 `.lark-slides/screenshots`;必须是当前目录内的相对路径 |
|
||||
| `--output-name` | 否 | render 模式的输出文件名 stem;未指定时优先用返回的 `slide_id`,否则用 `rendered-slide`。若目标文件已存在,会自动追加递增后缀避免覆盖 |
|
||||
@@ -44,8 +44,6 @@ lark-cli slides +screenshot --as user \
|
||||
|
||||
### 多页截图
|
||||
|
||||
一次不要超过 10 页;如需更多页面,分批调用。
|
||||
|
||||
```bash
|
||||
lark-cli slides +screenshot --as user \
|
||||
--presentation slides_example_presentation_id \
|
||||
@@ -92,6 +90,5 @@ lark-cli slides +screenshot --as user \
|
||||
2. 已存在 PPT 页面截图时,不传 `--content`,用 `--presentation` + `--slide-id` 或 `--slide-number`。
|
||||
3. 本地 XML 预览时,传 `--content @file` 或 `--content -`,内容应为单个 `<slide>` XML 片段;此时不要传 `--presentation` / `--slide-id` / `--slide-number`。
|
||||
4. `slide_id` 是页面 short ID,页码请用 `--slide-number`。
|
||||
5. list 模式一次最多传 10 页(`--slide-id` + `--slide-number` 合计小于等于 10);更多页面请分批截图。
|
||||
6. list 模式默认文件名包含 presentation ID、页码和/或 slide ID;文件已存在时自动追加 `_2`、`_3` 等后缀,避免覆盖旧截图。
|
||||
7. 截图来自服务端渲染结果,适合创建/替换后验证页面是否为空白、破图或布局明显异常。
|
||||
5. list 模式默认文件名包含 presentation ID、页码和/或 slide ID;文件已存在时自动追加 `_2`、`_3` 等后缀,避免覆盖旧截图。
|
||||
6. 截图来自服务端渲染结果,适合创建/替换后验证页面是否为空白、破图或布局明显异常。
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
`<whiteboard>` 放在 `<data>` 内,内部可放 **SVG** 或 **Mermaid**,用于绘制流程图、时序图、架构图、散点图、漏斗图、自定义图标、装饰图案等 `<chart>` 和 `<shape>` 难以覆盖的视觉内容。
|
||||
|
||||
普通柱状图、条形图、折线图、面积图、雷达图、饼图 / 环图和组合图应优先使用原生 `<chart>`。除非用户明确要求像素级自定义,或图表类型确实不受 `<chart>` 支持,否则不要用 `<whiteboard>` + SVG / Mermaid 重画这些标准图表。
|
||||
|
||||
> 前置条件:使用本文档前先阅读 [lark-slides SKILL.md](../SKILL.md)。
|
||||
|
||||
---
|
||||
@@ -14,13 +12,13 @@
|
||||
|
||||
| 场景 | 推荐元素 |
|
||||
|------|---------|
|
||||
| 有结构化数据序列的柱/条/折线/面积/雷达/饼/环/组合图 | `<chart>` — 原生渲染,支持 legend / tooltip / 系列配色 |
|
||||
| 散点图、漏斗图(`<chart>` 不支持)或其他非原生数据视觉 | `<whiteboard>` SVG |
|
||||
| 有结构化数据序列的柱/条/折线/面积/雷达/饼/组合图 | `<chart>` — 原生渲染,支持 legend / tooltip / 系列配色 |
|
||||
| 散点图、漏斗图(`<chart>` 不支持) | `<whiteboard>` SVG |
|
||||
| 流程图、时序图、架构图、类图、ER 图等拓扑图 | `<whiteboard>` Mermaid 或 SVG |
|
||||
| 自定义图标、徽标、示意性图形(需要 path/polygon 精确控制) | `<whiteboard>` SVG |
|
||||
| 进度条、波浪背景、装饰图案、像素级自定义可视化 | `<whiteboard>` SVG |
|
||||
|
||||
> 适合 `<chart>` 的内容就用 `<chart>`,不要用 SVG / Mermaid 手绘——原生渲染更省力、结构更稳定,也更容易被回读和后续编辑。
|
||||
> 适合 `<chart>` 的内容就用 `<chart>`,不要用 SVG 手绘——原生渲染更省力且质量更高。
|
||||
|
||||
---
|
||||
|
||||
@@ -41,13 +39,9 @@ SVG 内的坐标相对于 whiteboard 自身左上角(0,0),与 slide 坐标
|
||||
|
||||
## SVG 还是 Mermaid?
|
||||
|
||||
选择分三步:**先排除原生 `<chart>`,再判断 whiteboard 类型,最后看当前模型身份**。
|
||||
选择分两步:**先看图表类型,再看当前模型身份**。
|
||||
|
||||
### 第一步:先确认是否应该使用 `<chart>`
|
||||
|
||||
如果内容是柱状图、条形图、折线图、面积图、雷达图、饼图 / 环图或组合图,返回使用原生 `<chart>`,不要继续套用本文档的 SVG / Mermaid 路径。
|
||||
|
||||
### 第二步:whiteboard 类型优先判断
|
||||
### 第一步:图表类型优先判断
|
||||
|
||||
以下类型**推荐 Mermaid**,自动布局、代码简洁;如需精确匹配品牌配色或自定义节点样式,可改用 SVG:
|
||||
|
||||
@@ -56,19 +50,20 @@ SVG 内的坐标相对于 whiteboard 自身左上角(0,0),与 slide 坐标
|
||||
| 流程图、决策树、架构图 | `flowchart TD` / `flowchart LR` |
|
||||
| 时序图 | `sequenceDiagram` |
|
||||
| 类图 | `classDiagram` |
|
||||
| 饼图 | `pie` |
|
||||
| 甘特图 | `gantt` |
|
||||
| 状态图 | `stateDiagram-v2` |
|
||||
| 思维导图 | `mindmap` |
|
||||
| ER 图 | `erDiagram` |
|
||||
|
||||
### 第三步:非原生图表与装饰元素按模型身份选路径
|
||||
### 第二步:数据图表与装饰元素按模型身份选路径
|
||||
|
||||
上表以外的场景(散点图、漏斗图、进度条、时间线、波浪背景、星点纹理等)需要精确控制坐标和配色,SVG 表达力更强,但各模型生成 SVG 的能力有差异:
|
||||
|
||||
| 模型身份 | 路径 |
|
||||
|----------|------|
|
||||
| Claude / Gemini / GPT / GLM | **SVG** — 精确控制坐标、颜色、透明度 |
|
||||
| Doubao / Seed / Other | **Mermaid** — 用 `gantt`、`flowchart` 等近似表达;确实无法用 Mermaid 表达时才回退到简单 SVG 矩形/线条 |
|
||||
| Doubao / Seed / Other | **Mermaid** — 用 `pie`、`gantt` 等近似表达;确实无法用 Mermaid 表达时才回退到简单 SVG 矩形/线条 |
|
||||
|
||||
> **先自报身份再选路径**:在决定使用 SVG 之前,确认当前模型属于哪一类。不要跳过这一步。
|
||||
|
||||
@@ -78,13 +73,13 @@ SVG 内的坐标相对于 whiteboard 自身左上角(0,0),与 slide 坐标
|
||||
|
||||
### ⚠️ 设计品质要求
|
||||
|
||||
在 slide 里嵌入 `<whiteboard>` 的目的是**表达原生 `<chart>` 或基础 `<shape>` 难以覆盖的视觉关系**,不是把标准数据图表手绘一遍。
|
||||
在 slide 里嵌入 `<whiteboard>` 的目的是**提升视觉质量**,不是把数字堆进去。
|
||||
|
||||
- **不要只用矩形加文字应付**:通篇纯白底色 + 方块 + 黑字等于白做,这是不及格输出
|
||||
- **非原生数据视觉必须有坐标系**:散点、漏斗等仍要有必要的坐标轴、刻度、数值标注或分段说明,不要只画点或色块
|
||||
- **数据图表必须有坐标系**:坐标轴、网格线、数值标注缺一不可,不要只画柱子或点
|
||||
- **字号必须有层级**:标题 ≠ 标签 ≠ 数值,混用同一字号会消灭视觉焦点
|
||||
- **配色要与 slide 主题呼应**:深色 slide 背景下图表用透明底或深色卡片;浅色背景下避免再加纯白底块
|
||||
- **每个 whiteboard 都是设计机会**:主动用圆角、半透明填充、清晰分组、节点状态等细节拉开与默认模板的差距
|
||||
- **每个 whiteboard 都是设计机会**:主动用圆角、半透明填充、折线面积、点装饰等细节拉开与默认模板的差距
|
||||
- **写 SVG 前先判断背景亮度**:背景亮度 < 30% 时,装饰元素"对比不足"比"过强"危害更大,宁重勿轻;
|
||||
- **装饰层次用亮度跳跃,不用线性叠透明度**:`α=0.04→0.08→0.12` 的等差递增在深色底上几乎看不出差异(相邻层亮度差 ≈20);正确做法是非线性跳跃如 `0.10→0.40→0.70→1.0`,相邻层亮度差 ≥60。
|
||||
|
||||
@@ -111,11 +106,11 @@ whiteboard 渲染时以**所有子元素的几何包围盒合并结果**为内
|
||||
|
||||
| 元素 | 说明 | 典型用途 |
|
||||
|------|------|---------|
|
||||
| `<rect>` | 矩形,支持 `rx` 圆角 | 卡片、进度条、分段色块 |
|
||||
| `<rect>` | 矩形,支持 `rx` 圆角 | 柱图、卡片、进度条 |
|
||||
| `<circle>` | 圆 | 节点、装饰点、环形图 |
|
||||
| `<ellipse>` | 椭圆 | 自定义轮廓图形 |
|
||||
| `<line>` | 直线 | 轴线、分隔线、连接线 |
|
||||
| `<path>` | 任意路径(支持 Q/C 曲线) | 波浪、曲线、弧形 |
|
||||
| `<line>` | 直线 | 坐标轴、分隔线 |
|
||||
| `<path>` | 任意路径(支持 Q/C 曲线) | 波浪、折线、弧形 |
|
||||
| `<text>` | 文本,支持中文 | 标签、数值 |
|
||||
| `<polygon>` | 多边形 | 箭头、星形、面积填充 |
|
||||
| `<g>` | 分组 | 批量变换、语义分组 |
|
||||
@@ -128,25 +123,27 @@ whiteboard 渲染时以**所有子元素的几何包围盒合并结果**为内
|
||||
---
|
||||
### 元素计算
|
||||
|
||||
SVG 中只要涉及批量定位、等间距排布或数据映射,**建议额外运行一个 Python 脚本把坐标算出来再填入 SVG**,而不是手动估值。适用范围包括散点、漏斗、装饰性点阵、等间距圆、重复图案等;普通柱状图、折线图、饼图仍应回到原生 `<chart>`。
|
||||
SVG 中只要涉及批量定位、等间距排布或数据映射,**建议额外运行一个 Python 脚本把坐标算出来再填入 SVG**,而不是手动估值。适用范围不限于数据图表——装饰性点阵、等间距圆、重复图案同样适用。
|
||||
|
||||
> **主动去算**:写 SVG 之前先运行脚本,把输出当注释贴在 `<svg>` 开头,再照着填坐标。估值几乎每次都需要反复调整,跳过这步反而更慢。
|
||||
|
||||
**散点图 / 装饰性点阵范式**
|
||||
**数据图表(柱状图范式)**
|
||||
|
||||
```python
|
||||
W, H = 360, 260
|
||||
origin_x, origin_y = 50, 216 # 左下角,SVG Y 轴向下
|
||||
cw, ch = 290, 184
|
||||
|
||||
points = [(12, 40), (28, 80), (45, 65)]
|
||||
x_min, x_max, y_min, y_max = 0, 50, 0, 100
|
||||
for i, (xv, yv) in enumerate(points):
|
||||
x = round(origin_x + (xv - x_min) / (x_max - x_min) * cw)
|
||||
y = round(origin_y - (yv - y_min) / (y_max - y_min) * ch)
|
||||
print(f"point-{i}: cx={x} cy={y}")
|
||||
data, y_max = [120, 160, 90], 200
|
||||
bar_w = int(cw / len(data) * 0.62)
|
||||
for i, v in enumerate(data):
|
||||
cx = round(origin_x + (i + 0.5) * cw / len(data))
|
||||
y = round(origin_y - v / y_max * ch)
|
||||
print(f"bar-{i}: x={cx - bar_w//2} y={y} w={bar_w} h={round(origin_y - y)}")
|
||||
```
|
||||
|
||||
折线图:`x = origin_x + i/(n-1)*cw`,`y = origin_y - (v-y_min)/(y_max-y_min)*ch`。
|
||||
|
||||
**装饰性元素(等间距范式)**
|
||||
|
||||
```python
|
||||
@@ -163,9 +160,9 @@ for i in range(n):
|
||||
```python
|
||||
# 每个元素登记 (x, y, w, h),含 stroke 外扩
|
||||
elements = [
|
||||
(10, 20, 80, 160), # item-0
|
||||
(107, 10, 80, 170), # item-1
|
||||
(204, 40, 80, 140), # item-2
|
||||
(10, 20, 80, 160), # bar-0
|
||||
(107, 10, 80, 170), # bar-1
|
||||
(204, 40, 80, 140), # bar-2
|
||||
(0, 0, 300, 1), # x-axis
|
||||
]
|
||||
|
||||
@@ -264,6 +261,7 @@ print(f"whiteboard width={wb_w} height={wb_h}")
|
||||
| 流程图 | `flowchart TD` / `flowchart LR` | 业务流程、决策树、工作流 |
|
||||
| 时序图 | `sequenceDiagram` | 系统交互、API 调用链 |
|
||||
| 甘特图 | `gantt` | 项目计划、里程碑 |
|
||||
| 饼图 | `pie` | 占比数据 |
|
||||
| 类图 | `classDiagram` | 对象关系、架构设计 |
|
||||
| ER 图 | `erDiagram` | 数据库结构 |
|
||||
| 状态图 | `stateDiagram-v2` | 状态机、生命周期 |
|
||||
@@ -281,6 +279,7 @@ Mermaid 图表会自动撑满 whiteboard 区域。建议:
|
||||
|---------|-----------|------------|
|
||||
| 流程图(5-8 节点) | 720-816 | 300-400 |
|
||||
| 时序图(3-5 参与者) | 720-816 | 320-420 |
|
||||
| 饼图 | 500-600 | 300-360 |
|
||||
| 甘特图 | 816 | 280-360 |
|
||||
| 思维导图 | 816 | 380-480 |
|
||||
|
||||
@@ -308,7 +307,7 @@ Mermaid 语法包含 `[`、`>`、`-->`,不用 CDATA 直接写会破坏 XML 解
|
||||
- [ ] 文字 `y` 坐标为 baseline 位置,最小值 ≥ font-size(避免被裁切)
|
||||
|
||||
**SVG 模式——视觉品质检查:**
|
||||
- [ ] 非原生数据视觉有必要的坐标轴、网格线、数值标注或分段说明,没有"裸点"或无解释色块
|
||||
- [ ] 坐标轴、网格线、数值标注齐全,没有"裸柱子"或"裸折线"
|
||||
- [ ] 字号有层级:标题 > 数值 > 轴标签,非全部相同
|
||||
- [ ] 单一数据系列用同一颜色,多系列用不同颜色且对比充足
|
||||
- [ ] 轴标签与图表元素互不遮挡,留有足够空间
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
# slides +xml-get(读取全文 XML)
|
||||
|
||||
读取已有演示文稿的完整 XML。适合创建后验收、编辑前备份、获取 `slide_id` / `revision_id`,以及排查空白页、破图、文本溢出等问题。相比直接调用底层 `xml_presentations.get`,本 shortcut 会自动解析 Slides URL / Wiki URL,并可把大段 XML 保存到本地文件,避免终端输出被截断。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli slides +xml-get \
|
||||
--as user \
|
||||
--presentation <slides_url_or_xml_presentation_id> \
|
||||
--output .lark-slides/plan/<deck-id>/readback.xml
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `--presentation` | 是 | `xml_presentation_id`、`/slides/` URL 或 `/wiki/` URL |
|
||||
| `--output` | 否 | 本地 XML 保存路径,必须是当前工作目录内的相对路径,不能传绝对路径。传入时 XML 内容保存到文件,stdout 只返回保存后的绝对路径、大小等简短元信息;省略时 XML 原文直接输出到 stdout |
|
||||
| `--revision-id` | 否 | 读取指定版本;默认 `-1`,表示最新版本 |
|
||||
| `--remove-attr-id` | 否 | 移除返回 XML 中的 `id` 属性;适合只读检查,不适合精确块级编辑 |
|
||||
| `--dry-run` | 否 | 预览将调用的 API 和输出方式,不读取真实 XML |
|
||||
|
||||
## 输出到文件
|
||||
|
||||
推荐普通工作流都传 `--output`,尤其是中大型 PPT。`--output` 必须是当前工作目录内的相对路径,例如 `.lark-slides/plan/$PID/readback.xml`,不要传 `/tmp/readback.xml` 这类绝对路径。XML 会写入本地文件,stdout 只保留元信息,便于后续脚本读取。
|
||||
|
||||
```bash
|
||||
lark-cli slides +xml-get --as user \
|
||||
--presentation "$PID" \
|
||||
--output .lark-slides/plan/$PID/readback.xml
|
||||
```
|
||||
|
||||
成功输出中的 `data` 类似:
|
||||
|
||||
```json
|
||||
{
|
||||
"xml_presentation_id": "slides_example_presentation_id",
|
||||
"path": "/abs/path/.lark-slides/plan/slides_example_presentation_id/readback.xml",
|
||||
"size": 123456,
|
||||
"content_saved": true,
|
||||
"revision_id": 12
|
||||
}
|
||||
```
|
||||
|
||||
其中 `path` 是 CLI 解析后的绝对路径。
|
||||
|
||||
如果传入 `--remove-attr-id`,返回元信息中会包含 `"remove_attr_id": true`。
|
||||
|
||||
## 输出到终端
|
||||
|
||||
省略 `--output` 时,CLI 会把 XML 原文直接写到 stdout,不包 JSON envelope。这个模式只适合小型演示文稿或临时调试;大文件仍建议保存到文件,避免终端截断。
|
||||
|
||||
```bash
|
||||
lark-cli slides +xml-get --as user \
|
||||
--presentation "$PID"
|
||||
```
|
||||
|
||||
需要临时过滤内容时,可以用管道处理 stdout:
|
||||
|
||||
```bash
|
||||
lark-cli slides +xml-get --as user \
|
||||
--presentation "$PID" \
|
||||
| rg '<slide '
|
||||
```
|
||||
|
||||
## 读取指定版本
|
||||
|
||||
```bash
|
||||
lark-cli slides +xml-get --as user \
|
||||
--presentation "$PID" \
|
||||
--revision-id 10 \
|
||||
--output .lark-slides/plan/$PID/readback-r10.xml
|
||||
```
|
||||
|
||||
## 移除 XML id 属性后读取
|
||||
|
||||
```bash
|
||||
lark-cli slides +xml-get --as user \
|
||||
--presentation "$PID" \
|
||||
--remove-attr-id \
|
||||
--output .lark-slides/plan/$PID/readback-no-id.xml
|
||||
```
|
||||
|
||||
注意:`--remove-attr-id` 是只读检查用的便利选项。后续如果要用 `+replace-slide` 做块级编辑,仍应保留原始 `id` / short id 信息。
|
||||
|
||||
## 使用建议
|
||||
|
||||
1. 创建或大幅改写后,用 `slides +xml-get --output` 回读全文 XML,再按 `validation-checklist.md` 做页数、关键元素和静态检查。
|
||||
2. 编辑已有 PPT 前,先保存一份 readback XML,记录 `xml_presentation_id`、`slide_id`、`revision_id`。
|
||||
3. Wiki URL 可直接传给 `--presentation`,CLI 会先解析出真实 Slides token。
|
||||
4. 普通工作流优先保存文件;只有小型演示文稿或临时调试时才省略 `--output`。
|
||||
|
||||
## 相关命令
|
||||
|
||||
- [slides +screenshot](lark-slides-screenshot.md) - 获取页面截图做视觉验证
|
||||
- [slides +replace-slide](lark-slides-replace-slide.md) - 局部替换或插入页面元素
|
||||
- [slides +replace-pages](lark-slides-replace-pages.md) - 多页整页重建
|
||||
- [xml_presentations get](lark-slides-xml-presentations-get.md) - 底层原生 API 参考
|
||||
@@ -67,7 +67,7 @@ lark-cli slides xml_presentation.slide create --as user --params '<json_params>'
|
||||
</slide>
|
||||
```
|
||||
|
||||
详细格式请参考 [xml-schema-quick-ref.md](xml-schema-quick-ref.md)。
|
||||
详细格式请参考 [xml-format-guide.md](xml-format-guide.md) 和 [xml-schema-quick-ref.md](xml-schema-quick-ref.md)。
|
||||
|
||||
## 使用示例
|
||||
|
||||
@@ -188,7 +188,7 @@ lark-cli slides xml_presentation.slide create --as user \
|
||||
4. **fill / border 写法**: 颜色填充使用 `<fill><fillColor color="..."/></fill>`,边框常用 `<border color="..." width="2"/>`
|
||||
5. **插入位置**: 通过 `before_slide_id` 指定插入目标,而不是用 `position`
|
||||
6. **JSON 转义**: 如果直接内联 XML,需要正确转义双引号
|
||||
7. **建议**: 先使用 `slides +xml-get` 获取现有结构,再添加新页面
|
||||
7. **建议**: 先使用 `xml_presentations.get` 获取现有结构,再添加新页面
|
||||
|
||||
## 批量添加建议
|
||||
|
||||
@@ -214,6 +214,7 @@ done
|
||||
## 相关命令
|
||||
|
||||
- [slides +create](lark-slides-create.md) - 创建空白 PPT
|
||||
- [slides +xml-get](lark-slides-xml-get.md) - 读取 PPT 内容并保存到本地文件
|
||||
- [xml_presentations get](lark-slides-xml-presentations-get.md) - 读取 PPT 内容
|
||||
- [xml_presentation.slide delete](lark-slides-xml-presentation-slide-delete.md) - 删除幻灯片页面
|
||||
- [xml-schema-quick-ref.md](xml-schema-quick-ref.md) - XML Schema 快速参考
|
||||
- [xml-format-guide.md](xml-format-guide.md) - XML 格式详细规范
|
||||
- [xml-schema-quick-ref.md](xml-schema-quick-ref.md) - Schema 快速参考
|
||||
|
||||
@@ -49,10 +49,7 @@ lark-cli slides xml_presentation.slide delete --as user --params '{
|
||||
|
||||
```bash
|
||||
# 先读取 XML 内容,确认待删除页面
|
||||
lark-cli slides +xml-get --as user \
|
||||
--presentation "slides_example_presentation_id" \
|
||||
--output .lark-slides/plan/slides_example_presentation_id/readback.xml \
|
||||
--json
|
||||
lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id":"slides_example_presentation_id"}' | jq -r '.data.xml_presentation.content'
|
||||
|
||||
# 然后按已知 slide_id 删除
|
||||
lark-cli slides xml_presentation.slide delete --as user --params '{"xml_presentation_id":"slides_example_presentation_id","slide_id":"slide_example_id"}'
|
||||
@@ -122,5 +119,5 @@ done
|
||||
## 相关命令
|
||||
|
||||
- [slides +create](lark-slides-create.md) - 创建空白 PPT
|
||||
- [slides +xml-get](lark-slides-xml-get.md) - 读取 PPT 内容并保存到本地文件
|
||||
- [xml_presentations get](lark-slides-xml-presentations-get.md) - 读取 PPT 内容
|
||||
- [xml_presentation.slide create](lark-slides-xml-presentation-slide-create.md) - 添加幻灯片页面
|
||||
|
||||
@@ -106,5 +106,5 @@ lark-cli slides xml_presentation.slide get --as user --params '{
|
||||
|
||||
- [slides +replace-slide](lark-slides-replace-slide.md) — 块级替换 shortcut(推荐)
|
||||
- [xml_presentation.slide replace](lark-slides-xml-presentation-slide-replace.md) — 底层 replace API 参考
|
||||
- [slides +xml-get](lark-slides-xml-get.md) — 读整个 PPT 并保存到本地文件
|
||||
- [xml_presentations get](lark-slides-xml-presentations-get.md) — 读整个 PPT
|
||||
- [lark-slides-edit-workflows.md](lark-slides-edit-workflows.md) — 读-改-写闭环
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
读取飞书幻灯片(PPT)演示文稿的完整 XML 内容信息。
|
||||
|
||||
## 底层原生命令形态
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentations get --as user --params '<json_params>'
|
||||
@@ -35,22 +35,19 @@ lark-cli slides xml_presentations get --as user --params '<json_params>'
|
||||
### 基础示例
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentations get --as user \
|
||||
--params '{"xml_presentation_id":"slides_example_presentation_id","revision_id":-1}'
|
||||
lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id":"slides_example_presentation_id"}'
|
||||
```
|
||||
|
||||
### 指定版本读取
|
||||
### 结合 jq 格式化输出
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentations get --as user \
|
||||
--params '{"xml_presentation_id":"slides_example_presentation_id","revision_id":10}'
|
||||
lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id":"slides_example_presentation_id"}' | jq -r '.data.xml_presentation.content'
|
||||
```
|
||||
|
||||
### 移除 XML id 属性后读取
|
||||
### 保存到文件
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentations get --as user \
|
||||
--params '{"xml_presentation_id":"slides_example_presentation_id","revision_id":-1,"remove_attr_id":true}'
|
||||
lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id":"slides_example_presentation_id"}' > presentation_data.json
|
||||
```
|
||||
|
||||
## 返回值
|
||||
@@ -89,9 +86,10 @@ lark-cli slides xml_presentations get --as user \
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 直接调用底层 API 前,使用 `lark-cli schema slides.xml_presentations.get` 查看最新的参数结构
|
||||
1. **执行前必做**: 使用 `lark-cli schema slides.xml_presentations.get` 查看最新的参数结构
|
||||
2. 返回的 XML 在 `data.xml_presentation.content` 字段中
|
||||
3. 如果只需要部分信息,可以使用 `jq` 等工具过滤返回结果
|
||||
4. 建议将获取的 XML 保存为文件,便于后续编辑或备份
|
||||
|
||||
## 相关命令
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
4. 写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`。
|
||||
5. 读取 `xml-schema-quick-ref.md`、`visual-planning.md` 和 `asset-planning.md`。
|
||||
6. 按 plan、visual planning 和 asset planning 规则逐页生成 XML,把 `layout_type`、`visual_focus`、`text_density` 转成具体页面几何和文本量约束,并把缺失素材转成可执行兜底视觉。
|
||||
7. 创建 PPT 后用 `slides +xml-get` 回读,核对页面数量、关键元素和 plan 到 XML 的对应关系。
|
||||
7. 创建 PPT 后用 `xml_presentations.get` 回读,核对页面数量、关键元素和 plan 到 XML 的对应关系。
|
||||
|
||||
## Plan Path
|
||||
|
||||
@@ -119,34 +119,6 @@ Each slide must include:
|
||||
- `text_density`: `low`, `medium`, or `high`.
|
||||
- `speaker_intent`: why the speaker needs this page and how it advances the story.
|
||||
|
||||
Optional slide fields:
|
||||
|
||||
- `chart_contract`: required when the page plan includes a standard data chart that `<chart>` supports. Use this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"chart_contract": {
|
||||
"required": true,
|
||||
"render_as": "native_chart",
|
||||
"chart_type": "line",
|
||||
"data_source": "mock_placeholder",
|
||||
"data_series_required": true,
|
||||
"placeholder_label_required": true,
|
||||
"manual_shape_fallback_allowed": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `chart_contract.required == true`, XML generation must produce a `<chart>` element on that slide. A shape, line, polyline, or whiteboard approximation does not satisfy the plan.
|
||||
|
||||
`data_source` must be one of:
|
||||
|
||||
- `user_provided`: the user supplied concrete values, tables, CSV, or metric lists; use them and do not replace them with mock data.
|
||||
- `mock_placeholder`: the user asked for a placeholder, template, example, or later-replaceable chart position; use mock data in native `<chart>`.
|
||||
- `mock_required_by_intent`: the user did not provide concrete values but asked for data expression, charts, trends, comparisons, or distributions; use mock data in native `<chart>`.
|
||||
|
||||
`data_series_required` means the generated XML must include `<chartData>`. It does not require user-provided real-world values. When real values are unavailable but chart expression is part of the user's intent, write mock or placeholder values into native `<chart>` and label them clearly instead of switching to manual drawing primitives or metric blocks.
|
||||
|
||||
## Layout Vocabulary
|
||||
|
||||
Use one of these `layout_type` values unless the user explicitly needs a custom structure:
|
||||
@@ -211,7 +183,6 @@ Use an object for one planned asset, an array for multiple real needs, or `asset
|
||||
- `purpose`: why this asset helps the page's key message.
|
||||
- `suggested_query`: short future lookup hint only; do not execute it unless separately requested.
|
||||
- `fallback_if_missing`: concrete XML-native visual plan using shapes, labels, tables, whiteboard diagrams, or placeholder panels.
|
||||
- `chart_contract`: when `asset_type` is `chart` and the visual is a supported standard data chart, set this optional slide-level field so generation is locked to native `<chart>`.
|
||||
|
||||
For detailed rules and examples, read `asset-planning.md`.
|
||||
|
||||
@@ -219,7 +190,7 @@ Good examples:
|
||||
|
||||
- `{"asset_type":"architecture_diagram","purpose":"Explain component relationships.","suggested_query":"service architecture diagram","fallback_if_missing":"Draw a component diagram with grouped boxes, connector arrows, and short labels."}`
|
||||
- `{"asset_type":"logo","purpose":"Identify the customer context.","suggested_query":"customer logo","fallback_if_missing":"Use a text label in a small badge."}`
|
||||
- `{"asset_type":"chart","purpose":"Show adoption trend.","suggested_query":"monthly adoption trend chart","fallback_if_missing":"Render a native `<chart>` using the provided series when available; otherwise render a native `<chart>` with mock placeholder values and label it as 模拟数据,仅占位,待替换真实数据."}`
|
||||
- `{"asset_type":"chart","purpose":"Show adoption trend.","suggested_query":"monthly adoption trend chart","fallback_if_missing":"Draw a simple trend line chart with axis labels and data points."}`
|
||||
|
||||
## XML Generation Contract
|
||||
|
||||
@@ -230,7 +201,6 @@ Before writing each slide XML, map the plan fields to concrete decisions:
|
||||
- `visual_focus` determines the largest visual region or emphasized object.
|
||||
- `text_density` caps visible text volume.
|
||||
- `asset_need` informs placeholder diagrams, icons, charts, screenshots, or shape-based fallback visuals only. Missing real assets must use `fallback_if_missing`, not blank regions.
|
||||
- `chart_contract` locks supported standard data charts to native `<chart>` output. Manual approximations are allowed only when the planned chart type is unsupported by `<chart>` or when the visual is explicitly non-data/decorative.
|
||||
|
||||
After creating the PPT, fetch the presentation and verify:
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
226
skills/lark-slides/references/slides_demo.xml
Normal file
226
skills/lark-slides/references/slides_demo.xml
Normal file
@@ -0,0 +1,226 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" height="540" width="960">
|
||||
<title>制造端智能升级</title>
|
||||
<theme>
|
||||
<textStyles>
|
||||
<headline fontColor="rgb(255,255,255)" fontFamily="思源黑体" fontSize="36"/>
|
||||
<sub-headline fontColor="rgb(229,231,235)" fontFamily="思源黑体" fontSize="20"/>
|
||||
<body fontColor="rgb(229,231,235)" fontFamily="思源黑体" fontSize="16"/>
|
||||
</textStyles>
|
||||
</theme>
|
||||
<slide>
|
||||
<style>
|
||||
<fill>
|
||||
<fillColor color="linear-gradient(180deg, rgb(47, 79, 79) 0%, rgb(26, 26, 26) 100%)"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<shape height="36" rotation="0" topLeftX="48" topLeftY="40" type="text" width="300">
|
||||
<content>
|
||||
<p>
|
||||
<strong>
|
||||
<span color="rgb(255, 215, 0)" fontFamily="黑体" fontSize="28">时代背景</span>
|
||||
</strong>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape height="200" rotation="0" topLeftX="48" topLeftY="85" type="rect" width="864">
|
||||
<fill>
|
||||
<fillColor color="rgba(0,0,0,0.2)"/>
|
||||
</fill>
|
||||
</shape>
|
||||
<img alpha="0.4" alt="十月革命场景" height="180" rotation="0" src="https://example.com/images/scene-1.png" topLeftX="48" topLeftY="95" width="288">
|
||||
<crop type="rect"/>
|
||||
</img>
|
||||
<img alpha="0.4" alt="列宁演讲油画" height="180" rotation="0" src="https://example.com/images/scene-2.png" topLeftX="336" topLeftY="95" width="288">
|
||||
<crop type="rect"/>
|
||||
</img>
|
||||
<img alpha="0.4" alt="十月革命战斗场面" height="180" rotation="0" src="https://example.com/images/scene-3.png" topLeftX="624" topLeftY="95" width="288">
|
||||
<crop type="rect"/>
|
||||
</img>
|
||||
<shape height="2" rotation="0" topLeftX="90" topLeftY="180" type="rect" width="780">
|
||||
<fill>
|
||||
<fillColor color="rgba(255, 215, 0, 0.3)"/>
|
||||
</fill>
|
||||
</shape>
|
||||
<shape height="10" rotation="0" topLeftX="120" topLeftY="176" type="ellipse" width="10">
|
||||
<fill>
|
||||
<fillColor color="rgb(196, 30, 58)"/>
|
||||
</fill>
|
||||
<border color="rgb(255, 215, 0)" width="1"/>
|
||||
</shape>
|
||||
<shape height="24" rotation="0" topLeftX="60" topLeftY="140" type="text" width="200">
|
||||
<content>
|
||||
<p textAlign="center">
|
||||
<strong>
|
||||
<span color="rgb(255, 215, 0)" fontFamily="黑体" fontSize="20">1917</span>
|
||||
</strong>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape height="22" rotation="0" topLeftX="60" topLeftY="200" type="text" width="200">
|
||||
<content>
|
||||
<p textAlign="center">
|
||||
<span color="rgb(230, 230, 230)" fontFamily="宋体" fontSize="16">十月革命</span>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape height="40" rotation="0" topLeftX="60" topLeftY="225" type="text" width="200">
|
||||
<content verticalAlign="top">
|
||||
<p textAlign="center">
|
||||
<span color="rgb(156, 163, 175)" fontSize="12">沙皇专制终结,苏维埃政权建立</span>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape height="10" rotation="0" topLeftX="475" topLeftY="176" type="ellipse" width="10">
|
||||
<fill>
|
||||
<fillColor color="rgb(196, 30, 58)"/>
|
||||
</fill>
|
||||
<border color="rgb(255, 215, 0)" width="1"/>
|
||||
</shape>
|
||||
<shape height="24" rotation="0" topLeftX="380" topLeftY="140" type="text" width="200">
|
||||
<content>
|
||||
<p textAlign="center">
|
||||
<strong>
|
||||
<span color="rgb(255, 215, 0)" fontFamily="黑体" fontSize="20">1920s</span>
|
||||
</strong>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape height="22" rotation="0" topLeftX="380" topLeftY="200" type="text" width="200">
|
||||
<content>
|
||||
<p textAlign="center">
|
||||
<span color="rgb(230, 230, 230)" fontFamily="宋体" fontSize="16">国内战争</span>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape height="40" rotation="0" topLeftX="380" topLeftY="225" type="text" width="200">
|
||||
<content verticalAlign="top">
|
||||
<p textAlign="center">
|
||||
<span color="rgb(156, 163, 175)" fontSize="12">革命与反革命的残酷斗争</span>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape height="10" rotation="0" topLeftX="830" topLeftY="176" type="ellipse" width="10">
|
||||
<fill>
|
||||
<fillColor color="rgb(196, 30, 58)"/>
|
||||
</fill>
|
||||
<border color="rgb(255, 215, 0)" width="1"/>
|
||||
</shape>
|
||||
<shape height="24" rotation="0" topLeftX="740" topLeftY="140" type="text" width="200">
|
||||
<content>
|
||||
<p textAlign="center">
|
||||
<strong>
|
||||
<span color="rgb(255, 215, 0)" fontFamily="黑体" fontSize="20">1930s</span>
|
||||
</strong>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape height="22" rotation="0" topLeftX="740" topLeftY="200" type="text" width="200">
|
||||
<content>
|
||||
<p textAlign="center">
|
||||
<span color="rgb(230, 230, 230)" fontFamily="宋体" fontSize="16">社会主义建设</span>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape height="40" rotation="0" topLeftX="740" topLeftY="225" type="text" width="200">
|
||||
<content verticalAlign="top">
|
||||
<p textAlign="center">
|
||||
<span color="rgb(156, 163, 175)" fontSize="12">新经济政策与工业化探索</span>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape height="1" rotation="0" topLeftX="48" topLeftY="300" type="rect" width="864">
|
||||
<fill>
|
||||
<fillColor color="rgba(255, 215, 0, 0.2)"/>
|
||||
</fill>
|
||||
</shape>
|
||||
<shape height="24" rotation="0" topLeftX="48" topLeftY="320" type="text" width="280">
|
||||
<content>
|
||||
<p>
|
||||
<span color="rgb(230, 230, 230)" fontFamily="宋体" fontSize="18">作者:奥斯特洛夫斯基</span>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<icon height="16" iconType="iconpark/Peoples/user.svg" topLeftX="52" topLeftY="360" width="16">
|
||||
<fill>
|
||||
<fillColor color="rgb(196, 30, 58)"/>
|
||||
</fill>
|
||||
</icon>
|
||||
<shape height="20" topLeftX="76" topLeftY="358" type="text" width="260">
|
||||
<content verticalAlign="middle">
|
||||
<p>
|
||||
<span color="rgb(209, 213, 219)" fontSize="13">工人家庭出身,投身革命浪潮</span>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<icon height="16" iconType="iconpark/Sports/torch.svg" topLeftX="52" topLeftY="390" width="16">
|
||||
<fill>
|
||||
<fillColor color="rgb(196, 30, 58)"/>
|
||||
</fill>
|
||||
</icon>
|
||||
<shape height="20" topLeftX="76" topLeftY="388" type="text" width="260">
|
||||
<content verticalAlign="middle">
|
||||
<p>
|
||||
<span color="rgb(209, 213, 219)" fontSize="13">战场负伤致残,生命陷入黑暗</span>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<icon height="16" iconType="iconpark/Health/first-aid-kit.svg" topLeftX="52" topLeftY="420" width="16">
|
||||
<fill>
|
||||
<fillColor color="rgb(196, 30, 58)"/>
|
||||
</fill>
|
||||
</icon>
|
||||
<shape height="20" topLeftX="76" topLeftY="418" type="text" width="260">
|
||||
<content verticalAlign="middle">
|
||||
<p>
|
||||
<span color="rgb(209, 213, 219)" fontSize="13">全身瘫痪、双目失明</span>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<icon height="16" iconType="iconpark/Edit/edit.svg" topLeftX="52" topLeftY="450" width="16">
|
||||
<fill>
|
||||
<fillColor color="rgb(196, 30, 58)"/>
|
||||
</fill>
|
||||
</icon>
|
||||
<shape height="20" topLeftX="76" topLeftY="448" type="text" width="260">
|
||||
<content verticalAlign="middle">
|
||||
<p>
|
||||
<span color="rgb(209, 213, 219)" fontSize="13">以文学为武器,口述完成创作</span>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<img alt="奥斯特洛夫斯基青年时期" height="213" rotation="0" src="https://example.com/images/ostrovsky.png" topLeftX="360" topLeftY="320" width="160">
|
||||
<border color="rgba(255, 215, 0, 0.5)" width="2"/>
|
||||
<crop type="rect"/>
|
||||
</img>
|
||||
<shape height="24" rotation="0" topLeftX="552" topLeftY="320" type="text" width="360">
|
||||
<content>
|
||||
<p>
|
||||
<span color="rgb(230, 230, 230)" fontFamily="宋体" fontSize="18">创作动机</span>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape height="16" rotation="0" topLeftX="552" topLeftY="350" type="rect" width="2">
|
||||
<fill>
|
||||
<fillColor color="rgb(196, 30, 58)"/>
|
||||
</fill>
|
||||
</shape>
|
||||
<shape height="120" rotation="0" topLeftX="562" topLeftY="350" type="text" width="350">
|
||||
<content lineSpacing="multiple:1.6" verticalAlign="top">
|
||||
<p>
|
||||
<span color="rgb(209, 213, 219)" fontSize="13">在双目失明、全身瘫痪的逆境中,奥斯特洛夫斯基以自身经历为蓝本,用顽强的意志口述完成了这部不朽巨著。他将文学创作视为生命的延续和战斗的武器,旨在通过保尔·柯察金的形象,向青年一代传递坚不可摧的革命信念和超越个人痛苦的崇高人生价值观。</span>
|
||||
</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
<note>
|
||||
<content>
|
||||
<p>各位好,这一页将我们带回《钢铁是怎样炼成的》这部巨著诞生的波澜壮阔的时代。</p>
|
||||
<p>上半部分展示了从1917年十月革命到1930年代苏联社会主义建设的宏大历史画卷。这是一个充满剧烈社会变革和残酷斗争的年代,也是英雄主义和理想主义精神熊熊燃烧的年代。正是这样的背景,孕育了小说的灵魂。</p>
|
||||
<p>下半部分,我们聚焦于作者奥斯特洛夫斯基的个人经历。他的一生,本身就是一部比小说更震撼人心的传奇。从投身革命的青年,到因伤致残的战士,再到与命运抗争的文学巨匠。他的创作动机源于自身不屈的战斗精神,他希望用保尔的故事激励后人,在任何困境中都不要放弃理想,要将有限的生命投入到无限的为人类解放而斗争的事业中去。</p>
|
||||
<p>通过了解这段历史和作者的生平,我们能更深刻地理解《钢铁是怎样炼成的》这部作品的伟大之处。</p>
|
||||
</content>
|
||||
</note>
|
||||
</slide>
|
||||
</presentation>
|
||||
@@ -3018,7 +3018,7 @@
|
||||
|
||||
子元素(mermaid 与 svg 二选一):
|
||||
- mermaid: Mermaid 源码文本, 可使用 CDATA 包裹
|
||||
适用场景: 流程图、时序图、思维导图、类图、甘特图、ER 图、用户旅程等结构图
|
||||
适用场景: 流程图、时序图、思维导图、类图、甘特图、饼图等结构化图表
|
||||
特点: 用简短的文本声明描述图表逻辑, 由渲染引擎自动布局, 无需手动计算坐标
|
||||
示例: <mermaid><![CDATA[flowchart TD\n A[开始] --> B[结束]]]></mermaid>
|
||||
- svg: SVG 内容
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
遇到 `invalid param`、某一页创建失败、页面空白或布局错乱时,按顺序处理:
|
||||
|
||||
1. 记录 `xml_presentation_id`,不要假设失败代表什么都没创建。
|
||||
2. 用 `slides +xml-get` 回读,确认是否已有部分页面写入。
|
||||
2. 用 `xml_presentations.get` 回读,确认是否已有部分页面写入。
|
||||
3. 检查失败页是否含未转义字符:`Q&A -> Q&A`,文本 `<` / `>` 写成 `<` / `>`,属性 URL `a=1&b=2 -> a=1&b=2`。
|
||||
4. 检查标签闭合、属性引号、`<content>` 结构,以及 `<slide>` 直接子元素。
|
||||
5. 页面空白、溢出、重叠或越界时,按 [validation-checklist.md](validation-checklist.md) 运行 XML 文本重叠检查,并人工核对越界、截断、图文压盖等视觉风险;工具当前只会报告 `xml_not_well_formed` / `bbox_overlap`。
|
||||
@@ -46,14 +46,14 @@
|
||||
| 400 XML 格式错误 | XML 语法错误 | 检查标签闭合、属性引号、特殊字符转义 |
|
||||
| 400 请求包装错误 | `--data` 未按 schema 包装 | 检查是否传入 `xml_presentation.content` 或 `slide.content` |
|
||||
| 创建成功但页面空白 / 内容缺失 / 布局错乱 | 常见于 `--slides '[...]'` 的 shell 转义或长参数传递问题 | 改用两步创建,并在创建后立即读取 XML 验证 |
|
||||
| 403 权限不足 | scope 或文档权限不匹配 | 确认 scope 和文档权限;无权限时根据错误响应引导用户解决 |
|
||||
| 403 权限不足 | 身份或 scope 不匹配 | 先检查是否误用了 bot 身份,再确认 scope 和文档权限 |
|
||||
| 404 演示文稿不存在 | `xml_presentation_id` 不正确或无权限 | 检查 token;wiki URL 需先解析真实 `obj_token` |
|
||||
| 404 幻灯片不存在 | `slide_id` 不正确 | 重新读取 presentation 或 slide,确认最新 ID |
|
||||
| 400 无法删除唯一幻灯片 | 演示文稿至少保留一页 | 先创建新页,再删除旧页 |
|
||||
| 1061002 媒体上传 params error | slides 媒体上传参数不符合约定 | 用 `slides +media-upload`,不要手拼原生 `medias/upload_all`;slides 唯一可用 `parent_type` 是 `slide_file` |
|
||||
| 1061004 forbidden | 当前用户对演示文稿无编辑权限 | 确认当前用户对目标 PPT 有编辑权限 |
|
||||
| 1061004 forbidden | 当前身份对演示文稿无编辑权限 | 确认 user/bot 对目标 PPT 有编辑权限;bot 常见于 PPT 非该 bot 创建 |
|
||||
| 3350001 | XML 非 well-formed、XML 结构不符合服务端要求,或 replace 片段问题 | 优先检查未转义字符;replace 场景再看 `block_id` 和 `<content/>` |
|
||||
| 3350002 | `revision_id` 大于当前版本 | 用 `-1` 取当前版本,或重新用 `slides +xml-get` 取最新 `revision_id` |
|
||||
| 3350002 | `revision_id` 大于当前版本 | 用 `-1` 取当前版本,或重新读 `xml_presentations.get` 取最新 `revision_id` |
|
||||
| validation: unsafe file path | `--file` 给了绝对路径或上层路径 | `--file` 必须是 CWD 内相对路径;先 `cd` 到素材目录再执行 |
|
||||
|
||||
## Command-Specific References
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
## Required Flow
|
||||
|
||||
1. 记录创建或编辑返回的 `xml_presentation_id`,以及已知的 `slide_id` / `revision_id`。
|
||||
2. 用 `slides +xml-get` 回读全文 XML 到本地文件。
|
||||
2. 用 `xml_presentations.get` 回读全文 XML。
|
||||
3. 检查实际页数是否符合计划或用户要求。
|
||||
4. 检查每页 `<data>` 内是否有预期主要元素。
|
||||
5. 检查没有明显空白页、破损页、缺失标题或缺失主视觉。
|
||||
@@ -19,15 +19,13 @@
|
||||
回读命令:
|
||||
|
||||
```bash
|
||||
lark-cli slides +xml-get --as user \
|
||||
--presentation "YOUR_ID" \
|
||||
--output .lark-slides/plan/<deck-or-task-id>/readback.xml \
|
||||
--json
|
||||
lark-cli slides xml_presentations get --as user \
|
||||
--params '{"xml_presentation_id":"YOUR_ID"}'
|
||||
```
|
||||
|
||||
## Automated XML Text Overlap Lint
|
||||
|
||||
`slides +xml-get` 保存 XML 到本地文件后,优先运行 XML 语法和文本重叠静态检查:
|
||||
回读 XML 保存到本地文件后,优先运行 XML 语法和文本重叠静态检查:
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentation.xml>
|
||||
@@ -103,7 +101,7 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
|
||||
|
||||
```text
|
||||
验证记录:
|
||||
- 回读:已执行 slides +xml-get,实际页数 N / 预期 N。
|
||||
- 回读:已执行 xml_presentations.get,实际页数 N / 预期 N。
|
||||
- 关键页:架构解释 / Self-Attention / 对比或演进 / 总结页均存在。
|
||||
- 结构:检查了主要 shape/img/table/chart 元素,无明显空白页或破损页。
|
||||
- 布局:检查了标题层级、主视觉、重叠/越界/文本溢出风险。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
新建演示文稿或大幅改写页面时,在 `slide_plan.json` 完成后、生成 XML 前读取本文件。目标是让 `layout_type`、`visual_focus`、`text_density` 变成实际页面几何,而不是只写在 plan 里。
|
||||
|
||||
默认画布按 `960 x 540` 规划。模板 XML 可以覆盖具体坐标,但不能覆盖这些原则:页面要有主视觉区域、文本要受密度约束、不同 `layout_type` 必须产生明显不同的坐标结构。
|
||||
默认画布按 `960 x 540` 规划。已有页面回读 XML 可以影响具体坐标,但不能覆盖这些原则:页面要有主视觉区域、文本要受密度约束、不同 `layout_type` 必须产生明显不同的坐标结构。
|
||||
|
||||
## Core Rules
|
||||
|
||||
@@ -131,7 +131,6 @@ Purpose: make one metric or fact memorable.
|
||||
|
||||
Geometry:
|
||||
- Reserve the largest object for the metric: font size often `64-110`, region at least `300 x 120`.
|
||||
- Set `autoFit="normal-auto-fit"` on the metric's `<content>` so an oversized number shrinks to fit its box instead of overflowing.
|
||||
- Pair the number with one explanation and optional 2-3 small supporting labels.
|
||||
- Do not bury the number in a bullet list or small card.
|
||||
|
||||
@@ -169,7 +168,7 @@ Text:
|
||||
|
||||
Purpose: explain components, dependencies, or system flow.
|
||||
|
||||
Implementation: prefer Mermaid `<whiteboard>` (see `lark-slides-whiteboard.md`); use `<shape>` + `<line>` as fallback.
|
||||
Implementation: prefer `<whiteboard>` (see `lark-slides-whiteboard.md`); use `<shape>` + `<line>` only as fallback.
|
||||
|
||||
Geometry:
|
||||
- Main visual area should be a diagram, not prose.
|
||||
@@ -185,7 +184,7 @@ Text:
|
||||
|
||||
Purpose: show operational steps, workflow, or cause-effect path.
|
||||
|
||||
Implementation: prefer Mermaid `<whiteboard>` (see `lark-slides-whiteboard.md`); use `<shape>` + `<line>` as fallback.
|
||||
Implementation: prefer `<whiteboard>` (see `lark-slides-whiteboard.md`); use `<shape>` + `<line>` only as fallback.
|
||||
|
||||
Geometry:
|
||||
- Use numbered steps connected by arrows or lines.
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
# 特殊视觉风格
|
||||
|
||||
当用户明确点名以下某种风格时,本文对应小节的规格**优先于** `visual-planning.md` 的默认审美设定;仍需遵守画布 960×540、留白、文字不溢出等硬约束。颜色一律用 `rgba(R,G,B,A)`,深浅背景都友好。
|
||||
|
||||
适用触发:用户说“咨询风 / 麦肯锡风 / BCG 风 / 战略咨询风 / 数据报告风”,或“豆包风 / doubao 风 / 豆包 PPT 那种”,或直接贴出属于这两类风格的参考图/链接。
|
||||
|
||||
---
|
||||
|
||||
## 咨询风(McKinsey / BCG 战略咨询)
|
||||
|
||||
要求:一张专业、高密度的咨询演示幻灯片,设计风格需融合顶尖战略咨询公司(麦肯锡/BCG)的风格与高端出版物的美学。
|
||||
|
||||
核心内容与布局:
|
||||
- 丰富的数据可视化:幻灯片需填充复杂、精确的图表(堆叠柱状图、瀑布图或折线图)以及包含行和列的详细数据表。
|
||||
- 结构化框架:包含战略图表或由细致、简洁线条构成的 2x2 矩阵。
|
||||
- 高信息密度:布局精致且采用多栏设计,模仿真实的商业分析文稿,而非仅仅是一个空白的封面页。
|
||||
|
||||
视觉风格:
|
||||
- 美学:科技极简主义,但信息承载量大。风格干净、锐利且具权威感。
|
||||
- 字体:主标题使用衬线体(如 Times New Roman),以营造高级财务报告的质感;图表标签和数据数字使用简洁的无衬线体。
|
||||
- 配色:纯白背景。文字为清晰的黑色。图表和图形装饰使用深皇家蓝(Deep Royal Blue)和不同深浅的灰色来体现数据层级。
|
||||
- 图形:表格使用极细的边框线,图表使用精确的矢量线条。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 豆包风(豆包数据卡片)
|
||||
|
||||
要求:一张信息密度巨高的图文卡片化布局幻灯片,追求充实饱满、图文丰富、可逐行细读的版面。
|
||||
|
||||
核心内容与布局:
|
||||
- **信息密度巨高(至关重要):除封面和分节页外,每个内容页都塞满多张图文卡片,宁可密而满,不要空而疏,不要留白。**
|
||||
- 卡片化布局:方角卡片按多行网格铺满版面(网格数、图文比例按内容变化,避免每页雷同);每个卡片不是短标签,而是“标题 + 完整说明”,像浓缩的分析文稿。
|
||||
- 卡片版式:卡片用窄条点缀(贴任意一边或不加),采用“锚点(语义图标或编号)+ 标题 + 细分隔线(可选)+ 正文”的结构。
|
||||
- 文字卡片:内容页正文量级必须达到 400-600 中文字,分布在 4-12 个卡片,每个文字卡片都塞满文字内容,宁可密而满,不要空而疏。
|
||||
- 图片卡片:内容页必须包含图片卡片,多数内容页目标 1-3 张,图片卡片与文字卡片组成网格,缺少图片卡片时必须用生图工具生成配图补足。
|
||||
- 图标密集镶嵌:文字卡片、列表项和指标旁大量内嵌语义图标(部分放在圆形 / 方形底里)作为视觉锚点,让高密度文字也有图形节奏,而不是成片纯文字块。
|
||||
- 数据驱动:数据页密集使用原生图表(饼图、环形图、柱状、折线、组合),图表常与卡片网格并排成半栏布局(如左图右卡或上表下卡)。
|
||||
- 关键指标突出:KPI 用比正文稍大的数字呈现,不要过大,下方配小字标签与简短解读。
|
||||
- 防文本溢出:所有承载突出信息和密集文字的 `<content>` 必须设置 `autoFit="normal-auto-fit"`,字号会在框内自动缩排以防溢出。
|
||||
|
||||
视觉风格:
|
||||
- 美学:干净、明亮、清爽但信息饱满;纯平无渐变,靠卡片、分隔线和对齐网格在高密度下维持秩序感。
|
||||
- 字体:全篇以无衬线体(思源黑体)为主,封面或关键强调可少量使用衬线体。
|
||||
- 字号:使用偏小的正文字号以容纳更多文字,提高信息密度。
|
||||
- 配色:控制配色数量,避免色彩泛滥导致画面杂乱,1 个主色,1-2 个辅助色,1 个强调色。
|
||||
369
skills/lark-slides/references/xml-format-guide.md
Normal file
369
skills/lark-slides/references/xml-format-guide.md
Normal file
@@ -0,0 +1,369 @@
|
||||
# XML 格式指南
|
||||
|
||||
本文档基于 [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml) 整理,说明飞书 Slides XML Schema(SML 2.0)的核心结构和常用写法。
|
||||
|
||||
## 基本结构
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<title>演示文稿标题</title>
|
||||
<slide>
|
||||
<style>
|
||||
<fill>
|
||||
<fillColor color="rgb(245, 245, 245)"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<shape type="text" topLeftX="80" topLeftY="80" width="800" height="120">
|
||||
<content textType="title">
|
||||
<p>主标题</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
<note>
|
||||
<content textType="body">
|
||||
<p>这是演讲者备注。</p>
|
||||
</content>
|
||||
</note>
|
||||
</slide>
|
||||
</presentation>
|
||||
```
|
||||
|
||||
## 根元素
|
||||
|
||||
### `<presentation>`
|
||||
|
||||
协议标准写法应带命名空间 `http://www.larkoffice.com/sml/2.0`;当前服务端实现可能兼容不带 `xmlns` 的输入,但不作为协议保证。
|
||||
|
||||
**属性:**
|
||||
|
||||
| 属性 | 类型 | 必需 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `width` | positiveInteger | 是 | 演示文稿宽度,如 `960` |
|
||||
| `height` | positiveInteger | 是 | 演示文稿高度,如 `540` |
|
||||
| `id` | string | 否 | 演示文稿标识 |
|
||||
|
||||
**子元素:**
|
||||
|
||||
| 元素 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `<title>` | 否 | 演示文稿标题 |
|
||||
| `<theme>` | 否 | 全局主题 |
|
||||
| `<slide>` | 是 | 幻灯片页面,至少 1 页,最多 100 页 |
|
||||
|
||||
## 主题
|
||||
|
||||
### `<theme>`
|
||||
|
||||
`<theme>` 当前包含两部分:
|
||||
|
||||
- `<background>`:演示文稿级背景填充
|
||||
- `<textStyles>`:主题文本样式集合
|
||||
|
||||
`<textStyles>` 下可选子元素:
|
||||
|
||||
- `<title>`
|
||||
- `<headline>`
|
||||
- `<sub-headline>`
|
||||
- `<body>`
|
||||
- `<caption>`
|
||||
|
||||
这些元素定义的是主题默认样式,不是页面结构。常用属性:
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `fontFamily` | 字体 |
|
||||
| `fontSize` | 字号 |
|
||||
| `fontColor` | 字体颜色 |
|
||||
|
||||
## 幻灯片元素
|
||||
|
||||
### `<slide>`
|
||||
|
||||
单张幻灯片的结构比较严格。
|
||||
|
||||
**属性:**
|
||||
|
||||
| 属性 | 类型 | 必需 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `id` | string | 否 | 幻灯片标识 |
|
||||
|
||||
**直接子元素只有:**
|
||||
|
||||
| 元素 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `<style>` | 否 | 页面样式 |
|
||||
| `<data>` | 否 | 页面元素容器 |
|
||||
| `<note>` | 否 | 演讲者备注 |
|
||||
|
||||
这意味着 `<title>`、`<headline>`、`<body>`、`<caption>` 不能直接放在 `<slide>` 下。
|
||||
|
||||
## 文本内容模型
|
||||
|
||||
### `<content>`
|
||||
|
||||
实际页面文本通常通过 `<content>` 表达,常见位置有:
|
||||
|
||||
- `shape` 内部
|
||||
- `table/td` 内部
|
||||
- `note` 内部
|
||||
|
||||
**常用属性:**
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `textType` | `title` / `headline` / `sub-headline` / `body` / `caption` |
|
||||
| `verticalAlign` | 垂直对齐 |
|
||||
| `textAlign` | 水平对齐 |
|
||||
| `lineSpacing` | 行间距 |
|
||||
| `fontSize` | 字号 |
|
||||
| `fontFamily` | 字体 |
|
||||
| `color` | 字体颜色 |
|
||||
| `bold` / `italic` / `underline` / `strikethrough` | 内容级样式 |
|
||||
| `wrap` | 是否自动换行 |
|
||||
|
||||
**可包含的子元素:**
|
||||
|
||||
- `<p>`
|
||||
- `<ul>`
|
||||
- `<ol>`
|
||||
|
||||
### `<p>`
|
||||
|
||||
`<p>` 是段落元素,可混排纯文本和内联标签:
|
||||
|
||||
- `<br/>`
|
||||
- `<strong>`
|
||||
- `<em>`
|
||||
- `<u>`
|
||||
- `<span>`
|
||||
- `<del>`
|
||||
- `<a>`
|
||||
- `<shadow>`
|
||||
- `<outline>`
|
||||
|
||||
示例:
|
||||
|
||||
```xml
|
||||
<content textType="body" textAlign="left">
|
||||
<p>普通文本 <strong>加粗</strong> <em>斜体</em> <a href="https://example.com">链接</a></p>
|
||||
<ul>
|
||||
<li><p>列表项 1</p></li>
|
||||
<li><p>列表项 2</p></li>
|
||||
</ul>
|
||||
</content>
|
||||
```
|
||||
|
||||
## 常用页面元素
|
||||
|
||||
所有页面元素都放在 `<data>` 中。
|
||||
|
||||
### `<shape>`
|
||||
|
||||
`shape` 可表示普通形状,也可表示文本框。文本框推荐使用 `type="text"`。
|
||||
|
||||
```xml
|
||||
<shape type="text" topLeftX="80" topLeftY="80" width="800" height="120">
|
||||
<content textType="title">
|
||||
<p>主标题</p>
|
||||
</content>
|
||||
</shape>
|
||||
```
|
||||
|
||||
```xml
|
||||
<shape type="rect" topLeftX="700" topLeftY="120" width="180" height="120">
|
||||
<fill>
|
||||
<fillColor color="rgba(100, 149, 237, 0.25)"/>
|
||||
</fill>
|
||||
<border color="rgb(100, 149, 237)" width="2"/>
|
||||
</shape>
|
||||
```
|
||||
|
||||
**属性:**
|
||||
|
||||
| 属性 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `type` | 是 | 形状类型,`text` 表示文本框 |
|
||||
| `topLeftX` | 是 | 左上角 X 坐标 |
|
||||
| `topLeftY` | 是 | 左上角 Y 坐标 |
|
||||
| `width` | 是 | 宽度 |
|
||||
| `height` | 是 | 高度 |
|
||||
| `rotation` | 否 | 旋转角度 |
|
||||
| `flipX` / `flipY` | 否 | 翻转 |
|
||||
| `alpha` | 否 | 透明度 |
|
||||
|
||||
**可选子元素:**
|
||||
|
||||
- `<fill>`
|
||||
- `<border>`
|
||||
- `<reflection>`
|
||||
- `<shadow>`
|
||||
- `<content>`
|
||||
|
||||
### `<line>`
|
||||
|
||||
```xml
|
||||
<line startX="100" startY="200" endX="420" endY="200">
|
||||
<border color="rgb(43, 47, 54)" width="2"/>
|
||||
</line>
|
||||
```
|
||||
|
||||
`line` 使用的是 `startX` / `startY` / `endX` / `endY`,不是 `x1` / `y1` / `x2` / `y2`。
|
||||
|
||||
### `<img>`
|
||||
|
||||
```xml
|
||||
<img src="file_token_or_url" topLeftX="100" topLeftY="220" width="320" height="180"/>
|
||||
```
|
||||
|
||||
`img` 使用 `topLeftX` / `topLeftY`,不是 `x` / `y`。
|
||||
|
||||
`src` 只接受两种值:
|
||||
|
||||
| `src` 形式 | 说明 |
|
||||
|---|---|
|
||||
| `file_token`(如 `boxcnXXXXXXXXXXXXXXXXXXXXXX`) | 通过 `slides +media-upload` 上传后返回的 token |
|
||||
| `@<本地路径>`(如 `@./assets/chart.png`) | **仅在 `slides +create --slides` 中可用**:CLI 会自动上传该文件并替换为 file_token |
|
||||
|
||||
> **禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,`src="https://..."` 在 PPT 里通常显示破图。要用网图必须先 `curl`/下载到 CWD 内,再走上传流程拿 `file_token`。
|
||||
|
||||
本地图片的两种姿势:
|
||||
|
||||
- **新建带图 PPT**:`+create --slides` 里直接写 `src="@./pic.png"`,CLI 在创空白 PPT 后、加 slides 前自动上传并替换 token
|
||||
- **给已有 PPT 加带图新页**:先 `slides +media-upload --file ./pic.png --presentation $PID` 拿 token,再用 token 写进 `xml_presentation.slide create` 的 XML
|
||||
|
||||
### `<icon>`
|
||||
|
||||
```xml
|
||||
<icon iconType="iconpark/Base/setting.svg" topLeftX="440" topLeftY="220" width="32" height="32"/>
|
||||
```
|
||||
|
||||
### `<table>`
|
||||
|
||||
表格结构为:
|
||||
|
||||
- `<table>`
|
||||
- `<colgroup>` / `<tr>`
|
||||
- `<tr>` 内为 `<td>`
|
||||
- `<td>` 内可放 `<content>`
|
||||
|
||||
### `<chart>`
|
||||
|
||||
图表元素必须至少包含:
|
||||
|
||||
- `<chartPlotArea>`
|
||||
- `<chartData>`
|
||||
|
||||
同时还可以包含:
|
||||
|
||||
- `<chartTitle>`
|
||||
- `<chartSubTitle>`
|
||||
- `<chartStyle>`
|
||||
- `<chartLegend>`
|
||||
- `<chartTooltip>`
|
||||
|
||||
如果要写图表 XML,建议直接以 XSD 为准,不要自行发明更简化的 chart DSL。
|
||||
|
||||
## 样式元素
|
||||
|
||||
### `<fill>`
|
||||
|
||||
```xml
|
||||
<fill>
|
||||
<fillColor color="rgb(100, 149, 237)"/>
|
||||
</fill>
|
||||
```
|
||||
|
||||
### `<border>`
|
||||
|
||||
```xml
|
||||
<border color="rgb(0, 0, 0)" width="2" dashArray="solid"/>
|
||||
```
|
||||
|
||||
### 颜色格式
|
||||
|
||||
```xml
|
||||
<fillColor color="rgb(255, 0, 0)"/>
|
||||
<fillColor color="rgba(255, 0, 0, 0.5)"/>
|
||||
<fillColor color="linear-gradient(90deg, rgb(255,0,0) 0%, rgb(0,0,255) 100%)"/>
|
||||
<fillColor color="radial-gradient(circle at 50% 50%, rgb(255,0,0) 0%, rgb(0,0,255) 100%)"/>
|
||||
```
|
||||
|
||||
## 演讲者备注
|
||||
|
||||
### `<note>`
|
||||
|
||||
```xml
|
||||
<note>
|
||||
<content textType="body">
|
||||
<p>这是演讲者备注内容。</p>
|
||||
</content>
|
||||
</note>
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<title>季度报告</title>
|
||||
<theme>
|
||||
<textStyles>
|
||||
<title fontFamily="思源黑体" fontSize="54" fontColor="rgba(0, 0, 0, 1)"/>
|
||||
<body fontFamily="思源黑体" fontSize="18" fontColor="rgba(43, 47, 54, 1)"/>
|
||||
</textStyles>
|
||||
</theme>
|
||||
<slide>
|
||||
<style>
|
||||
<fill>
|
||||
<fillColor color="rgb(245, 245, 245)"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<shape type="text" topLeftX="80" topLeftY="72" width="760" height="100">
|
||||
<content textType="title">
|
||||
<p>2024 年第一季度报告</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape type="text" topLeftX="80" topLeftY="200" width="520" height="180">
|
||||
<content textType="body">
|
||||
<p>核心指标</p>
|
||||
<ul>
|
||||
<li><p>用户增长:+25%</p></li>
|
||||
<li><p>收入增长:+30%</p></li>
|
||||
<li><p>市场份额:15%</p></li>
|
||||
</ul>
|
||||
</content>
|
||||
</shape>
|
||||
<shape type="rect" topLeftX="660" topLeftY="180" width="180" height="140">
|
||||
<fill>
|
||||
<fillColor color="rgba(100, 149, 237, 0.25)"/>
|
||||
</fill>
|
||||
<border color="rgb(100, 149, 237)" width="2"/>
|
||||
</shape>
|
||||
</data>
|
||||
<note>
|
||||
<content textType="body">
|
||||
<p>讲到增长率时补充样本范围。</p>
|
||||
</content>
|
||||
</note>
|
||||
</slide>
|
||||
</presentation>
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. 始终带上命名空间 `xmlns="http://www.larkoffice.com/sml/2.0"`
|
||||
2. 用 `shape type="text"` + `content` 表达页面文本
|
||||
3. 用 `topLeftX` / `topLeftY`、`startX` / `startY` 等 schema 中定义的属性名
|
||||
4. 优先使用 `rgb` / `rgba` 颜色格式
|
||||
5. 特殊字符按 XML 规则转义
|
||||
6. 标准 16:9 页面建议使用 `width="960"` 和 `height="540"`
|
||||
|
||||
## 参考文档
|
||||
|
||||
- [xml-schema-quick-ref.md](xml-schema-quick-ref.md)
|
||||
- [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml)
|
||||
- [examples.md](examples.md)
|
||||
- [slides_demo.xml](slides_demo.xml)
|
||||
@@ -1,6 +1,6 @@
|
||||
# XML Schema 快速参考
|
||||
|
||||
本文档是 [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml) 的精简版摘要,并合并了常用 XML 格式写法;如果两者不一致,以 XSD 原文为准。
|
||||
本文档是 [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml) 的精简版摘要;如果两者不一致,以 XSD 原文为准。
|
||||
|
||||
## 最重要的规则
|
||||
|
||||
@@ -30,31 +30,25 @@
|
||||
|
||||
| 属性 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `width` | 是 | 演示文稿宽度,正整数,标准 16:9 页面建议使用 `960` |
|
||||
| `height` | 是 | 演示文稿高度,正整数,标准 16:9 页面建议使用 `540` |
|
||||
| `width` | 是 | 演示文稿宽度,正整数 |
|
||||
| `height` | 是 | 演示文稿高度,正整数 |
|
||||
| `id` | 否 | 演示文稿标识 |
|
||||
|
||||
**子元素:** `<title>?`, `<theme>?`, `<slide>+`
|
||||
|
||||
`<slide>` 至少 1 页,最多 100 页。
|
||||
## slide 元素
|
||||
|
||||
| 属性 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | 否 | 幻灯片标识 |
|
||||
|
||||
**子元素:**
|
||||
- `<style>?` - 页面样式,目前可放 `<fill>`
|
||||
- `<data>?` - 页面元素容器,可放 `shape`、`line`、`polyline`、`img`、`table`、`icon`、`chart`、`whiteboard`、`undefined`
|
||||
- `<note>?` - 演讲者备注,内部可放 `<content>`
|
||||
|
||||
## theme 与文本类型
|
||||
|
||||
`<theme>` 当前包含两部分:
|
||||
|
||||
- `<background>`:演示文稿级背景填充
|
||||
- `<textStyles>`:主题文本样式集合
|
||||
|
||||
`<textStyles>` 下可选子元素包括 `<title>`、`<headline>`、`<sub-headline>`、`<body>`、`<caption>`。这些元素定义的是主题默认样式,不是页面结构。
|
||||
|
||||
常用属性:
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `fontFamily` | 字体 |
|
||||
| `fontSize` | 字号 |
|
||||
| `fontColor` | 字体颜色 |
|
||||
|
||||
XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出现在:
|
||||
|
||||
- `<theme><textStyles>...</textStyles></theme>` 中,作为主题文本样式
|
||||
@@ -70,20 +64,6 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
|
||||
| `body` | 16 |
|
||||
| `caption` | 12 |
|
||||
|
||||
## slide 元素
|
||||
|
||||
| 属性 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | 否 | 幻灯片标识 |
|
||||
|
||||
**子元素:**
|
||||
|
||||
- `<style>?` - 页面样式,目前可放 `<fill>`
|
||||
- `<data>?` - 页面元素容器,可放 `shape`、`line`、`polyline`、`img`、`table`、`icon`、`chart`、`whiteboard`、`undefined`
|
||||
- `<note>?` - 演讲者备注,内部可放 `<content>`
|
||||
|
||||
这意味着 `<title>`、`<headline>`、`<body>`、`<caption>` 不能直接放在 `<slide>` 下。
|
||||
|
||||
## content 内容模型
|
||||
|
||||
`<content>` 可出现在 `shape`、`table/td`、`note` 中,常用属性包括:
|
||||
@@ -91,14 +71,12 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `textType` | `title` / `headline` / `sub-headline` / `body` / `caption` |
|
||||
| `verticalAlign` | 垂直对齐 |
|
||||
| `textAlign` | 文本对齐方式 |
|
||||
| `lineSpacing` | 行间距,schema 默认 `multiple:1.5` |
|
||||
| `fontSize` | 字号 |
|
||||
| `fontFamily` | 字体 |
|
||||
| `color` | 字体颜色 |
|
||||
| `bold` / `italic` / `underline` / `strikethrough` | 内容级样式 |
|
||||
| `wrap` | 是否自动换行 |
|
||||
| `bold` / `italic` / `underline` / `strikethrough` | 文本样式 |
|
||||
|
||||
`<content>` 的子元素只能是:
|
||||
|
||||
@@ -106,21 +84,7 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
|
||||
- `<ul>`
|
||||
- `<ol>`
|
||||
|
||||
### p 段落与内联标签
|
||||
|
||||
`<p>` 是段落元素,可混排纯文本和内联标签:
|
||||
|
||||
- `<br/>`
|
||||
- `<strong>`
|
||||
- `<em>`
|
||||
- `<u>`
|
||||
- `<span>`
|
||||
- `<del>`
|
||||
- `<a>`
|
||||
- `<shadow>`
|
||||
- `<outline>`
|
||||
|
||||
示例:
|
||||
### content 示例
|
||||
|
||||
```xml
|
||||
<content textType="body" textAlign="left">
|
||||
@@ -134,20 +98,8 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
|
||||
|
||||
## data 常用元素
|
||||
|
||||
所有页面元素都放在 `<data>` 中。
|
||||
|
||||
### shape
|
||||
|
||||
`shape` 可表示普通形状,也可表示文本框。文本框推荐使用 `type="text"`。
|
||||
|
||||
```xml
|
||||
<shape type="text" topLeftX="80" topLeftY="80" width="800" height="120">
|
||||
<content textType="title">
|
||||
<p>主标题</p>
|
||||
</content>
|
||||
</shape>
|
||||
```
|
||||
|
||||
```xml
|
||||
<shape type="rect" topLeftX="120" topLeftY="120" width="240" height="120">
|
||||
<fill>
|
||||
@@ -165,16 +117,6 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
|
||||
| `width` | 是 | 宽度 |
|
||||
| `height` | 是 | 高度 |
|
||||
| `rotation` | 否 | 旋转角度 |
|
||||
| `flipX` / `flipY` | 否 | 翻转 |
|
||||
| `alpha` | 否 | 透明度 |
|
||||
|
||||
可选子元素:
|
||||
|
||||
- `<fill>`
|
||||
- `<border>`
|
||||
- `<reflection>`
|
||||
- `<shadow>`
|
||||
- `<content>`
|
||||
|
||||
### line
|
||||
|
||||
@@ -184,23 +126,14 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
|
||||
</line>
|
||||
```
|
||||
|
||||
`line` 使用的是 `startX` / `startY` / `endX` / `endY`,不是 `x1` / `y1` / `x2` / `y2`。
|
||||
|
||||
### img
|
||||
|
||||
```xml
|
||||
<img src="file_token_or_url" topLeftX="80" topLeftY="120" width="320" height="180"/>
|
||||
```
|
||||
|
||||
`img` 使用 `topLeftX` / `topLeftY`,不是 `x` / `y`。
|
||||
|
||||
`src` 只支持:`slides +media-upload` 返回的 `file_token`,或 `@<本地路径>` 占位符(仅 `+create --slides` 自动上传并替换)。**禁止使用 http(s) 外链 URL**——飞书 slides 渲染端不会代理外链图,外链 src 在 PPT 里通常不显示。本地图片详见 [lark-slides-create.md](lark-slides-create.md#本地图片path-占位符) / [lark-slides-media-upload.md](lark-slides-media-upload.md)。
|
||||
|
||||
本地图片的两种姿势:
|
||||
|
||||
- 新建带图 PPT:`+create --slides` 里直接写 `src="@./pic.png"`,CLI 在创空白 PPT 后、加 slides 前自动上传并替换 token
|
||||
- 给已有 PPT 加带图新页:先 `slides +media-upload --file ./pic.png --presentation $PID` 拿 token,再用 token 写进 `xml_presentation.slide create` 的 XML
|
||||
|
||||
> **注意**:`width`/`height` 是**裁剪后**的显示尺寸。比例和原图不一致时会自动裁剪(无法靠属性关闭),想避免裁剪就让 `width:height` 对齐原图比例。
|
||||
|
||||
### icon
|
||||
@@ -211,85 +144,10 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
|
||||
|
||||
`iconType` 必须来自已验证的 IconPark 路径。需要语义图标时,先运行 `scripts/iconpark_tool.py search --query "<语义>"`,不要凭记忆拼路径。更多规则见 [iconpark.md](iconpark.md)。
|
||||
|
||||
### table
|
||||
|
||||
表格结构为:
|
||||
|
||||
- `<table>`
|
||||
- `<colgroup>` / `<tr>`
|
||||
- `<tr>` 内为 `<td>`
|
||||
- `<td>` 内可放 `<content>`
|
||||
|
||||
### chart
|
||||
|
||||
图表元素必须至少包含:
|
||||
|
||||
- `<chartPlotArea>`
|
||||
- `<chartData>`
|
||||
|
||||
同时还可以包含:
|
||||
|
||||
- `<chartTitle>`
|
||||
- `<chartSubTitle>`
|
||||
- `<chartStyle>`
|
||||
- `<chartLegend>`
|
||||
- `<chartTooltip>`
|
||||
|
||||
完整图表类型覆盖示例见 [slides_chart_demo.xml](slides_chart_demo.xml),其中包含柱状、条形、折线、面积、饼 / 环、雷达等原生 `<chart>` 示例,以及散点、气泡、漏斗、帕累托、瀑布等 `<whiteboard>` SVG 图表示例。
|
||||
|
||||
组合图示例:
|
||||
|
||||
```xml
|
||||
<chart width="556" height="350" topLeftX="42" topLeftY="132">
|
||||
<chartPlotArea>
|
||||
<chartPlot type="combo">
|
||||
<chartExtra/>
|
||||
<chartSeriesList>
|
||||
<chartSeries index="1" comboType="column"/>
|
||||
<chartSeries index="2" comboType="line" yAxisPosition="right">
|
||||
<chartTooltip format="0%"/>
|
||||
</chartSeries>
|
||||
</chartSeriesList>
|
||||
</chartPlot>
|
||||
<chartAxes>
|
||||
<chartAxis type="x">
|
||||
<chartLabel fontSize="10"/>
|
||||
</chartAxis>
|
||||
<chartAxis type="y" position="left">
|
||||
<chartGridLine color="rgb(226, 232, 240)"/>
|
||||
<chartLabel fontSize="10"/>
|
||||
</chartAxis>
|
||||
<chartAxis type="y" position="right">
|
||||
<chartLabel fontSize="10" format="0%"/>
|
||||
</chartAxis>
|
||||
</chartAxes>
|
||||
</chartPlotArea>
|
||||
<chartLegend position="bottom" fontSize="11"/>
|
||||
<chartData>
|
||||
<dim1>
|
||||
<chartField name="季度">24Q1,24Q2,24Q3,24Q4,25Q1,25Q2,25Q3,25Q4</chartField>
|
||||
</dim1>
|
||||
<dim2>
|
||||
<chartField name="营收">180,195,210,245,220,238,258,296</chartField>
|
||||
<chartField name="增速">0.08,0.12,0.15,0.18,0.22,0.22,0.23,0.21</chartField>
|
||||
</dim2>
|
||||
</chartData>
|
||||
<chartTitle fontSize="12" color="rgba(15, 30, 58, 1)" bold="true">营收(亿美元, 左轴) · 同比增速(%, 右轴)</chartTitle>
|
||||
<chartStyle>
|
||||
<chartBackground color="rgba(0, 0, 0, 0)"/>
|
||||
<chartBorder color="rgb(222, 224, 227)" width="0"/>
|
||||
<chartColorTheme>
|
||||
<color value="rgb(28, 71, 120)"/>
|
||||
<color value="rgb(240, 129, 54)"/>
|
||||
</chartColorTheme>
|
||||
</chartStyle>
|
||||
</chart>
|
||||
```
|
||||
|
||||
### whiteboard
|
||||
|
||||
```xml
|
||||
<!-- SVG 模式:<chart> 不支持的图表或自定义视觉、装饰元素 -->
|
||||
<!-- SVG 模式:数据图表、装饰元素 -->
|
||||
<whiteboard topLeftX="580" topLeftY="120" width="340" height="280">
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="60" y="80" width="40" height="140" rx="3" fill="rgba(59,130,246,0.85)"/>
|
||||
@@ -373,69 +231,12 @@ Mermaid 模式:内容用 `<![CDATA[...]]>` 包裹,避免 `[`、`>`、`-->`
|
||||
</note>
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<title>季度报告</title>
|
||||
<theme>
|
||||
<textStyles>
|
||||
<title fontFamily="思源黑体" fontSize="54" fontColor="rgba(0, 0, 0, 1)"/>
|
||||
<body fontFamily="思源黑体" fontSize="18" fontColor="rgba(43, 47, 54, 1)"/>
|
||||
</textStyles>
|
||||
</theme>
|
||||
<slide>
|
||||
<style>
|
||||
<fill>
|
||||
<fillColor color="rgb(245, 245, 245)"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<shape type="text" topLeftX="80" topLeftY="72" width="760" height="100">
|
||||
<content textType="title">
|
||||
<p>2024 年第一季度报告</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape type="text" topLeftX="80" topLeftY="200" width="520" height="180">
|
||||
<content textType="body">
|
||||
<p>核心指标</p>
|
||||
<ul>
|
||||
<li><p>用户增长:+25%</p></li>
|
||||
<li><p>收入增长:+30%</p></li>
|
||||
<li><p>市场份额:15%</p></li>
|
||||
</ul>
|
||||
</content>
|
||||
</shape>
|
||||
<shape type="rect" topLeftX="660" topLeftY="180" width="180" height="140">
|
||||
<fill>
|
||||
<fillColor color="rgba(100, 149, 237, 0.25)"/>
|
||||
</fill>
|
||||
<border color="rgb(100, 149, 237)" width="2"/>
|
||||
</shape>
|
||||
</data>
|
||||
<note>
|
||||
<content textType="body">
|
||||
<p>讲到增长率时补充样本范围。</p>
|
||||
</content>
|
||||
</note>
|
||||
</slide>
|
||||
</presentation>
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. 始终带上命名空间 `xmlns="http://www.larkoffice.com/sml/2.0"`
|
||||
2. 用 `shape type="text"` + `content` 表达页面文本
|
||||
3. 用 `topLeftX` / `topLeftY`、`startX` / `startY` 等 schema 中定义的属性名
|
||||
4. 优先使用 `rgb` / `rgba` 颜色格式;渐变必须使用 `rgba()` 且带百分比停靠点
|
||||
5. 特殊字符按 XML 规则转义
|
||||
6. 标准 16:9 页面建议使用 `width="960"` 和 `height="540"`
|
||||
|
||||
## 详细参考
|
||||
|
||||
- [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml)
|
||||
- [slides_chart_demo.xml](slides_chart_demo.xml)
|
||||
- [xml-format-guide.md](xml-format-guide.md)
|
||||
- [examples.md](examples.md)
|
||||
- [slides_demo.xml](slides_demo.xml)
|
||||
|
||||
## Schema 版本信息
|
||||
|
||||
|
||||
@@ -3,16 +3,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import xml_text_overlap_lint
|
||||
|
||||
|
||||
TEMPLATES_DIR = Path(__file__).resolve().parents[1] / "assets" / "templates"
|
||||
|
||||
|
||||
class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
def assertNoXmlTextOverlapLintIssues(self, result: dict, template_path: Path) -> None:
|
||||
def assertNoXmlTextOverlapLintIssues(self, result: dict, sample_name: str) -> None:
|
||||
issue_summaries = []
|
||||
for slide in result.get("slides", []):
|
||||
for issue in slide.get("issues", []):
|
||||
@@ -25,24 +21,64 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
self.assertEqual(
|
||||
result["summary"]["error_count"],
|
||||
0,
|
||||
f"{template_path.name} has XML text overlap lint errors:\n" + "\n".join(issue_summaries),
|
||||
f"{sample_name} has XML text overlap lint errors:\n" + "\n".join(issue_summaries),
|
||||
)
|
||||
self.assertEqual(
|
||||
result["summary"]["warning_count"],
|
||||
0,
|
||||
f"{template_path.name} has XML text overlap lint warnings:\n" + "\n".join(issue_summaries),
|
||||
f"{sample_name} has XML text overlap lint warnings:\n" + "\n".join(issue_summaries),
|
||||
)
|
||||
|
||||
def test_xml_text_overlap_lint_accepts_all_template_xml_files(self) -> None:
|
||||
template_paths = sorted(TEMPLATES_DIR.glob("*.xml"))
|
||||
self.assertTrue(template_paths)
|
||||
for template_path in template_paths:
|
||||
with self.subTest(template=template_path.name):
|
||||
def test_xml_text_overlap_lint_accepts_inline_fixture_xml_samples(self) -> None:
|
||||
samples = {
|
||||
"image-led-cover": """
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<style><fill><fillColor color="rgb(15,23,42)"/></fill></style>
|
||||
<data>
|
||||
<img src="tok" topLeftX="560" topLeftY="0" width="400" height="540"/>
|
||||
<shape type="text" topLeftX="64" topLeftY="150" width="420" height="70">
|
||||
<content textType="title"><p><span fontSize="42">Quarterly Review</span></p></content>
|
||||
</shape>
|
||||
<shape type="text" topLeftX="64" topLeftY="235" width="420" height="36">
|
||||
<content textType="sub-headline"><p><span fontSize="20">Focus, progress, and next steps</span></p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
""",
|
||||
"content-grid": """
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape type="text" topLeftX="60" topLeftY="44" width="620" height="46">
|
||||
<content textType="title"><p><span fontSize="30">Execution Snapshot</span></p></content>
|
||||
</shape>
|
||||
<shape type="rect" topLeftX="60" topLeftY="126" width="250" height="150"/>
|
||||
<shape type="text" topLeftX="84" topLeftY="152" width="200" height="36">
|
||||
<content textType="headline"><p><span fontSize="22">Plan</span></p></content>
|
||||
</shape>
|
||||
<shape type="rect" topLeftX="355" topLeftY="126" width="250" height="150"/>
|
||||
<shape type="text" topLeftX="379" topLeftY="152" width="200" height="36">
|
||||
<content textType="headline"><p><span fontSize="22">Build</span></p></content>
|
||||
</shape>
|
||||
<shape type="rect" topLeftX="650" topLeftY="126" width="250" height="150"/>
|
||||
<shape type="text" topLeftX="674" topLeftY="152" width="200" height="36">
|
||||
<content textType="headline"><p><span fontSize="22">Launch</span></p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
""",
|
||||
}
|
||||
self.assertTrue(samples)
|
||||
for sample_name, sample_xml in samples.items():
|
||||
with self.subTest(sample=sample_name):
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
template_path.read_text(encoding="utf-8"),
|
||||
str(template_path),
|
||||
sample_xml,
|
||||
sample_name,
|
||||
)
|
||||
self.assertNoXmlTextOverlapLintIssues(result, template_path)
|
||||
self.assertNoXmlTextOverlapLintIssues(result, sample_name)
|
||||
|
||||
def test_lint_xml_reports_unescaped_ampersand_in_text(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
|
||||
# vc +recording
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
通过 meeting_id 或 calendar_event_id 查询对应的 minute_token。这是 VC 域和 Minutes 域之间的桥梁命令。只读操作。
|
||||
|
||||
@@ -151,4 +150,3 @@ lark-cli minutes +download --minute-tokens <minute_token>
|
||||
- [lark-vc](../SKILL.md) — 视频会议全部命令
|
||||
- [lark-vc-search](lark-vc-search.md) — 搜索历史会议(获取 meeting_id)
|
||||
- [lark-minutes-detail](../../lark-minutes/references/lark-minutes-detail.md) — 获取会议纪要
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
- TestDriveAddCommentDryRun_File / TestDriveAddCommentDryRun_Base: dry-run coverage for `drive +add-comment` on supported Drive file and Base targets; pins the `metas.batch_query -> files/:token/new_comments` file chain, Base `file_type=bitable`, and Base anchor fields.
|
||||
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for the same path, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`.
|
||||
- TestDrive_SecureLabelDryRun: dry-run coverage for `drive +secure-label-list` and `drive +secure-label-update`; asserts label-list query params and update URL→type inference, request method/URL/type query, and `label-id` body shape. Runs without hitting live APIs because update can trigger document-level security approval flows.
|
||||
- TestDriveExportDryRun_FileNameMetadata / TestDriveExportDryRun_MarkdownFetchAPI / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, markdown fetch request shape without docs fetch `extra_param`, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body without calling live APIs.
|
||||
- TestDriveExportDryRun_FileNameMetadata / TestDriveExportDryRun_WikiURLPlansResolveBeforeExportTask / TestDriveExportDryRun_WikiTokenTypePlansResolveBeforeExportTask / TestDriveExportDryRun_MarkdownFetchAPI / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, Wiki URL and `--doc-type wiki` token `get_node -> export_tasks` planning, markdown fetch request shape without docs fetch `extra_param`, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body without calling live APIs.
|
||||
- TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary.
|
||||
- TestDrive_PushDryRun / TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +push`; asserts the list-files request shape, Validate-stage safety guards, conditional delete preflight, and acceptance of `--on-duplicate-remote=newest|oldest` by the real CLI binary.
|
||||
- Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered.
|
||||
@@ -29,7 +29,7 @@
|
||||
| ✓ | drive +apply-permission | shortcut | drive_apply_permission_dryrun_test.go::TestDrive_ApplyPermissionDryRun | `--token` URL vs bare; `--type` (enum) with URL inference; `--perm view\|edit`; `--remark` optional | dry-run only; no live-apply E2E because a real request pushes a card to the owner |
|
||||
| ✕ | drive +delete | shortcut | | none | no primary delete workflow yet |
|
||||
| ✕ | drive +download | shortcut | | none | no file fixture workflow yet |
|
||||
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_MarkdownFetchAPI + TestDriveExportDryRun_BitableBaseOnlySchema | `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema`; markdown fetch omits docs fetch `extra_param` | dry-run only; no live export workflow yet |
|
||||
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_WikiURLPlansResolveBeforeExportTask + TestDriveExportDryRun_WikiTokenTypePlansResolveBeforeExportTask + TestDriveExportDryRun_MarkdownFetchAPI + TestDriveExportDryRun_BitableBaseOnlySchema | `--url`; `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema`; Wiki URL / `--doc-type wiki` resolve step; markdown fetch omits docs fetch `extra_param` | dry-run only; no live export workflow yet |
|
||||
| ✕ | drive +export-download | shortcut | | none | no export-download workflow yet |
|
||||
| ✕ | drive +import | shortcut | | none | no import workflow yet |
|
||||
| ✕ | drive +move | shortcut | | none | no move workflow yet |
|
||||
|
||||
@@ -61,6 +61,96 @@ func TestDriveExportDryRun_FileNameMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportDryRun_WikiURLPlansResolveBeforeExportTask(t *testing.T) {
|
||||
setDriveDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+export",
|
||||
"--url", "https://example.feishu.cn/wiki/wikiDryRunExport",
|
||||
"--file-extension", "pdf",
|
||||
"--file-name", "wiki-report",
|
||||
"--output-dir", "./exports",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("api.0.method=%q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/wiki/v2/spaces/get_node" {
|
||||
t.Fatalf("api.0.url=%q, want wiki get_node\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.params.token").String(); got != "wikiDryRunExport" {
|
||||
t.Fatalf("api.0.params.token=%q, want wiki token\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.method").String(); got != "POST" {
|
||||
t.Fatalf("api.1.method=%q, want POST\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.url").String(); got != "/open-apis/drive/v1/export_tasks" {
|
||||
t.Fatalf("api.1.url=%q, want export_tasks\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.body.token").String(); got != "obj_token_from_step_0" {
|
||||
t.Fatalf("api.1.body.token=%q, want resolved token placeholder\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.body.type").String(); got != "obj_type_from_step_0" {
|
||||
t.Fatalf("api.1.body.type=%q, want resolved type placeholder\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "wiki_token").String(); got != "wikiDryRunExport" {
|
||||
t.Fatalf("wiki_token=%q, want source wiki token\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "file_name").String(); got != "wiki-report.pdf" {
|
||||
t.Fatalf("file_name=%q, want wiki-report.pdf\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportDryRun_WikiTokenTypePlansResolveBeforeExportTask(t *testing.T) {
|
||||
setDriveDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+export",
|
||||
"--token", "wikiDryRunExport",
|
||||
"--doc-type", "wiki",
|
||||
"--file-extension", "pdf",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("api.0.method=%q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/wiki/v2/spaces/get_node" {
|
||||
t.Fatalf("api.0.url=%q, want wiki get_node\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.params.token").String(); got != "wikiDryRunExport" {
|
||||
t.Fatalf("api.0.params.token=%q, want wiki token\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.body.token").String(); got != "obj_token_from_step_0" {
|
||||
t.Fatalf("api.1.body.token=%q, want resolved token placeholder\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.body.type").String(); got != "obj_type_from_step_0" {
|
||||
t.Fatalf("api.1.body.type=%q, want resolved type placeholder\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "wiki_token").String(); got != "wikiDryRunExport" {
|
||||
t.Fatalf("wiki_token=%q, want source wiki token\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportDryRun_MarkdownFetchAPI(t *testing.T) {
|
||||
setDriveDryRunConfigEnv(t)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user