Compare commits

...

7 Commits

Author SHA1 Message Date
zhanghuanxu
d7be4205d0 fix(slides): lint unsupported slide font families 2026-06-30 21:28:56 +08:00
zhanghuanxu
9b05a71de3 feat(slides):template rewrite 2026-06-30 14:45:20 +08:00
zhanghuanxu
c906fcac7e feat: support PDF imports as slides 2026-06-26 15:42:09 +08:00
zhanghuanxu
6ddbbafc4f feat: expose slides presentation url 2026-06-26 14:46:47 +08:00
zhanghuanxu
bf9264c901 fix: stop advertising slides screenshot scope 2026-06-26 14:46:46 +08:00
zhanghuanxu
e9f8d1d94b feat: add slides xml get shortcut 2026-06-26 14:46:46 +08:00
zhanghuanxu
a520b7ca93 feat: add slides replace-pages shortcut 2026-06-26 14:46:46 +08:00
29 changed files with 1811 additions and 88 deletions

View File

@@ -28,7 +28,7 @@ var DriveImport = common.Shortcut{
ConditionalScopes: []string{"wiki:node:retrieve"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file", Desc: "local file path (e.g. .docx, .xlsx, .md, .base, .pptx; large files auto use multipart upload; .base is capped at 20MB, .pptx at 500MB)", Required: true},
{Name: "file", Desc: "local file path (e.g. .docx, .xlsx, .md, .base, .pptx, .pdf; large files auto use multipart upload; .base is capped at 20MB, .pptx/.pdf at 500MB)", Required: true},
{Name: "type", Desc: "target document type (docx, sheet, bitable, slides)", Required: true},
{Name: "folder-token", Desc: "target folder token (omit for root folder; API accepts empty mount_key as root)"},
{Name: "name", Desc: "imported file name (default: local file name without extension)"},

View File

@@ -45,6 +45,7 @@ var driveImportExtToDocTypes = map[string][]string{
"csv": {"sheet", "bitable"},
"base": {"bitable"},
"pptx": {"slides"},
"pdf": {"slides"},
}
// driveImportSpec contains the user-facing import inputs after normalization.
@@ -153,7 +154,7 @@ func driveImportFileSizeLimit(filePath, docType string) (int64, bool) {
switch strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".") {
case "docx", "doc":
return driveImport600MBFileSizeLimit, true
case "pptx":
case "pptx", "pdf":
return driveImport500MBFileSizeLimit, true
case "txt", "md", "mark", "markdown", "html", "xls", "base":
return driveImport20MBFileSizeLimit, true
@@ -199,7 +200,7 @@ func validateDriveImportFileSize(filePath, docType string, fileSize int64) error
func validateDriveImportSpec(spec driveImportSpec) error {
ext := spec.FileExtension()
if ext == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must have an extension (e.g. .md, .docx, .xlsx, .pptx)").WithParam("--file")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must have an extension (e.g. .md, .docx, .xlsx, .pptx, .pdf)").WithParam("--file")
}
switch spec.DocType {
@@ -210,7 +211,7 @@ func validateDriveImportSpec(spec driveImportSpec) error {
supportedTypes, ok := driveImportExtToDocTypes[ext]
if !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file extension: %s. Supported extensions are: docx, doc, txt, md, mark, markdown, html, xlsx, xls, csv, base, pptx", ext).WithParam("--file")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file extension: %s. Supported extensions are: docx, doc, txt, md, mark, markdown, html, xlsx, xls, csv, base, pptx, pdf", ext).WithParam("--file")
}
typeAllowed := false
@@ -231,8 +232,8 @@ func validateDriveImportSpec(spec driveImportSpec) error {
hint = fmt.Sprintf(".xls files can only be imported as 'sheet', not '%s'", spec.DocType)
case "base":
hint = fmt.Sprintf(".base files can only be imported as 'bitable', not '%s'", spec.DocType)
case "pptx":
hint = fmt.Sprintf(".pptx files can only be imported as 'slides', not '%s'", spec.DocType)
case "pptx", "pdf":
hint = fmt.Sprintf(".%s files can only be imported as 'slides', not '%s'", ext, spec.DocType)
default:
hint = fmt.Sprintf(".%s files can only be imported as 'docx', not '%s'", ext, spec.DocType)
}

View File

@@ -41,6 +41,10 @@ func TestValidateDriveImportSpec(t *testing.T) {
name: "pptx slides ok",
spec: driveImportSpec{FilePath: "./deck.pptx", DocType: "slides"},
},
{
name: "pdf slides ok",
spec: driveImportSpec{FilePath: "./deck.pdf", DocType: "slides"},
},
{
name: "base non bitable rejected",
spec: driveImportSpec{FilePath: "./snapshot.base", DocType: "sheet"},
@@ -51,6 +55,11 @@ func TestValidateDriveImportSpec(t *testing.T) {
spec: driveImportSpec{FilePath: "./deck.pptx", DocType: "docx"},
wantErr: ".pptx files can only be imported as 'slides'",
},
{
name: "pdf non slides rejected",
spec: driveImportSpec{FilePath: "./deck.pdf", DocType: "docx"},
wantErr: ".pdf files can only be imported as 'slides'",
},
{
name: "unknown extension rejected",
spec: driveImportSpec{FilePath: "./data.rtf", DocType: "docx"},
@@ -138,6 +147,19 @@ func TestValidateDriveImportFileSize(t *testing.T) {
docType: "slides",
fileSize: driveImport500MBFileSizeLimit,
},
{
name: "pdf exceeds 500mb limit",
filePath: "./deck.pdf",
docType: "slides",
fileSize: driveImport500MBFileSizeLimit + 1,
wantText: "exceeds 500.0 MB import limit for .pdf",
},
{
name: "pdf within 500mb limit",
filePath: "./deck.pdf",
docType: "slides",
fileSize: driveImport500MBFileSizeLimit,
},
{
name: "base exceeds 20mb limit",
filePath: "./snapshot.base",

View File

@@ -11,6 +11,8 @@ func Shortcuts() []common.Shortcut {
SlidesCreate,
SlidesMediaUpload,
SlidesReplaceSlide,
SlidesReplacePages,
SlidesScreenshot,
SlidesXMLGet,
}
}

View File

@@ -204,13 +204,11 @@ var SlidesCreate = common.Shortcut{
}
}
// Build the presentation URL locally from the token. The brand-standard
// host transparently redirects to the tenant domain (same fallback used by
// drive +upload / wiki +node-create). This avoids the prior best-effort
// drive metas/batch_query call, which needed an extra drive scope and 403'd
// for users who only authorized slides scopes — without ever blocking an
// otherwise-successful creation.
if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" {
// Prefer the URL returned by presentation.create. Fall back to a local
// brand-standard URL only when the API omits it.
if url := common.GetString(data, "url"); url != "" {
result["url"] = url
} else if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" {
result["url"] = url
}

View File

@@ -34,6 +34,7 @@ func TestSlidesCreateBasic(t *testing.T) {
"data": map[string]interface{}{
"xml_presentation_id": "pres_abc123",
"revision_id": 1,
"url": "https://tenant.example.com/slides/pres_abc123",
},
},
})
@@ -54,10 +55,8 @@ func TestSlidesCreateBasic(t *testing.T) {
if data["title"] != "项目汇报" {
t.Fatalf("title = %v, want 项目汇报", data["title"])
}
// URL is built locally from the token (brand-standard host), not fetched from
// drive metas, so it is deterministic and needs no drive scope.
if data["url"] != "https://www.feishu.cn/slides/pres_abc123" {
t.Fatalf("url = %v, want https://www.feishu.cn/slides/pres_abc123", data["url"])
if data["url"] != "https://tenant.example.com/slides/pres_abc123" {
t.Fatalf("url = %v, want https://tenant.example.com/slides/pres_abc123", data["url"])
}
if _, ok := data["permission_grant"]; ok {
t.Fatalf("did not expect permission_grant in user mode")
@@ -647,12 +646,12 @@ func TestSlidesCreateWithoutSlidesUnchanged(t *testing.T) {
}
}
// TestSlidesCreateURLBuiltLocally verifies the presentation URL is constructed
// locally from the token — no drive metas/batch_query call is made, so creation
// works for users who only authorized slides scopes. The httpmock registry has no
// batch_query stub registered; if the shortcut tried to call it, the request would
// fail the test (unregistered stub), proving the URL is built without a drive call.
func TestSlidesCreateURLBuiltLocally(t *testing.T) {
// TestSlidesCreateURLFallsBackToLocalBuild verifies the presentation URL is
// constructed locally from the token when presentation.create omits url — no
// drive metas/batch_query call is made, so creation works for users who only
// authorized slides scopes. The httpmock registry has no batch_query stub
// registered; if the shortcut tried to call it, the request would fail the test.
func TestSlidesCreateURLFallsBackToLocalBuild(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
@@ -665,6 +664,7 @@ func TestSlidesCreateURLBuiltLocally(t *testing.T) {
"data": map[string]interface{}{
"xml_presentation_id": "pres_local_url",
"revision_id": 1,
"url": "",
},
},
})

View File

@@ -0,0 +1,426 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// SlidesReplacePages rebuilds multiple pages inside an existing presentation.
// It deliberately creates the new page before deleting the old one so a create
// failure cannot remove existing user content. The operation is not atomic.
const replacePagesInitialRevisionID = -1
var SlidesReplacePages = common.Shortcut{
Service: "slides",
Command: "+replace-pages",
Description: "Batch rebuild pages inside an existing Slides presentation (create before old page, then delete old page; not atomic)",
Risk: "write",
Scopes: []string{"slides:presentation:update", "slides:presentation:write_only"},
// wiki:node:read is required only when --presentation is a wiki URL.
ConditionalScopes: []string{"wiki:node:read"},
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: "pages", Desc: "JSON array of page replacements (each: {slide_id, content}); supports @file or -", Required: true, Input: []string{common.File, common.Stdin}},
{Name: "continue-on-error", Type: "bool", Desc: "continue with later pages after a create/delete failure; default false"},
{Name: "validate-only", Type: "bool", Desc: "validate input and build the create/delete plan without write calls"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return err
}
if ref.Kind == "wiki" {
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
return err
}
}
pages, err := parseReplacePages(runtime.Str("pages"))
if err != nil {
return err
}
return validateReplacePagesInput(pages)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
dry := common.NewDryRunAPI()
resolved, err := prepareReplacePages(runtime)
if err != nil {
return dry.Set("error", err.Error())
}
appendReplacePagesDryRunCalls(dry, resolved)
return dry.
Set("xml_presentation_id", resolved.PresentationID).
Set("pages_count", len(resolved.Plan)).
Set("plan", replacePagesPlanOutput(resolved.Plan)).
Set("note", "dry-run built a create/delete plan from slide_id inputs; no Slides presentation get/create/delete calls were executed")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
resolved, err := prepareReplacePages(runtime)
if err != nil {
return err
}
if runtime.Bool("validate-only") {
runtime.Out(map[string]interface{}{
"xml_presentation_id": resolved.PresentationID,
"pages_count": len(resolved.Plan),
"plan": replacePagesPlanOutput(resolved.Plan),
"status": "validated",
"note": "validate-only checked input and built the create/delete plan; no Slides presentation get/create/delete calls were executed",
}, nil)
return nil
}
revisionID := replacePagesInitialRevisionID
results := make([]replacePageResult, 0, len(resolved.Plan))
for i, item := range resolved.Plan {
result, err := replaceOnePage(runtime, resolved.PresentationID, item, revisionID)
results = append(results, result)
if result.RevisionID != nil {
revisionID = *result.RevisionID
}
if err != nil {
if runtime.Bool("continue-on-error") {
continue
}
return appendSlidesProgressHint(err, fmt.Sprintf("slides +replace-pages stopped at item %d/%d; %d page(s) completed before failure; old page is kept when create failed", i+1, len(resolved.Plan), countReplacedPages(results)))
}
}
out := map[string]interface{}{
"xml_presentation_id": resolved.PresentationID,
"pages_count": len(resolved.Plan),
"results": replacePageResultsOutput(results),
"status": "completed",
"summary": replacePagesSummaryOutput(results),
"note": "batch replace is not atomic; each page was created before its old page was deleted",
}
if revisionID != replacePagesInitialRevisionID {
out["revision_id"] = revisionID
}
if hasReplacePageFailures(results) {
out["status"] = "partial_failure"
return runtime.OutPartialFailure(out, nil)
}
runtime.Out(out, nil)
return nil
},
}
type replacePageInput struct {
SlideID string
Content string
}
type replacePagePlanItem struct {
OldSlideID string
Content string
Locator string
}
type replacePagesPrepared struct {
PresentationID string
Plan []replacePagePlanItem
}
type replacePageResult struct {
OldSlideID string
NewSlideID string
Status string
Error string
RevisionID *int
}
func prepareReplacePages(runtime *common.RuntimeContext) (*replacePagesPrepared, error) {
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return nil, err
}
presentationID, err := resolvePresentationID(runtime, ref)
if err != nil {
return nil, err
}
pages, err := parseReplacePages(runtime.Str("pages"))
if err != nil {
return nil, err
}
if err := validateReplacePagesInput(pages); err != nil {
return nil, err
}
plan, err := buildReplacePagesPlan(pages)
if err != nil {
return nil, err
}
return &replacePagesPrepared{PresentationID: presentationID, Plan: plan}, nil
}
func parseReplacePages(raw string) ([]replacePageInput, error) {
s := strings.TrimSpace(raw)
if s == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages cannot be empty").WithParam("--pages")
}
var decoded []map[string]interface{}
if err := json.Unmarshal([]byte(s), &decoded); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages invalid JSON, must be an array of objects: %v", err).WithParam("--pages").WithCause(err)
}
out := make([]replacePageInput, 0, len(decoded))
for i, m := range decoded {
p := replacePageInput{}
if v, ok := m["slide_number"]; ok {
_ = v
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_number is no longer supported; use slide_id", i).WithParam("--pages").WithHint("read current slide IDs first, then pass slide_id for each page replacement")
}
if v, ok := m["slide_id"]; ok {
s, ok := v.(string)
if !ok {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_id must be a string", i).WithParam("--pages")
}
p.SlideID = s
}
if v, ok := m["content"]; ok {
s, ok := v.(string)
if !ok {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content must be a string", i).WithParam("--pages")
}
p.Content = s
}
out = append(out, p)
}
return out, nil
}
func validateReplacePagesInput(pages []replacePageInput) error {
if len(pages) == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages must contain at least 1 item").WithParam("--pages")
}
seenIDs := map[string]bool{}
for i, p := range pages {
id := strings.TrimSpace(p.SlideID)
if id == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_id is required", i).WithParam("--pages")
}
if seenIDs[id] {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages contains duplicate slide_id %q", id).WithParam("--pages")
}
seenIDs[id] = true
if strings.TrimSpace(p.Content) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content cannot be empty", i).WithParam("--pages")
}
if err := validateCompleteSlideXML(p.Content); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content must be a complete <slide> XML element: %v", i, err).WithParam("--pages").WithCause(err)
}
}
return nil
}
func validateCompleteSlideXML(content string) error {
dec := xml.NewDecoder(strings.NewReader(content))
depth := 0
seenRoot := false
for {
tok, err := dec.Token()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return err
}
switch t := tok.(type) {
case xml.StartElement:
if depth == 0 {
if seenRoot {
return invalidSlideXMLStructureError("multiple root elements")
}
if t.Name.Local != "slide" {
return invalidSlideXMLStructureError("root element is <%s>, want <slide>", t.Name.Local)
}
seenRoot = true
}
depth++
case xml.EndElement:
depth--
case xml.CharData:
if depth == 0 && strings.TrimSpace(string(t)) != "" {
return invalidSlideXMLStructureError("non-whitespace text outside root element")
}
}
}
if !seenRoot {
return invalidSlideXMLStructureError("missing root element")
}
if depth != 0 {
return invalidSlideXMLStructureError("unclosed XML element")
}
return nil
}
func invalidSlideXMLStructureError(format string, args ...interface{}) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
}
func buildReplacePagesPlan(pages []replacePageInput) ([]replacePagePlanItem, error) {
plan := make([]replacePagePlanItem, 0, len(pages))
for _, page := range pages {
id := strings.TrimSpace(page.SlideID)
plan = append(plan, replacePagePlanItem{
OldSlideID: id,
Content: page.Content,
Locator: "slide_id",
})
}
return plan, nil
}
func appendReplacePagesDryRunCalls(dry *common.DryRunAPI, resolved *replacePagesPrepared) {
dry.Desc("Batch replace pages in-place: create each new page before old page, then delete old page (not atomic)")
for i, item := range resolved.Plan {
dry.POST(fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(resolved.PresentationID))).
Desc(fmt.Sprintf("[%d/%d] Create replacement before old slide %s", i*2+1, len(resolved.Plan)*2, item.OldSlideID)).
Params(map[string]interface{}{"revision_id": "<latest_or_revision_returned_by_previous_step>"}).
Body(map[string]interface{}{
"slide": map[string]interface{}{"content": item.Content},
"before_slide_id": item.OldSlideID,
})
dry.DELETE(fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(resolved.PresentationID))).
Desc(fmt.Sprintf("[%d/%d] Delete old slide %s after create succeeds", i*2+2, len(resolved.Plan)*2, item.OldSlideID)).
Params(map[string]interface{}{
"slide_id": item.OldSlideID,
"revision_id": "<revision_returned_by_create>",
})
}
}
func replaceOnePage(runtime *common.RuntimeContext, presentationID string, item replacePagePlanItem, revisionID int) (replacePageResult, error) {
result := replacePageResult{
OldSlideID: item.OldSlideID,
Status: "pending",
}
slideURL := fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(presentationID))
createData, err := runtime.CallAPITyped(
"POST",
slideURL,
map[string]interface{}{"revision_id": revisionID},
map[string]interface{}{
"slide": map[string]interface{}{"content": item.Content},
"before_slide_id": item.OldSlideID,
},
)
if err != nil {
result.Status = "create_failed"
result.Error = err.Error()
return result, err
}
newSlideID := common.GetString(createData, "slide_id")
if newSlideID == "" {
err := errs.NewInternalError(errs.SubtypeInvalidResponse, "slide.create returned no slide_id for replacement of slide_id %q", item.OldSlideID)
result.Status = "create_failed"
result.Error = err.Error()
return result, err
}
result.NewSlideID = newSlideID
if rev, ok := revisionFromData(createData); ok {
revisionID = rev
result.RevisionID = &rev
}
deleteData, err := runtime.CallAPITyped(
"DELETE",
slideURL,
map[string]interface{}{
"slide_id": item.OldSlideID,
"revision_id": revisionID,
},
nil,
)
if err != nil {
result.Status = "delete_failed"
result.Error = err.Error()
return result, err
}
if rev, ok := revisionFromData(deleteData); ok {
result.RevisionID = &rev
}
result.Status = "replaced"
return result, nil
}
func revisionFromData(data map[string]interface{}) (int, bool) {
if _, ok := data["revision_id"]; !ok {
return 0, false
}
return int(common.GetFloat(data, "revision_id")), true
}
func replacePagesPlanOutput(plan []replacePagePlanItem) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(plan))
for _, item := range plan {
out = append(out, map[string]interface{}{
"old_slide_id": item.OldSlideID,
"insert_before_slide_id": item.OldSlideID,
"locator": item.Locator,
"action": "create_before_then_delete_old",
})
}
return out
}
func replacePageResultsOutput(results []replacePageResult) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(results))
for _, result := range results {
m := map[string]interface{}{
"old_slide_id": result.OldSlideID,
"status": result.Status,
}
if result.NewSlideID != "" {
m["new_slide_id"] = result.NewSlideID
}
if result.Error != "" {
m["error"] = result.Error
}
if result.RevisionID != nil {
m["revision_id"] = *result.RevisionID
}
out = append(out, m)
}
return out
}
func replacePagesSummaryOutput(results []replacePageResult) map[string]interface{} {
replaced := countReplacedPages(results)
return map[string]interface{}{
"replaced": replaced,
"failed": len(results) - replaced,
"total": len(results),
}
}
func countReplacedPages(results []replacePageResult) int {
n := 0
for _, result := range results {
if result.Status == "replaced" {
n++
}
}
return n
}
func hasReplacePageFailures(results []replacePageResult) bool {
for _, result := range results {
if result.Status == "create_failed" || result.Status == "delete_failed" {
return true
}
}
return false
}

View File

@@ -0,0 +1,341 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"encoding/json"
"errors"
"net/http"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
func TestReplacePagesDeclaredScopes(t *testing.T) {
if got := SlidesReplacePages.ScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) {
t.Fatalf("user preflight scopes = %#v, want slides update/write_only only", got)
}
if got := SlidesReplacePages.ScopesForIdentity("bot"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) {
t.Fatalf("bot preflight scopes = %#v, want slides update/write_only only", got)
}
got := SlidesReplacePages.DeclaredScopesForIdentity("user")
want := []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("declared scopes = %#v, want %#v", got, want)
}
}
func TestReplacePagesCreatesBeforeThenDeletesOld(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
var requestOrder []string
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"slide_id": "new2", "revision_id": 11},
},
OnMatch: func(req *http.Request) {
requestOrder = append(requestOrder, req.Method)
},
}
reg.Register(createStub)
var deleteQuery map[string][]string
deleteStub := &httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"revision_id": 12},
},
OnMatch: func(req *http.Request) {
requestOrder = append(requestOrder, req.Method)
deleteQuery = req.URL.Query()
},
}
reg.Register(deleteStub)
pages := `[{"slide_id":"old2","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}]`
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
"+replace-pages",
"--presentation", "pres_abc",
"--pages", pages,
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var createBody struct {
Slide struct {
Content string `json:"content"`
} `json:"slide"`
BeforeSlideID string `json:"before_slide_id"`
}
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
t.Fatalf("decode create body: %v\nraw=%s", err, createStub.CapturedBody)
}
if createBody.BeforeSlideID != "old2" {
t.Fatalf("before_slide_id = %q, want old2", createBody.BeforeSlideID)
}
if !strings.Contains(createBody.Slide.Content, "<slide") {
t.Fatalf("create content = %q", createBody.Slide.Content)
}
if !reflect.DeepEqual(requestOrder, []string{"POST", "DELETE"}) {
t.Fatalf("request order = %#v, want POST then DELETE", requestOrder)
}
deleteURL := string(deleteStub.CapturedBody)
if deleteURL != "" {
t.Fatalf("delete body = %q, want empty", deleteURL)
}
if got := deleteQuery["slide_id"]; !reflect.DeepEqual(got, []string{"old2"}) {
t.Fatalf("delete slide_id = %#v, want old2", got)
}
if got := deleteQuery["revision_id"]; !reflect.DeepEqual(got, []string{"11"}) {
t.Fatalf("delete revision_id = %#v, want 11 from create response", got)
}
data := decodeShortcutData(t, stdout)
if data["xml_presentation_id"] != "pres_abc" {
t.Fatalf("xml_presentation_id = %v", data["xml_presentation_id"])
}
if data["revision_id"] != float64(12) {
t.Fatalf("revision_id = %v, want 12", data["revision_id"])
}
summary, _ := data["summary"].(map[string]interface{})
if summary["failed"] != float64(0) {
t.Fatalf("summary.failed = %v, want 0", summary["failed"])
}
results, _ := data["results"].([]interface{})
if len(results) != 1 {
t.Fatalf("results len = %d, want 1", len(results))
}
first, _ := results[0].(map[string]interface{})
if first["old_slide_id"] != "old2" || first["new_slide_id"] != "new2" || first["status"] != "replaced" {
t.Fatalf("result = %#v", first)
}
}
func TestReplacePagesContinueOnErrorReturnsPartialFailure(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
Body: map[string]interface{}{
"code": 3350001,
"msg": "invalid param",
"data": map[string]interface{}{},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"slide_id": "new2", "revision_id": 11},
},
})
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"revision_id": 12},
},
})
pages := `[
{"slide_id":"old1","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"},
{"slide_id":"old2","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}
]`
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
"+replace-pages",
"--presentation", "pres_abc",
"--pages", pages,
"--continue-on-error",
"--as", "user",
})
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("err = %T %v, want *output.PartialFailureError", err, err)
}
env := decodeReplacePagesEnvelope(t, stdout)
if env.OK {
t.Fatalf("stdout ok = true, want false for partial failure")
}
data := env.Data
if data["status"] != "partial_failure" {
t.Fatalf("status = %v, want partial_failure", data["status"])
}
summary, _ := data["summary"].(map[string]interface{})
if summary["replaced"] != float64(1) || summary["failed"] != float64(1) || summary["total"] != float64(2) {
t.Fatalf("summary = %#v, want replaced=1 failed=1 total=2", summary)
}
results, _ := data["results"].([]interface{})
if len(results) != 2 {
t.Fatalf("results len = %d, want 2", len(results))
}
first, _ := results[0].(map[string]interface{})
second, _ := results[1].(map[string]interface{})
if first["status"] != "create_failed" {
t.Fatalf("first status = %v, want create_failed", first["status"])
}
if second["status"] != "replaced" || second["new_slide_id"] != "new2" {
t.Fatalf("second result = %#v, want replaced with new2", second)
}
}
func TestReplacePagesContinueOnErrorDeleteFailureIncludesNewSlideID(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"slide_id": "new1", "revision_id": 11},
},
})
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
Body: map[string]interface{}{
"code": 3350001,
"msg": "invalid param",
"data": map[string]interface{}{},
},
})
pages := `[{"slide_id":"old1","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}]`
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
"+replace-pages",
"--presentation", "pres_abc",
"--pages", pages,
"--continue-on-error",
"--as", "user",
})
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("err = %T %v, want *output.PartialFailureError", err, err)
}
env := decodeReplacePagesEnvelope(t, stdout)
if env.OK {
t.Fatalf("stdout ok = true, want false for partial failure")
}
results, _ := env.Data["results"].([]interface{})
if len(results) != 1 {
t.Fatalf("results len = %d, want 1", len(results))
}
first, _ := results[0].(map[string]interface{})
if first["status"] != "delete_failed" {
t.Fatalf("status = %v, want delete_failed", first["status"])
}
if first["new_slide_id"] != "new1" {
t.Fatalf("new_slide_id = %v, want new1", first["new_slide_id"])
}
}
func TestReplacePagesDryRunPlansOnly(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
pages := `[{"slide_id":"old2","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}]`
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
"+replace-pages",
"--presentation", "pres_abc",
"--pages", pages,
"--dry-run",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var out map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
t.Fatalf("decode dry-run: %v\nraw=%s", err, stdout.String())
}
if out["xml_presentation_id"] != "pres_abc" {
t.Fatalf("xml_presentation_id = %v", out["xml_presentation_id"])
}
plan, _ := out["plan"].([]interface{})
if len(plan) != 1 {
t.Fatalf("plan len = %d, want 1", len(plan))
}
item, _ := plan[0].(map[string]interface{})
if item["old_slide_id"] != "old2" || item["action"] != "create_before_then_delete_old" {
t.Fatalf("plan item = %#v", item)
}
api, _ := out["api"].([]interface{})
if len(api) != 2 {
t.Fatalf("api len = %d, want create/delete plan", len(api))
}
}
func TestReplacePagesValidationParam(t *testing.T) {
t.Parallel()
tests := []struct {
name string
pages string
}{
{"empty pages", `[]`},
{"slide number no longer supported", `[{"slide_number":1,"content":"<slide/>"}]`},
{"no locator", `[{"content":"<slide/>"}]`},
{"empty content", `[{"slide_id":"s1","content":" "}]`},
{"not slide XML", `[{"slide_id":"s1","content":"<shape/>"}]`},
{"duplicate id", `[{"slide_id":"s1","content":"<slide/>"},{"slide_id":"s1","content":"<slide/>"}]`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
"+replace-pages",
"--presentation", "pres_abc",
"--pages", tt.pages,
"--as", "user",
})
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %v, want *errs.ValidationError", err)
}
if ve.Param != "--pages" {
t.Fatalf("Param = %q, want --pages", ve.Param)
}
})
}
}
type replacePagesEnvelope struct {
OK bool `json:"ok"`
Data map[string]interface{} `json:"data"`
}
func decodeReplacePagesEnvelope(t *testing.T, stdout interface{ Bytes() []byte }) replacePagesEnvelope {
t.Helper()
var env replacePagesEnvelope
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode output: %v\nraw=%s", err, string(stdout.Bytes()))
}
if env.Data == nil {
t.Fatalf("missing data: %#v", env)
}
return env
}

View File

@@ -43,8 +43,10 @@ var SlidesReplaceSlide = common.Shortcut{
Command: "+replace-slide",
Description: "Replace elements on a slide via block_replace / block_insert parts (auto-injects id + <content/> on shape elements)",
Risk: "write",
Scopes: []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Scopes: []string{"slides:presentation:update", "slides:presentation:write_only"},
// wiki:node:read is required only when --presentation is a wiki URL.
ConditionalScopes: []string{"wiki:node:read"},
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: "slide-id", Desc: "slide page identifier (slide_id)", Required: true},
@@ -53,9 +55,15 @@ var SlidesReplaceSlide = common.Shortcut{
{Name: "tid", Desc: "transaction id for concurrent-edit locking (usually empty)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := parsePresentationRef(runtime.Str("presentation")); err != nil {
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return err
}
if ref.Kind == "wiki" {
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
return err
}
}
if strings.TrimSpace(runtime.Str("slide-id")) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slide-id cannot be empty").WithParam("--slide-id")
}

View File

@@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
"reflect"
"strings"
"testing"
@@ -15,6 +16,21 @@ import (
"github.com/larksuite/cli/internal/httpmock"
)
func TestReplaceSlideDeclaredScopes(t *testing.T) {
if got := SlidesReplaceSlide.ScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) {
t.Fatalf("user preflight scopes = %#v, want slides update/write_only only", got)
}
if got := SlidesReplaceSlide.ScopesForIdentity("bot"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) {
t.Fatalf("bot preflight scopes = %#v, want slides update/write_only only", got)
}
got := SlidesReplaceSlide.DeclaredScopesForIdentity("user")
want := []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("declared scopes = %#v, want %#v", got, want)
}
}
// TestReplaceSlideBlockReplaceInjectsID is the core regression: users write
// <shape>…</shape> as replacement and the CLI must stitch id="<block_id>"
// onto the root before sending. The backend returns 3350001 otherwise.

View File

@@ -34,7 +34,9 @@ var SlidesScreenshot = common.Shortcut{
Command: "+screenshot",
Description: "Save slide screenshots to local files without printing Base64 image data",
Risk: "read",
Scopes: []string{"slides:presentation:screenshot"},
Scopes: []string{},
// The screenshot API is allowlist-gated for only a few apps, so do not
// advertise/preflight its scope. Let the API fail and let callers degrade.
// wiki:node:read is required only when --presentation is a wiki URL.
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},

View File

@@ -17,11 +17,23 @@ import (
)
func TestSlidesScreenshotDeclaredScopes(t *testing.T) {
if got := SlidesScreenshot.ScopesForIdentity("user"); len(got) != 0 {
t.Fatalf("user preflight scopes = %#v, want empty", got)
}
if got := SlidesScreenshot.ScopesForIdentity("bot"); len(got) != 0 {
t.Fatalf("bot preflight scopes = %#v, want empty", got)
}
got := SlidesScreenshot.DeclaredScopesForIdentity("user")
want := []string{"slides:presentation:screenshot", "wiki:node:read"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
want := []string{"wiki:node:read"}
if len(got) != len(want) || got[0] != want[0] {
t.Fatalf("declared scopes = %#v, want %#v", got, want)
}
for _, scope := range got {
if scope == "slides:presentation:screenshot" {
t.Fatalf("declared scopes must not advertise screenshot scope: %#v", got)
}
}
}
func TestSlidesScreenshotWritesFilesAndSuppressesBase64(t *testing.T) {

View File

@@ -0,0 +1,144 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"bytes"
"context"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// 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 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.
ConditionalScopes: []string{"wiki:node:read"},
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", 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"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return err
}
if ref.Kind == "wiki" {
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
return err
}
}
if strings.TrimSpace(runtime.Str("output")) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output cannot be empty").WithParam("--output")
}
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
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
presentationID := ref.Token
dry := common.NewDryRunAPI()
if ref.Kind == "wiki" {
presentationID = "<resolved_slides_token>"
dry.Desc("2-step orchestration: resolve wiki → fetch full presentation XML").
GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to slides presentation").
Params(map[string]interface{}{"token": ref.Token})
} else {
dry.Desc("Fetch full presentation XML and save it to a local file")
}
params := map[string]interface{}{
"revision_id": runtime.Int("revision-id"),
}
if runtime.Bool("remove-attr-id") {
params["remove_attr_id"] = true
}
dry.GET(fmt.Sprintf(
"/open-apis/slides_ai/v1/xml_presentations/%s",
validate.EncodePathSegment(presentationID),
)).
Params(params)
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"))
if err != nil {
return err
}
presentationID, err := resolvePresentationID(runtime, ref)
if err != nil {
return err
}
params := map[string]interface{}{
"revision_id": runtime.Int("revision-id"),
}
if runtime.Bool("remove-attr-id") {
params["remove_attr_id"] = true
}
data, err := runtime.CallAPITyped(
"GET",
fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s", validate.EncodePathSegment(presentationID)),
params,
nil,
)
if err != nil {
return err
}
presentation := common.GetMap(data, "xml_presentation")
content := common.GetString(presentation, "content")
if content == "" {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "slides xml get returned empty xml_presentation.content")
}
outputPath := runtime.Str("output")
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
ContentType: "application/xml",
ContentLength: int64(len(content)),
}, bytes.NewReader([]byte(content)))
if err != nil {
return common.WrapSaveErrorTyped(err)
}
resolvedPath, err := runtime.ResolveSavePath(outputPath)
if err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "resolve saved XML path %s: %v", outputPath, err).WithCause(err)
}
out := map[string]interface{}{
"xml_presentation_id": presentationID,
"path": resolvedPath,
"size": result.Size(),
"content_saved": true,
}
if revisionID := common.GetFloat(presentation, "revision_id"); revisionID > 0 {
out["revision_id"] = int(revisionID)
}
if runtime.Bool("remove-attr-id") {
out["remove_attr_id"] = true
}
runtime.Out(out, nil)
return nil
},
}

View File

@@ -0,0 +1,165 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"errors"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
var capturedQuery url.Values
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{}{
"presentation_id": "pres_abc",
"revision_id": 7,
"content": xml,
},
},
},
OnMatch: func(req *http.Request) {
capturedQuery = req.URL.Query()
},
})
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
"+xml-get",
"--presentation", "pres_abc",
"--output", "readback.xml",
"--revision-id", "7",
"--remove-attr-id",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
path := filepath.Join(dir, "readback.xml")
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read saved XML: %v", err)
}
if string(got) != xml {
t.Fatalf("saved XML = %q, want %q", got, xml)
}
if strings.Contains(stdout.String(), xml) {
t.Fatalf("stdout leaked full XML content: %s", stdout.String())
}
if got := capturedQuery.Get("revision_id"); got != "7" {
t.Fatalf("revision_id query = %q, want 7", got)
}
if got := capturedQuery.Get("remove_attr_id"); got != "true" {
t.Fatalf("remove_attr_id query = %q, want true", got)
}
data := decodeShortcutData(t, stdout)
if data["xml_presentation_id"] != "pres_abc" {
t.Fatalf("xml_presentation_id = %v, want pres_abc", data["xml_presentation_id"])
}
if data["revision_id"] != float64(7) {
t.Fatalf("revision_id = %v, want 7", data["revision_id"])
}
if data["size"] != float64(len(xml)) {
t.Fatalf("size = %v, want %d", data["size"], len(xml))
}
gotPath, _ := data["path"].(string)
if !filepath.IsAbs(gotPath) {
t.Fatalf("path = %v, want absolute path", gotPath)
}
if !strings.HasSuffix(gotPath, "readback.xml") {
t.Fatalf("path = %v, want readback.xml suffix", gotPath)
}
}
func TestSlidesXMLGetResolvesWikiPresentation(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
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": "slides",
"obj_token": "pres_real",
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_real",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"xml_presentation": map[string]interface{}{
"content": `<presentation/>`,
},
},
},
})
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
"+xml-get",
"--presentation", "https://example.feishu.cn/wiki/wikcn123",
"--output", "wiki.xml",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeShortcutData(t, stdout)
if data["xml_presentation_id"] != "pres_real" {
t.Fatalf("xml_presentation_id = %v, want pres_real", data["xml_presentation_id"])
}
}
func TestSlidesXMLGetRejectsUnsafeOutputPath(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
"+xml-get",
"--presentation", "pres_abc",
"--output", "../readback.xml",
"--as", "user",
})
if err == nil {
t.Fatal("expected unsafe output path error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T %v", err, err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryValidation)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
}
if validationErr.Param != "--output" {
t.Fatalf("param = %q, want --output", validationErr.Param)
}
}

View File

@@ -25,7 +25,7 @@ metadata:
- 用户给出 doubao.com 的云空间资源 URL/token或明确提到豆包里的 file/folder/docx/sheet/bitable/wiki 资源时仍按资源类型、URL 路径和 token 路由到本 skill不要因为域名不是飞书而回退到 WebFetch。
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable第一步必须使用 `lark-cli drive +import --type bitable`
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`
- 用户要把本地 `.pptx` 导入成飞书幻灯片,使用 `lark-cli drive +import --type slides`;当前 PPTX 导入上限是 500MB。
- 用户要把本地 `.pptx` / `.pdf` 导入成飞书幻灯片,使用 `lark-cli drive +import --type slides`;当前 PPTX/PDF 导入上限是 500MB。
- 用户要在 Drive 里上传、创建、读取、局部 patch 或覆盖更新**原生 `.md` 文件**(不是导入成 docx切到 [`lark-markdown`](../lark-markdown/SKILL.md)。
- 用户要比较原生 `.md` 文件的**历史版本差异**,或比较远端 Markdown 与本地草稿,切到 [`lark-markdown`](../lark-markdown/SKILL.md) 的 `lark-cli markdown +diff`;需要版本号时先用 `drive +version-history`
- 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `drive +version-history``drive +version-get``drive +version-revert``drive +version-delete`;这组命令同时支持 `--as user``--as bot`,自动化场景优先 `--as bot`

View File

@@ -2,7 +2,7 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
将本地文件(如 Word、TXT、Markdown、Excel、PPTX 等导入并转换为飞书在线云文档docx、sheet、bitable、slides。底层统一通过 `POST /open-apis/drive/v1/import_tasks` 接口创建导入任务,并在 shortcut 内做有限次数轮询 `GET /open-apis/drive/v1/import_tasks/:ticket`
将本地文件(如 Word、TXT、Markdown、Excel、PPTX、PDF导入并转换为飞书在线云文档docx、sheet、bitable、slides。底层统一通过 `POST /open-apis/drive/v1/import_tasks` 接口创建导入任务,并在 shortcut 内做有限次数轮询 `GET /open-apis/drive/v1/import_tasks/:ticket`
> [!IMPORTANT]
> 当用户说“把本地 Excel / CSV / `.base` 快照导入成 Base / 多维表格 / bitable 文档”时,第一步必须使用 `drive +import --type bitable`。
@@ -45,8 +45,9 @@ lark-cli drive +import --file ./crm.xlsx --type bitable --name "客户台账"
# 导入 .base 快照为多维表格 / Base (bitable)(文件不能超过 20MB
lark-cli drive +import --file ./snapshot.base --type bitable --name "快照还原"
# 导入 PPTX 为飞书幻灯片 (slides)(文件不能超过 500MB
# 导入 PPTX / PDF 为飞书幻灯片 (slides)(文件不能超过 500MB
lark-cli drive +import --file ./deck.pptx --type slides --name "项目汇报"
lark-cli drive +import --file ./deck.pdf --type slides --name "项目汇报"
# 导入到指定文件夹,并指定导入后的文件名
lark-cli drive +import --file ./data.csv --type bitable --folder-token <FOLDER_TOKEN> --name "导入数据表"
@@ -94,6 +95,7 @@ lark-cli drive +import --file ./README.md --type docx --dry-run
| `.csv` | `sheet`, `bitable` | CSV 数据文件 |
| `.base` | `bitable` | 多维表格快照文件 |
| `.pptx` | `slides` | Microsoft PowerPoint 演示文稿 |
| `.pdf` | `slides` | PDF 文档 |
> [!IMPORTANT]
> 用户口头说的 “Base” / “多维表格” / “bitable”在命令里统一对应 `--type bitable`。
@@ -103,7 +105,7 @@ lark-cli drive +import --file ./README.md --type docx --dry-run
> - `.xlsx` / `.csv` 文件**只能**导入为 `sheet` 或 `bitable`
> - `.xls` 文件**只能**导入为 `sheet`
> - `.base` 文件**只能**导入为 `bitable`
> - `.pptx` 文件**只能**导入为 `slides`
> - `.pptx` / `.pdf` 文件**只能**导入为 `slides`
> - 例如:`.csv` 文件不能导入为 `docx``.md` 文件不能导入为 `sheet`
> [!IMPORTANT]
@@ -137,7 +139,7 @@ lark-cli drive +import --file ./README.md --type docx --dry-run
| `.csv` | `bitable` | 100MB |
| `.xls` | `sheet` | 20MB |
| `.base` | `bitable` | 20MB |
| `.pptx` | `slides` | 500MB |
| `.pptx`, `.pdf` | `slides` | 500MB |
- 如果文件超出对应上限shortcut 会在真正上传前直接返回验证错误。
- “超过 20MB 自动切换分片上传”只表示上传链路会切到 multipart不代表所有格式都允许导入超过 20MB 的文件。

View File

@@ -1,7 +1,7 @@
---
name: lark-slides
version: 1.0.0
description: "飞书幻灯片:创建和编辑幻灯片。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。不负责云文档内容编辑走 lark-doc、云文档里的独立画板对象走 lark-whiteboard注意 slide 内嵌的流程图/架构图仍属本 skill、上传或下载普通文件走 lark-drive。"
description: "飞书幻灯片:创建和编辑幻灯片。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用;当用户给定 PPTX/PDF/existing Slides 作为模板、底稿或二创对象时,也用本 skill 统筹导入后的二次创作(导入命令本身走 `lark-drive``drive +import --type slides`。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。不负责云文档内容编辑走 lark-doc、云文档里的独立画板对象走 lark-whiteboard注意 slide 内嵌的流程图/架构图仍属本 skill、上传或下载普通文件走 lark-drive。"
metadata:
requires:
bins: ["lark-cli"]
@@ -14,8 +14,8 @@ metadata:
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|----------|----------|-----------------|
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md``visual-planning.md``asset-planning.md``slides +create` |
| 大幅改写页面 | 先回读现有 XML写入新 plan再替换或重建相关页面 | `xml_presentations.get``+replace-slide``lark-slides-edit-workflows.md` |
| 新建 PPT、从空白生成、明确重设计 | 走 Create Workflow先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md``visual-planning.md``asset-planning.md``slides +create` |
| 用户提供 PPTX/PDF/slides、existing Slides、模板/底稿/原 PPT 二创 | 走 Template Rewrite Workflow导入/回读成 `source.xml`,从源 XML 生成 replacement slides`pages.json` 执行 `+replace-pages`,再保存 `readback.xml` 验证 | `template-rewrite-workflow.md``xml_presentations.get``lark-slides-replace-pages.md` |
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide``lark-slides-replace-slide.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` |
@@ -23,31 +23,37 @@ metadata:
| 在 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` |
| 用户提到模板、主题、版式 | 先检索模板,再摘要,必要时裁切骨架 | `template_tool.py search → summarize → extract` |
| 用户提到模板、主题、版式但没有提供本地/在线模板材料 | 先检索内置模板,再摘要,必要时裁切骨架 | `template_tool.py search → summarize → extract` |
| 创建失败、空白页、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 — 新建演示文稿或大幅改写页面时,MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。先创建对应目录规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**
**CRITICAL — Create Workflow新建演示文稿、从空白生成、用户明确要求重设计、没有模板保留诉求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` 实际改变页面几何、主视觉和文本量。**
**CRITICAL — Create Workflow 生成 XML 前 MUST 读取 [visual-planning.md](references/visual-planning.md),确保 `layout_type`、`visual_focus`、`text_density` 实际改变页面几何、主视觉和文本量。**
**CRITICAL — 新建演示文稿或大幅改写页面时,规划 `asset_need` MUST 遵循 [asset-planning.md](references/asset-planning.md):只做元数据规划,必须有 `fallback_if_missing`,不得要求真实搜索、下载或上传素材。**
**CRITICAL — Create Workflow 规划 `asset_need` MUST 遵循 [asset-planning.md](references/asset-planning.md):只做元数据规划,必须有 `fallback_if_missing`,不得要求真实搜索、下载或上传素材。**
**CRITICAL — 创建或大幅改写后MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)**
**CRITICAL — Template Rewrite Workflow用户提供 PPTX/PDF/slides、existing Slides、要求基于模板/底稿/原 PPT 二创或保留原版式MUST 读取 [template-rewrite-workflow.md](references/template-rewrite-workflow.md)。不要读取 `planning-layer.md`、`visual-planning.md`、`asset-planning.md` 来生成二创 plan不要生成 `slide_plan.json`、`page_rewrite_plan.json`、`rewrite_manifest.json`。固定数据流是 `source.xml -> pages.json -> slides +replace-pages -> readback.xml validation`**
**CRITICAL — Template Rewrite 不允许用 `python-pptx` / PowerPoint 自动化清空模板页后从 blank layout 重画,也不允许生成一个只继承模板尺寸/主题色的本地 PPTX 再导入作为最终产物。模板二创必须以 `source.xml` 的每页真实 XML 为骨架;如果 `source.xml` 不可得,停止并说明该工作流被阻塞,不能伪装成模板二创。**
**CRITICAL — Template Rewrite 必须做 source-connected rewrite新内容要进入源页已有 text container、图形标签、节点、箭头、时间线、图表/table 或注释容器。不要把模板当背景再覆盖通用顶栏、三卡片、2x2 卡片、大白卡或重复组件系统;源页 dominant structure 必须继续承载内容。**
**CRITICAL — 创建、Template Rewrite 或大幅改写后MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)。**
**CRITICAL — 创建前自检或失败排障时MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。**
**CRITICAL — 如果用户提到“模板”“套用模板”“参考某种主题/风格/版式”,或用户需求明显落在已有场景模板内(如工作汇报、产品介绍、商业计划书、培训、晋升汇报等)MUST 先用 [`scripts/template_tool.py`](scripts/template_tool.py) 的 `search` 做模板检索;默认给出 2-3 个最匹配模板候选供用户选择。锁定模板后用 `summarize` 获取主题和布局摘要;只有需要布局骨架时才用 `extract` 裁切目标页型 XML。不要直接读取完整模板 XML。**
**CRITICAL — 如果用户提到“模板”“套用模板”“参考某种主题/风格/版式”,但没有提供本地/在线模板材料MUST 先用 [`scripts/template_tool.py`](scripts/template_tool.py) 的 `search` 做内置模板检索;默认给出 2-3 个最匹配模板候选供用户选择。锁定模板后用 `summarize` 获取主题和布局摘要;只有需要布局骨架时才用 `extract` 裁切目标页型 XML。不要直接读取完整模板 XML。**
> [!NOTE]
> `scripts/template_tool.py` 需要 Python 3。`references/template-index.json` 是脚本缓存/轻量路由索引,不是默认给 agent 阅读的文档;`assets/templates/*.xml` 是机器资源,只应通过脚本摘要或裁切,不要全文读取。
**CRITICAL — 使用模板生成或改写页面时MUST 先 `summarize` 目标页型;只有需要具体布局骨架时才 `extract`。**
**CRITICAL — 使用内置模板生成或改写页面时MUST 先 `summarize` 目标页型;只有需要具体布局骨架时才 `extract`。用户提供真实 PPTX/PDF/slides/existing Slides 时不要走内置模板工具,走 Template Rewrite Workflow。**
**编辑已有幻灯片页面**优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序)选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。
**编辑已有幻灯片页面**模板二创、页面级重写、导入底稿改写优先用 [`+replace-pages`](references/lark-slides-replace-pages.md) 在原 presentation 内重建页面,避免 `slides +create` 生成新链接;单个标题、文本块、图片或局部元素才用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序)选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。
## 身份选择
@@ -74,24 +80,77 @@ lark-cli auth login --domain slides
高频只读:
- [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md)
- [planning-layer.md](references/planning-layer.md)新建 / 大幅改写
- [visual-planning.md](references/visual-planning.md)新建 / 大幅改写
- [asset-planning.md](references/asset-planning.md)新建 / 大幅改写
- [validation-checklist.md](references/validation-checklist.md)(创建 / 大幅改写后
- [planning-layer.md](references/planning-layer.md)Create Workflow新建 / 从空白生成 / 明确重设计
- [visual-planning.md](references/visual-planning.md)Create Workflow
- [asset-planning.md](references/asset-planning.md)Create Workflow
- [template-rewrite-workflow.md](references/template-rewrite-workflow.md)Template Rewrite WorkflowPPTX/PDF/slides/existing Slides 二创
- [validation-checklist.md](references/validation-checklist.md)(创建 / Template Rewrite / 大幅改写后)
按需再读:
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)
- 编辑:[`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)
- 模板[`template-catalog.md`](references/template-catalog.md)、[`scripts/template_tool.py`](scripts/template_tool.py)
- 内置模板(仅无用户材料时)[`template-catalog.md`](references/template-catalog.md)、[`scripts/template_tool.py`](scripts/template_tool.py)
- 排障:[`troubleshooting.md`](references/troubleshooting.md)
- 完整协议:[`slides_xml_schema_definition.xml`](references/slides_xml_schema_definition.xml)
## Workflow
## Workflow Routing
### A. Create Workflow
适用:
- 新建 PPT。
- 从空白生成。
- 用户明确要求重新设计。
- 没有模板保留诉求。
路由:
- 读取 `planning-layer.md`
- 可读取 `visual-planning.md`
- 可读取 `asset-planning.md`
- 生成 `slide_plan.json`
- 使用 `slides +create` 或对应创建流程。
### B. Template Rewrite Workflow
适用:
- 用户上传 PPTX / PDF / slides。
- 用户给 existing Slides。
- 用户说“基于这个模板”。
- 用户说“保留原版式”。
- 用户说“根据这个底稿生成”。
- 用户说“二次创作 / 改写这个 PPT”。
路由:
- 读取 `template-rewrite-workflow.md`
- 不读取 `planning-layer.md`
- 不读取 `visual-planning.md`
- 不读取 `asset-planning.md`
- 不生成 `slide_plan.json`
- 不生成任何 rewrite plan / manifest。
- 固定执行import/readback -> `source.xml` -> `pages.json` -> `replace-pages` -> readback validation。
- 不用 `python-pptx` 清空模板页、`add_slide(blank)` 重画,或导入新生成的本地 PPTX 作为最终产物。
- 逐页从源页骨架向外改写,把新内容贴回源页已有容器和视觉节点;不能用一套通用卡片层覆盖模板结构。
### C. Switch Back To Create Workflow
只有用户明确表达以下意图,才允许从 Template Rewrite 切回 Create Workflow
- “不要保留模板素材”
- “只参考风格重做”
- “重新设计整套 PPT”
- “原模板只是灵感”
- “完全换一种版式”
## Create Workflow
> **这是演示文稿,不是文档。** 每页 slide 是独立的视觉画面,信息密度要低,排版要留白。
@@ -99,7 +158,7 @@ lark-cli auth login --domain slides
不要生成无设计感的幻灯片。纯白背景 + 标题 + bullets 只能作为极简临时稿,不能作为正式交付。
开始写 XML 前,先在 `slide_plan.json` 里确定 deck 级视觉策略:
以下设计规划只适用于 Create Workflow。开始写 XML 前,先在 `slide_plan.json` 里确定 deck 级视觉策略:
- **主题化配色**:配色必须服务本次主题、行业和受众,不要默认蓝色商务风。如果把同一套颜色换到另一个完全不同主题仍然成立,说明配色不够具体。
- **主次比例**:选择 1 个主色承担约 60-70% 视觉权重1-2 个辅助色承担结构和分区1 个强调色只用于关键数字、结论或行动点。不要让所有颜色权重相同。
@@ -133,7 +192,7 @@ lark-cli auth login --domain slides
- 不要把素材缺失表现为空白图片框;必须按 `fallback_if_missing` 生成 XML-native 视觉。
- 不要留下模板占位文案、示例公司名、示例日期或与用户主题无关的原模板内容。
### 创建方式选择
### Create Workflow 创建方式选择
| 场景 | 推荐方式 |
|------|----------|
@@ -147,9 +206,9 @@ lark-cli auth login --domain slides
> [!IMPORTANT]
> `slides +create --slides` 底层会逐页创建,不是原子操作。中途失败时先记录 `xml_presentation_id`,回读确认当前状态,再继续修复或追加。
### 模板与脚本优先流程
### 内置模板与脚本优先流程
模板细则见 [template-catalog.md](references/template-catalog.md)。主流程只记住:先 `search`,锁定后 `summarize`,需要骨架时才 `extract`;不要直接读取完整模板 XML 或照搬占位文案。
仅在用户没有提供本地/在线模板材料时使用内置模板流程。模板细则见 [template-catalog.md](references/template-catalog.md)。主流程只记住:先 `search`,锁定后 `summarize`,需要骨架时才 `extract`;不要直接读取完整模板 XML 或照搬占位文案。
```bash
python3 skills/lark-slides/scripts/template_tool.py search --query "<用户需求原文>" --limit 3
@@ -159,12 +218,12 @@ python3 skills/lark-slides/scripts/template_tool.py extract --template <template
```text
Step 1: 需求澄清 & 读取知识
- 澄清主题、受众、页数、风格;模板需求按“模板与脚本优先流程”处理
- 读取 xml-schema-quick-ref.md新建 / 大幅改写时还要读取 planning-layer.md、visual-planning.md、asset-planning.md
- 澄清主题、受众、页数、风格;没有用户提供模板材料时,模板需求按“内置模板与脚本优先流程”处理
- 读取 xml-schema-quick-ref.mdCreate Workflow 还要读取 planning-layer.md、visual-planning.md、asset-planning.md
Step 2: 生成大纲 → 用户确认 → 写入 slide_plan.json
- 生成结构化大纲供用户确认;如使用模板,标明基于哪个模板改写
- 新建 / 大幅改写必须先创建目录并写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
- 生成结构化大纲供用户确认;如使用内置模板,标明基于哪个模板改写
- Create Workflow 必须先创建目录并写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
- plan 字段、路径命名、模板边界和 `asset_need` 结构按 planning-layer.md / asset-planning.md 执行
Step 3: 按 slide_plan.json 生成 XML → 创建
@@ -174,7 +233,7 @@ Step 3: 按 slide_plan.json 生成 XML → 创建
Step 4: 审查 & 交付
- 创建完成后,必须用 xml_presentations.get 读取全文 XML并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
- 失败或部分成功按 troubleshooting.md 处理;局部问题优先用 `+replace-slide` 修正
- 失败或部分成功按 troubleshooting.md 处理;局部问题用 `+replace-slide` 修正,页面级问题用对应页面流程修正
- 没问题 → 交付:告知用户演示文稿 ID 和访问方式
```
@@ -268,6 +327,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`
| [`+create`](references/lark-slides-create.md) | 创建 PPT可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |
| [`+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) | 在原演示文稿内批量重建一个或多个页面:先创建新页到旧页前,再删除旧页;适合模板二创、页面级重写和素材保留,不新建链接 |
没有 Shortcut 覆盖时使用原生 API。高频资源`xml_presentations.get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。
@@ -280,13 +340,17 @@ lark-cli slides <resource> <method> [flags] # 调用 API
## 核心规则
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;模板、风格和大纲只能作为规划输入,不能绕过规划层
2. **创建流程**:简单短 XML1-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 内
5. **保存关键 ID**:后续操作需要 `xml_presentation_id``slide_id``revision_id`
6. **删除谨慎**删除操作不可逆,且至少保留一页幻灯片
7. **编辑已有页面优先块级替换**:修改单个 shape/img 用 `+replace-slide``block_replace` / `block_insert`),不要整页重建;只有需要替换整页结构时才用 `slide.delete` + `slide.create`
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides``@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**slides upload API 不支持分片上传)。
1. **先判定工作流**:新建/空白生成/明确重设计走 Create WorkflowPPTX/PDF/slides/existing Slides 模板二创走 Template Rewrite Workflow。不要把二创塞回 `slide_plan.json` 工作流。
2. **Create Workflow 先规划再写 XML**:必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;内置模板、风格和大纲只能作为规划输入,不能绕过规划层。
3. **Template Rewrite 不生成 plan artifact**:唯一事实源是 `source.xml`,唯一执行输入是 `pages.json`,替换后用 `readback.xml` 验证。默认 preserve source, replace content, local adjustment only。
4. **Template Rewrite 不能本地清空重画**禁止`python-pptx` 删除模板页、从 blank layout 重建、只借用尺寸/主题色后生成本地 PPTX 再导入。`source.xml` 不可得时停止,不要伪装成模板二创。
5. **Template Rewrite 不能通用卡片覆盖模板**:新内容必须映射到源页已有文本框、图形标签、节点、箭头、时间线、图表/table 或注释容器。源页 dominant structure 仍要承担表达,不允许只把它留在背景里。
6. **创建流程**简单短 XML1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT再用 `xml_presentation.slide.create` 逐页添加
7. **`<slide>` 直接子元素只有 `<style>``<data>``<note>`**:文本和图形必须放在 `<data>`
8. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
9. **保存关键 ID**:后续操作需要 `xml_presentation_id``slide_id``revision_id`
10. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片
11. **编辑已有页面优先原链接更新**:模板二创、页面级重写、素材保留用 `+replace-pages`;修改单个 shape/img/text block 才用 `+replace-slide``block_replace` / `block_insert`)。不要用 `slides +create` 新建脱离模板的 deck。
12. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides``@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果 `file_token` 来自同一个 `xml_presentation_id` 的旧页,可以在 Template Rewrite 的新页 XML 中直接复用;如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**slides upload API 不支持分片上传)。
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。

View File

@@ -1,9 +1,11 @@
# Asset Planning
新建演示文稿大幅改写页面时,在写入 `slide_plan.json` 前后都可以参考本文件。目标是让 agent 主动识别有价值的图、图标、图表、流程图、时序图、架构图、装饰图案、截图或示意图需求,同时保持 deck 在没有真实素材时也能完整执行。
本文件默认只供 Create Workflow / `planning-layer.md` 使用。新建演示文稿、从零大幅改写或用户明确要求重设计时,在写入 `slide_plan.json` 前后都可以参考本文件。目标是让 agent 主动识别有价值的图、图标、图表、流程图、时序图、架构图、装饰图案、截图或示意图需求,同时保持 deck 在没有真实素材时也能完整执行。
本文件只定义轻量资产规划。不要把它理解成素材采集流程。
模板二创不要为了 `asset_need` 重新规划或替换旧素材。模板二创中的旧素材、旧容器、旧样式以 `source.xml` 为准,由 `template-rewrite-workflow.md` 处理。
## 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.
@@ -12,6 +14,8 @@
- 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.
- Do not leave blank image boxes in final XML. If the asset is missing, render the fallback visual.
- In Template Rewrite Workflow, `fallback_if_missing` cannot cover, replace, or obscure existing source img/table/chart/whiteboard/shape/motif/text containers.
- In Template Rewrite Workflow, reference fallback ideas only when the source page has no usable carrying area and a new visual element is truly required.
## JSON Shape

View File

@@ -1,6 +1,8 @@
# 编辑已有 PPT读-改-写闭环
编辑走 **shortcut [`+replace-slide`](lark-slides-replace-slide.md)**(块级替换 / 插入),配合 `xml_presentation.slide.get` 读原页拿 `block_id`
局部编辑走 **shortcut [`+replace-slide`](lark-slides-replace-slide.md)**(块级替换 / 插入),配合 `xml_presentation.slide.get` 读原页拿 `block_id`已有 Slides 的多页整页重建走 **[`+replace-pages`](lark-slides-replace-pages.md)**,保持原 presentation 链接不变。
模板/底稿/PPTX/PDF/existing Slides 二创必须先读 [`template-rewrite-workflow.md`](template-rewrite-workflow.md):以 `source.xml` 为源页骨架生成 replacement slide不允许用 `python-pptx` 清空模板页、从 blank layout 重画、再导入新本地 PPTX 作为最终产物;也不允许把模板当背景后覆盖一套通用卡片层。
> 生成 XML 前**必读** [xml-schema-quick-ref.md](xml-schema-quick-ref.md)。
@@ -11,6 +13,7 @@
| 已知某块的 `block_id`,要换这块内容(改标题、换图、挪坐标) | `block_replace` | 精准替换,原子性好;`replacement``id` 由 CLI 自动注入为 `block_id` |
| 只加 1~N 个元素、不动现有布局 | `block_insert` | 新增不覆盖,可选 `insert_before_block_id` 指定位置 |
| 一次动多个元素(如:换标题 + 加图) | 单次 `--parts` 里拼多条 | 整批作为原子事务,任一失败整批不生效;`block_replace``block_insert` 可混用 |
| 模板二创、页面级改写、整页坐标调整 | `+replace-pages` | 原 presentation 内批量 create-before/delete-old模板场景必须从源页 XML 骨架向外改,把新内容贴回源页已有容器、节点、箭头、时间线、图表/table 或注释,不生成新 Slides 链接 |
> **没有字段级 patch**:即便只想改一个 `shape` 的 `topLeftX`,也得把整个块的新 XML 写出来用 `block_replace`。这不是"微调",是块级重写。
@@ -136,6 +139,7 @@ cat parts.json | lark-cli slides +replace-slide --as user --presentation "$PID"
## 相关文档
- [lark-slides-replace-slide.md](lark-slides-replace-slide.md) — +replace-slide shortcut 参数详情
- [lark-slides-replace-pages.md](lark-slides-replace-pages.md) — 多页整页重建 shortcut
- [lark-slides-xml-presentation-slide-get.md](lark-slides-xml-presentation-slide-get.md) — slide.get 参考(拿 `block_id` / `revision_id`
- [lark-slides-xml-presentation-slide-replace.md](lark-slides-xml-presentation-slide-replace.md) — 底层 replace API 参考(一般直接用 shortcut 即可)
- [lark-slides-media-upload.md](lark-slides-media-upload.md) — 上传图片拿 file_token

View File

@@ -0,0 +1,100 @@
# slides +replace-pages多页整页重建
批量替换已有演示文稿里的多个页面,保持原 `xml_presentation_id` 和原 Slides 链接不变。适合模板二创、页面级改写、坐标调整和素材保留;单个文本框、图片或 shape 的局部编辑仍优先用 [`+replace-slide`](lark-slides-replace-slide.md)。
> 重要这是多步编排不是后端原子事务。CLI 对每页执行“先创建新页到旧页前,再删除旧页”;创建失败时旧页会保留。删除失败时可能出现新旧页同时存在,需要按返回结果继续处理。
> 模板二创重要边界:`+replace-pages` 消费完整 replacement slide XML但这个 XML 应以 `source.xml` 的源页结构为骨架。不要用 `python-pptx` 清空模板页、从 blank layout 重画、生成新本地 PPTX 再导入来替代本命令;也不要把模板当背景,再覆盖一套通用卡片系统。
## 命令
```bash
lark-cli slides +replace-pages \
--as user \
--presentation <slides_url_or_xml_presentation_id> \
--pages @pages.json
```
## 参数
| 参数 | 必需 | 说明 |
|------|------|------|
| `--presentation` | 是 | `xml_presentation_id``/slides/` URL 或 `/wiki/` URL |
| `--pages` | 是 | JSON 数组,每项包含 `slide_id``content`;支持 literal、`@file`、stdin `-` |
| `--dry-run` | 否 | 基于 `slide_id` 输入输出替换计划,不执行 create/delete |
| `--continue-on-error` | 否 | 默认失败即停;开启后继续处理后续页,并在结果中标记失败项 |
| `--validate-only` | 否 | 只校验输入并生成替换计划,不执行 Slides get/create/delete |
## pages.json
```json
[
{
"slide_id": "slide_short_id_1",
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"
},
{
"slide_id": "slide_short_id_2",
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"
}
]
```
规则:
- 每项必须提供 `slide_id`;不支持 `slide_number`
- `content` 必须是完整 `<slide>...</slide>` XML。
- 模板二创时,`content` 应复用源页 `<style>``<img src>`、chart/table/whiteboard、shape、line/icon、文本容器等结构只替换必要文本或局部元素。
- 模板二创时,新内容应贴回源页已有 text container、图形标签、节点、箭头、时间线、chart/table 或注释容器;源页的 dominant structure 不能只留作背景装饰。
- 同一批次不能重复 `slide_id`
- CLI 不会回读整份 presentation如果 `slide_id` 已失效create/delete 阶段会返回对应错误。
## Dry Run
```bash
lark-cli slides +replace-pages --as user \
--presentation "$PID" \
--pages @pages.json \
--dry-run
```
输出包含 `xml_presentation_id``pages_count``plan`,以及每页的 `old_slide_id``insert_before_slide_id` 和动作 `create_before_then_delete_old`。Dry-run 只基于输入的 `slide_id` 构造计划,不会调用 `xml_presentations.get`,也不会执行 create/delete。
## 成功输出
```json
{
"xml_presentation_id": "xxx",
"pages_count": 2,
"status": "completed",
"summary": {
"replaced": 2,
"failed": 0,
"total": 2
},
"results": [
{
"old_slide_id": "old3",
"new_slide_id": "new3",
"status": "replaced"
}
],
"revision_id": 123
}
```
如果使用 `--continue-on-error` 且任一页面失败CLI 会继续处理后续页,但最终以 partial failure 非零退出stdout 仍保留完整 `results`,顶层 `ok``false``status``partial_failure`
`status` 可能为:
- `replaced`:新页创建成功,旧页删除成功。
- `create_failed`:新页创建失败,旧页保留。
- `delete_failed`:新页已创建,但旧页删除失败。
## 使用建议
1. 大幅改写前先 `xml_presentations.get` 保存当前 XML并记录要替换页面的 `slide_id`
2. 生成只含 `slide_id``pages.json` 后先跑 `--dry-run``--validate-only`
3. 默认不要开 `--continue-on-error`,除非能接受部分页面已替换。
4. 模板二创不要把源页改成通用两卡、三卡、2x2 卡片,也不要用大白卡或大色块覆盖模板主体素材。源页如果有箭头、节点、时间线、图表、表格、几何结构、设备图或人物图,优先替换这些结构上的标签、数字和注释。
5. 替换后再回读全文 XML并按 `validation-checklist.md` 对比 `source.xml``readback.xml`;需要确认视觉一致性时,用 `slides +screenshot` 抽查封面、典型内容页、复杂结构页和结尾页。

View File

@@ -4,7 +4,7 @@
获取幻灯片页面截图并保存为本地图片文件。默认用于已存在 PPT 页面截图;传入 `--content` 时用于直接渲染单个 `<slide>` XML 片段预览。本 shortcut 会在 CLI 进程内解码并写入文件stdout 只返回文件路径、大小、页面 ID 等元信息,避免把图片 Base64 输出给模型。
注意:该截图能力对应的权限受白名单控制。只有在白名单内的应用才能申请该权限;不在白名单内的应用即使命令和参数正确,服务端仍可能返回权限或能力不可用相关错误
注意:该截图能力受应用白名单限制,绝大多数应用不可用。截图失败时不要引导用户申请 `slides:presentation:screenshot` 权限;记录错误后降级到 XML 读回、结构 lint、文本重叠检查等非截图检查路径
## 命令

View File

@@ -1,13 +1,15 @@
# Planning Layer
新建演示文稿大幅改写页面时,必须先写 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。这个文件是 deck 的设计中间层,用来把叙事、页面角色、布局、视觉重点和文字密度固定下来,避免从用户提示直接跳到 XML。
本文件只适用于 Create Workflow新建演示文稿、从零大幅改写、用户明确要求重设计,或没有模板保留诉求的场景。进入本工作流时,必须先写 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。这个文件是 deck 的设计中间层,用来把叙事、页面角色、布局、视觉重点和文字密度固定下来,避免从用户提示直接跳到 XML。
小型已有页编辑可豁免,例如只替换一个标题、改一个数字、插入一个块、上传并插入一张图。只要任务会重排多页、生成新 deck、替换整页结构仍然需要规划层
如果用户提供 PPTX/PDF/slides 作为模板、底稿或二创对象,或要求保留原版式/素材/结构,请走 `template-rewrite-workflow.md`。不要在 `slide_plan.json` 工作流中处理模板二创
小型已有页编辑可豁免,例如只替换一个标题、改一个数字、插入一个块、上传并插入一张图。模板二创也不使用本规划层;它以 `source.xml` 为事实源、`pages.json` 为执行输入。
## Required Flow
1. 理解用户需求,必要时澄清主题、受众、页数、风格。
2. 如果适合模板,先用 `template_tool.py search` 检索,锁定模板后用 `summarize` 获取主题和页型信息。
2. 如果没有用户提供本地/在线模板材料且适合内置模板,先用 `template_tool.py search` 检索,锁定模板后用 `summarize` 获取主题和页型信息。
3. 选择唯一 plan 目录:`.lark-slides/plan/<deck-or-task-id>/`
4. 先创建目录:`mkdir -p .lark-slides/plan/<deck-or-task-id>`
5. 写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
@@ -15,7 +17,9 @@
7. 按 plan、visual planning 和 asset planning 规则逐页生成 XML`layout_type``visual_focus``text_density` 转成具体页面几何和文本量约束,并把缺失素材转成可执行兜底视觉。
8. 创建 PPT 后用 `xml_presentations.get` 回读,核对页面数量、关键元素和 plan 到 XML 的对应关系。
模板不能代替 plan。模板搜索和摘要只能影响 `theme_style`、页面流、布局选择和局部布局骨架;最终仍必须有 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
内置模板不能代替 plan。模板搜索和摘要只能影响 `theme_style`、页面流、布局选择和局部布局骨架;最终仍必须有 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
如果用户提供 PPTX/PDF/slides 作为模板、底稿或二创对象,请走 `references/template-rewrite-workflow.md`。不要在本工作流中复制 `source.xml` 的素材清单、bbox、层级或样式也不要生成 `page_rewrite_plan.json` / `rewrite_manifest.json`
## Plan Path
@@ -24,7 +28,7 @@ Use a separate plan directory per deck or task so multiple presentations in the
Recommended IDs:
- New deck before creation: title slug plus date/time, such as `q3-review-20260507-1805`.
- Existing PPT rewrite: the `xml_presentation_id`.
- Existing PPT redesign after the user explicitly abandons template preservation: the `xml_presentation_id` plus a short redesign slug.
- Ambiguous or untitled task: short task slug plus date/time.
Rules:
@@ -40,7 +44,7 @@ Rules:
Keep:
- `.lark-slides/plan/<deck-or-task-id>/slide_plan.json` after successful creation or major rewrite. The plan is the editable design state for the deck.
- A small manifest when useful for follow-up work, such as `xml_presentation_id`, slide IDs, `revision_id`, plan path, and verification status.
- A small creation status note when useful for follow-up work, such as `xml_presentation_id`, slide IDs, `revision_id`, plan path, and verification status. Do not create a rewrite manifest for Template Rewrite Workflow.
Clean or avoid keeping:

View File

@@ -150,7 +150,7 @@
<xs:simpleType name="FontFamilyType">
<xs:annotation>
<xs:documentation>
字体族名称, 支持任意字体。
字体族名称, 支持下列字体。
常用中文字体:
思源宋体、寒蝉德黑体、标小智无界黑、寒蝉锦书宋、站酷小薇体、

View File

@@ -0,0 +1,218 @@
# Template Rewrite Workflow
本工作流只服务基于真实模板或底稿的二次创作。适用场景:
- 用户上传 PPTX / PDF / slides。
- 用户给 existing Slides。
- 用户说“基于这个模板生成”。
- 用户说“保留这个版式 / 底稿 / 原 PPT / 模板风格和结构”。
- 用户要对已有 PPT 做二次创作、改写、替换内容。
禁止默认行为:
- 不默认走 `planning-layer.md`
- 不默认读取 `visual-planning.md`
- 不默认读取 `asset-planning.md`
- 不生成 `slide_plan.json`
- 不生成 `page_rewrite_plan.json`
- 不生成 `rewrite_manifest.json`
- 不默认用 `slides +create` 新建脱离模板的 deck。
- 不通过 `python-pptx` / PowerPoint 自动化把模板页全部删除后从空白 layout 重画。
- 不生成一个“借用模板尺寸/主题色”的本地 PPTX 再导入,并声称它是模板二创。
- 不把模板页当成背景板再在上面覆盖一套通用标题栏、两卡、三卡、2x2 卡片或大白卡系统。
- 不把每页内容粘贴进一套重复组件,而让源页的箭头、节点、时间线、图形、图表、留白关系失去表达作用。
模板二创的数据流固定为:
```text
source.xml
-> generate replacement slide XML from each source slide skeleton
-> pages.json
-> slides +replace-pages
-> readback.xml validation
```
## 1. Import / Readback
- PPTX 必须先导入为 Slides。导入命令本身走 `lark-drive``drive +import --type slides`,但导入后的二创由本工作流负责。
- PDF 如果作为模板、底稿、原 PPT 或视觉参考使用,也先导入为 Slides只有明显是长文档资料而非演示稿时才不进入本工作流。
- Existing Slides 用 `xml_presentations.get` 回读。
- 保存回读 XML 为:
```text
.lark-slides/plan/<xml_presentation_id>/source.xml
```
如果无法得到 `source.xml`Template Rewrite Workflow 不能继续。不要退化为:
-`python-pptx` 打开模板后删除所有原 slides。
- `prs.part.drop_rel(...)` / `del prs.slides._sldIdLst[...]` 清空模板页。
-`prs.slide_layouts[...]` 或 blank layout 重新 `add_slide(...)`
- 只保留画布尺寸、少量主题色、少量母版占位符后重画整套内容页。
- 输出一个新的本地 PPTX再用 `drive +import --type slides` 当最终产物。
正确处理是:停止 Template Rewrite说明 `source.xml` 不可用,并让用户选择导入失败排障、只交付原导入 deck或明确切换到 Create Workflow / 只参考风格重做。
## 2. Treat source.xml As Truth
`source.xml` 是唯一布局和素材事实源。
- 不要把 `source.xml` 里的素材 token、bbox、层级、样式再复制到新的 plan 文件。
- 不要让模型手写素材清单来替代 `source.xml`
- 所有保留判断以 `source.xml` 为准。
- 不要用 `layout_type``visual_focus``visual_system` 来驱动模板二创。
- 可以在上下文中临时分析每页的源结构,但不要把它保存成新的 JSON / Markdown plan artifact。
## 3. Rewrite From Source Outward
以源页 XML 为骨架生成 replacement slide。默认顺序
1. 先复制源页的 `<style>`
2. 复制源页的 `<img src="...">``<chart>``<table>``<whiteboard>`
3. 复制 recurring shapes / motifs、line / icon / separator、card container、reusable text container。
4. 识别源页中承载表达的 dominant structure例如箭头流、节点关系、时间线、漏斗、三角形、圆环、曲线、坐标/表格、左右对照、设备图、人物/场景分组。
5. 把新内容映射到源页已有文本容器、图形标签、数字标签、节点标签或图表/table 数据上。
6. 替换旧文案所在文本容器里的 `<content>`
7. 最后只在必要时添加局部新元素。
不要把源页改成通用两卡、三卡、2x2 卡片。不要把“保留模板”简化成“保留背景图 + 重新画业务卡片”。
生成 replacement slide 时,页面级结构必须来自源页 XML。可以替换或缩短文字、更新图表数据、局部补充元素不能把源页删除后按自定义 `rect()` / `circle()` / `line()` / `add_text()` helper 重新搭一套卡片、流程、指标版式。
### Source-Connected Rewrite
每一页必须先在工作上下文中做源页结构判断。这个判断不是新 artifact不写入文件它只用于约束生成
- page role封面、目录、过渡、数据页、流程页、对比页、总结页等。
- dominant source structure源页最主要的视觉结构例如图、表、箭头、节点、时间线、几何结构、产品图、人物图、设备图、曲线或对比版式。
- content-bearing containers真正承载文字和数字的源文本框、图形标签、图表标签、表格单元格。
- source visual hierarchy标题、核心结论、主视觉、支撑信息、脚注的原始层级。
- safe insertion zones只有在源页没有合适容器且用户内容必须出现时才可使用的局部空白区域。
生成 replacement slide 时必须满足:
1. 新文案优先进入已有 text container、图形标签、节点标签、数字标签、表格单元格或 chart labels / data。
2. 如果源页有三角形、箭头、节点、时间线、曲线、地图、设备图、产品图或人物分组,新内容必须贴到这些源结构的对应标签/节点/注释上,而不是覆盖一组三张新卡片。
3. 如果源页是数据图形页,优先更新原图表、数字标签、曲线节点、坐标标签和注释;不要另造一个白色数据卡片区遮住原图。
4. 如果源页是流程/关系页,优先替换每个步骤、箭头、节点、关系说明;不要把流程压在背景里,再另起 bullet 卡片。
5. 如果源页是封面或章节页保留原图片、标题容器、logo / slogan / 装饰关系;不要把标题挪进不相干的新色块。
6. 如果原文本容器空间不足,先缩短文案、降低层级、拆到邻近源容器或用源页已有注释容器承载;不要默认新增大卡片。
7. 新增元素只能补足源结构的局部空缺,不能成为覆盖源结构的主版式。
8. 多页之间应保留源模板原本的页型差异;不要把整套 deck 归一成同一套顶栏 + 三卡片。
### Source-Connectedness Gate
生成 `pages.json` 前,对每个 replacement slide 做一次失败门检查。出现以下任一情况,必须重写该页:
- 页面主体内容主要落在新增 shape/card 中,而不是源页已有容器或源结构节点里。
- 源页的箭头、节点、时间线、图形、图表、设备图、人物图仍在,但已经只是背景装饰,没有承载新内容。
- 新增卡片、白板、大色块或信息面板覆盖了源页 dominant structure。
- 多个源页被改成同一套顶栏、三卡、2x2 卡片或大段 bullet 容器。
- 页内关键源容器还在,但其 bbox、层级、字号、颜色、对齐关系被无理由改写。
- 源页明明有图文关系、箭头关系或坐标关系,却把内容独立堆放到空白区域,导致互相错位或遮挡。
## 4. Generate pages.json
`pages.json` 是唯一执行输入。结构只保留 `slides +replace-pages` 需要的字段:
```json
[
{
"slide_id": "<old slide id>",
"content": "<full replacement slide XML>"
}
]
```
不要把 planning metadata 放进 `pages.json`
## 5. Execute replace-pages
- 默认用 `slides +replace-pages`
- `replace-pages` 消费 `pages.json`,不消费 `slide_plan.json`
- `replace-slide` 只用于小型块级编辑,例如改一个标题、插入一个图、替换已知 block。
## 6. Readback Validation
替换后必须用 `xml_presentations.get` 回读,保存为:
```text
.lark-slides/plan/<xml_presentation_id>/readback.xml
```
`readback.xml``source.xml` 对比验证模板结构没有被破坏。验证细则见 `validation-checklist.md` 的 Template Rewrite validation 小节。
## Preservation Rules
除非用户明确要求重设计,否则模板二创必须:
- preserve source layout
- preserve source assets
- preserve source style
- preserve source text containers
- preserve source visual hierarchy
- replace content only
- local adjustment only
具体规则:
1. `<style>` 默认保留。
2. `<img src="...">` 默认保留尤其是背景图、截图、装饰图、产品图、logo、模板视觉。
3. 同一个 `xml_presentation_id` 内复用 `<img src>` 时,直接复制原 `src` / token不要重新上传不要替换成外部 URL。
4. `<chart>` / `<table>` 默认保留;除非用户要求更新数据,才改 labels / data。
5. `<whiteboard>` 默认保留其位置和外层结构;注意 readback XML 未必包含内部 SVG / Mermaid。
6. shape / line / icon / separator / card container / motif 默认保留。
7. 旧文案所在 text container 默认保留 bbox、layer、textType、fontFamily、fontSize、color、alignment只替换 `<content>`
8. 如果源页已有卡片容器,优先复用源容器。
9. 如果源页已有图文结构,优先替换原文本。
10. 如果必须新增元素,新增元素必须局部且不破坏源页主要视觉结构。
11. 不允许以“模板文件只是内容容器”为由清空原页;模板页本身就是必须保留的设计资产。
12. 不允许把模板当成 wallpaper。源页的 dominant structure 必须继续承载内容和语义。
## Local PPTX Is Not A Rewrite Target
Template Rewrite 的写入目标是导入后或已有的 Slides presentation。默认最终写入动作是 `slides +replace-pages`,不是创建一个新的本地 PPTX。
禁止的本地 PPTX 生成模式:
```python
while len(prs.slides._sldIdLst):
r_id = prs.slides._sldIdLst[0].rId
prs.part.drop_rel(r_id)
del prs.slides._sldIdLst[0]
blank = prs.slide_layouts[...]
slide = prs.slides.add_slide(blank)
```
上面这种模式会删除背景图、截图、装饰图、产品图、logo、shape、文本框、层级和页内结构。它最多是 Create Workflow 的“新建 PPT”不是模板二创。
## No Full-Page Wash / Mask
禁止默认添加:
- full-page wash
- near-full-page overlay
- 全页半透明白色蒙版
- 全页半透明黑色蒙版
- 覆盖页面主体区域的大矩形
- `rgba(255,255,255,0.x)` 大面积遮罩
- `rgba(0,0,0,0.x)` 大面积遮罩
原因:模板二创时,模板素材是优先保留对象。全页 wash 会视觉遮盖模板素材,即使 token 仍然存在,也等同于破坏模板。
允许的可读性增强仅包括:
- 局部 text backing
- 局部 card backing
- 调整文字颜色
- 调整字重
- 文字阴影
- 缩短文案
- 复用源页已有文本容器
- 复制 `source.xml` 中原本存在的 overlay
如果新增 overlay 覆盖了大部分画布,应判定为失败,除非:
- 该 overlay 来自 `source.xml` 原有元素;或
- 用户明确要求统一加蒙版 / 遮罩。

View File

@@ -23,6 +23,37 @@ lark-cli slides xml_presentations get --as user \
--params '{"xml_presentation_id":"YOUR_ID"}'
```
## Template Rewrite Validation
模板二创用 `slides +replace-pages` 后必须回读全文 XML并保存
```text
.lark-slides/plan/<xml_presentation_id>/readback.xml
```
同时对比同目录的 `source.xml`。通过标准:
1. `readback.xml` 中仍存在 `source.xml` 的关键 `<img src>` token。
2. `readback.xml` 中仍存在关键 `<style>`、chart、table、whiteboard、shape motif、card container。
3. 旧 text container 的 bbox、layer、font、color、alignment 没有无理由变化。
4. 没有新增 full-page / near-full-page overlay、wash、mask。
5. 没有把多页改成同质化两卡、三卡、2x2 卡片。
6. 没有用大白卡、大色块覆盖模板主体素材。
7. 没有把 source img 重新上传或替换成外部 URL。
8. `pages.json` item 只包含 `slide_id` / `content`
9. `replace-pages` 使用 create-before-delete 语义时,确认最终页数正确。
10. 如果发现模板素材 token 存在但被新增遮罩视觉遮盖,应判定为失败。
11. 没有出现 `python-pptx` 清空模板页、blank layout 重建、本地生成 PPTX 再导入的路径。
12. 源模板的媒体资产数量、关键图片 token、主要 shape/table/chart/whiteboard/text container 没有断崖式丢失。若原模板有大量媒体资产而结果只剩极少数媒体资产,应判定为失败,除非用户明确要求移除这些素材。
13. 每页的 dominant source structure 仍然存在并承载内容,例如三角形、箭头、节点、时间线、曲线、图表、表格、设备图、人物图、产品图或左右对照结构没有退化成背景装饰。
14. 新内容主要落在源页已有 text container、图形标签、节点标签、数字标签、chart/table 数据或源页注释容器里,而不是新增的通用卡片层里。
15. 没有把多页改成同一套“顶栏 + 三卡片 / 2x2 卡片 / 大 bullet 面板”的重复版式。
16. 源页有箭头流、节点关系、时间线、图形结构或坐标关系时,新文案与这些结构对齐,没有漂浮在不相关空白区域或互相遮挡。
17. 能获取截图时,至少抽查封面、典型内容页、复杂结构页和结尾页;如果总页数不超过 8 页,逐页截图检查。截图验收重点是源模板视觉结构是否仍可见且承载内容。
18. 验证记录必须说明“与 source.xml 的模板结构/素材保留对比结果”。只记录页数、关键词存在、`xml_text_overlap_lint.py error_count=0` 不足以通过 Template Rewrite validation。
允许的可读性增强仅限局部 text backing、局部 card backing、文字颜色/字重调整、文字阴影、缩短文案、复用源页已有文本容器,或复制 `source.xml` 中原本存在的 overlay。
## Automated XML Text Overlap Lint
回读 XML 保存到本地文件后,优先运行 XML 语法和文本重叠静态检查:
@@ -36,6 +67,7 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
- `summary.error_count == 0`。任何 error 都必须先修复再交付。
- 当前工具只检查 XML well-formed 和文本元素之间的明显重叠;它不检查越界、文本高度不足、图文压盖、表格/图表压盖或底部拥挤。
- 该工具不能替代页数核对、关键内容核对或真实视觉验收。
- 该工具不能验证模板视觉一致性。Template Rewrite Workflow 中,即使 `error_count == 0`只要源页背景、图片、shape、文本框、结构层级或主要媒体资产被清空/重画/遮挡,仍然必须判定失败。
常见 code 的处理方向:
@@ -46,7 +78,7 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
## Page Count And Structure
- 实际页数必须等于用户要求 `slide_plan.json` 的页数
- 实际页数必须等于用户要求。Create Workflow 对照 `slide_plan.json`Template Rewrite Workflow 对照 `source.xml` / `pages.json` 和 replace-pages 结果
- 如果创建过程部分失败,先记录已创建的 `xml_presentation_id`,再回读确认哪些页已写入。
- 每页都应包含 `<data>`,且 `<data>` 内至少有一个非背景主体元素。
- 封面、章节页、总结页可以文字较少,但不能只有空背景。
@@ -54,7 +86,7 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
## Expected Elements
`slide_plan.json` 和用户要求逐页核对:
Create Workflow `slide_plan.json` 和用户要求逐页核对:
- 标题或主结论存在,并能对应 `key_message`
- `layout_type` 对应的主要结构已生成。
@@ -62,6 +94,15 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
- `text_density` 影响了文本量,没有用长 bullet 框替代规划。
- `asset_need` 有真实素材时已放入正确区域;没有真实素材时,`fallback_if_missing` 已用 XML 形状、线条、标签、表格或图表兜底。
Template Rewrite Workflow 按 `source.xml``pages.json` 和用户替换要求逐页核对:
- 标题或主结论存在,并写入源页对应的标题/结论容器。
- 源页 dominant structure 仍是页面中最醒目或最大的信息区域之一。
- 新内容映射到源页已有文本容器、图形标签、节点、箭头、时间线、图表/table 或注释容器。
- 源页原有图文关系、分组关系、层级关系仍然可读,没有被新增卡片层覆盖或挤散。
- 多页之间保留源模板的页型差异,没有被统一改成同质化卡片页。
- 当源容器装不下时,优先缩短文本、降低层级或复用邻近源容器;不能用大白卡、大色块或新面板覆盖模板主体。
如果用户指定了关键页例如“架构解释”“Self-Attention 机制解释”“对比或演进视角”“总结页”,最终验证记录必须逐项说明这些页已存在。
## Blank Or Broken Page Signals
@@ -72,6 +113,11 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
- 关键文本没有出现在回读 XML 中。
- 图片仍是 `@./path`,或 `<img src>` 是 http(s) 外链。
- 页面依赖的图片区域为空,且没有 fallback visual。
- Template Rewrite 结果只保留模板尺寸/主题色丢失大部分源页图片、背景、shape、文本容器或媒体资产。
- Template Rewrite 结果由新生成本地 PPTX 导入,而不是对导入/已有 Slides 使用 `+replace-pages`
- Template Rewrite 结果把内容贴进新增通用卡片层,源页的箭头、节点、时间线、图表、几何结构、设备图或人物图只剩背景作用。
- Template Rewrite 结果多页出现重复的顶栏、三卡片、2x2 卡片或大 bullet 面板,压过源模板原有页型差异。
- Template Rewrite 结果中源页关键结构仍存在,但新内容没有贴回对应标签、节点、数字、表格或注释位置。
- 返回 XML 缺页、页序明显错误,或某页内容被 shell 截断。
- 大量形状坐标完全相同,导致主体内容重叠。
- 渐变背景回退成空白或白底,导致文字不可读。
@@ -94,6 +140,8 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
- 高密度页使用单个长 bullet list没有分栏、表格或分组。
- 标题、主视觉、正文的字号和颜色差异太弱,视觉层级不清。
- 所有内容页都是同一套标题加 bullets 坐标。
- Template Rewrite 中,新增元素漂浮在源结构上方,没有和源页图形、节点、表格、图片或注释形成对应关系。
- Template Rewrite 中,源页主体视觉被新增白卡、色块、面板或文字框切断。
## Verification Record
@@ -105,6 +153,7 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
- 关键页:架构解释 / Self-Attention / 对比或演进 / 总结页均存在。
- 结构:检查了主要 shape/img/table/chart 元素,无明显空白页或破损页。
- 布局:检查了标题层级、主视觉、重叠/越界/文本溢出风险。
- 模板二创:逐页或抽样说明 source.xml 的 dominant structure 是否仍承载内容,是否存在通用卡片层覆盖源结构;如已截图,说明抽查页范围。
```
不要声称完成了人工视觉验收,除非确实打开或获取了可视化结果。仅从 XML 静态检查得出的结论,应表述为“静态检查未发现明显问题”。

View File

@@ -74,7 +74,7 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
| `textAlign` | 文本对齐方式 |
| `lineSpacing` | 行间距schema 默认 `multiple:1.5` |
| `fontSize` | 字号 |
| `fontFamily` | 字体 |
| `fontFamily` | 字体,必须来自 `slides_xml_schema_definition.xml``FontFamilyType` 清单 |
| `color` | 字体颜色 |
| `bold` / `italic` / `underline` / `strikethrough` | 文本样式 |

View File

@@ -17,6 +17,10 @@ class XmlTextOverlapLintError(Exception):
pass
FONT_FAMILY_PLACEHOLDER_VALUES = {"undefined"}
_SUPPORTED_FONT_FAMILIES: set[str] | None = None
def fail(message: str) -> None:
raise XmlTextOverlapLintError(message)
@@ -75,6 +79,81 @@ def xml_local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1] if tag.startswith("{") else tag
def schema_definition_path() -> Path:
return Path(__file__).resolve().parents[1] / "references" / "slides_xml_schema_definition.xml"
def extract_supported_font_families(schema_xml: str) -> set[str]:
simple_type_match = re.search(
r'<xs:simpleType\s+name="FontFamilyType">([\s\S]*?)</xs:simpleType>',
schema_xml,
)
if simple_type_match is None:
fail("FontFamilyType definition not found in slides XML schema")
documentation_match = re.search(
r"<xs:documentation>([\s\S]*?)</xs:documentation>",
simple_type_match.group(1),
)
if documentation_match is None:
fail("FontFamilyType documentation not found in slides XML schema")
font_families: set[str] = set()
for raw_line in documentation_match.group(1).splitlines():
line = raw_line.strip()
if not line or line.startswith("字体族名称") or line.endswith(""):
continue
for font_family in re.split(r"[、,,]", line):
font_family = font_family.strip()
if font_family:
font_families.add(font_family)
if not font_families:
fail("FontFamilyType supported font list is empty")
return font_families
def supported_font_families() -> set[str]:
global _SUPPORTED_FONT_FAMILIES
if _SUPPORTED_FONT_FAMILIES is None:
_SUPPORTED_FONT_FAMILIES = extract_supported_font_families(read_file(schema_definition_path()))
return _SUPPORTED_FONT_FAMILIES
def line_column_at_offset(source: str, offset: int) -> tuple[int, int]:
line = source.count("\n", 0, offset) + 1
line_start = source.rfind("\n", 0, offset)
column = offset + 1 if line_start == -1 else offset - line_start
return line, column
def lint_font_families(xml: str) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
allowed_font_families = supported_font_families()
for match in re.finditer(r"\bfontFamily\s*=\s*([\"'])(.*?)\1", xml):
font_family = match.group(2).strip()
if not font_family or font_family in FONT_FAMILY_PLACEHOLDER_VALUES:
continue
if font_family in allowed_font_families:
continue
line, column = line_column_at_offset(xml, match.start())
issues.append(
{
"level": "error",
"code": "unsupported_font_family",
"message": f'fontFamily "{font_family}" is not supported',
"line": line,
"column": column,
"fontFamily": font_family,
"hint": (
"Use a FontFamilyType value from slides_xml_schema_definition.xml, "
"or omit fontFamily to use the default font."
),
}
)
return issues
def extract_error_context(xml: str, line: int | None, column: int | None, radius: int = 40) -> str | None:
if line is None or column is None:
return None
@@ -326,18 +405,24 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
}
presentation = parse_presentation(xml)
global_issues = lint_font_families(xml)
slides = [
lint_slide(slide_xml, index + 1)
for index, slide_xml in enumerate(presentation["slides"])
]
error_count = sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "error")
warning_count = sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "warning")
return {
error_count = sum(1 for issue in global_issues if issue["level"] == "error")
error_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "error")
warning_count = sum(1 for issue in global_issues if issue["level"] == "warning")
warning_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "warning")
result = {
"file": source_path,
"slide_size": {"width": presentation["width"], "height": presentation["height"]},
"summary": {"slide_count": len(slides), "error_count": error_count, "warning_count": warning_count},
"slides": slides,
}
if global_issues:
result["issues"] = global_issues
return result
def print_usage() -> None:

View File

@@ -110,6 +110,61 @@ class XmlTextOverlapLintTest(unittest.TestCase):
)
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_xml_accepts_supported_font_family(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<theme>
<textStyles>
<body fontFamily="思源黑体"/>
</textStyles>
</theme>
<slide>
<data>
<shape type="text" topLeftX="80" topLeftY="80" width="300" height="60">
<content textType="body" fontFamily="Inter"><p>Body text</p></content>
</shape>
</data>
</slide>
</presentation>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertNotIn("issues", result)
def test_lint_xml_allows_legacy_undefined_font_family_placeholder(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape type="text" topLeftX="80" topLeftY="80" width="300" height="60">
<content textType="body" fontFamily="undefined"><p>Body text</p></content>
</shape>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertNotIn("issues", result)
def test_lint_xml_reports_unsupported_font_family(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape type="text" topLeftX="80" topLeftY="80" width="300" height="60">
<content textType="body" fontFamily="微软雅黑"><p>Body text</p></content>
</shape>
</data>
</slide>
"""
)
issue = result["issues"][0]
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(issue["code"], "unsupported_font_family")
self.assertEqual(issue["fontFamily"], "微软雅黑")
self.assertIn("FontFamilyType", issue["hint"])
def test_lint_xml_single_slide_uses_default_canvas_without_bounds_checks(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""

View File

@@ -2,8 +2,8 @@
## Metrics
- Denominator: 31 leaf commands
- Covered: 10
- Coverage: 32.3%
- Covered: 11
- Coverage: 35.5%
## Summary
- TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`.
@@ -15,6 +15,7 @@
- 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_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body without calling live APIs.
- TestDriveImportDryRun_PDFToSlides: dry-run coverage for `drive +import`; asserts PDF-to-slides request shape across media upload `extra` and import task 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.
@@ -31,7 +32,7 @@
| ✕ | drive +download | shortcut | | none | no file fixture workflow yet |
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_BitableBaseOnlySchema | `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema` | 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 +import | shortcut | drive_import_dryrun_test.go::TestDriveImportDryRun_PDFToSlides | `.pdf` source with `--type slides`; media upload `extra.file_extension=pdf`; import task `file_extension=pdf`, `type=slides`, `file_name` | dry-run only; no live import workflow yet |
| ✕ | drive +move | shortcut | | none | no move workflow yet |
| ✓ | drive +pull | shortcut | drive_pull_dryrun_test.go::TestDrive_PullDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--on-duplicate-remote=rename\|newest\|oldest`; `--delete-local --yes` guard | dry-run locks flag/validate shape; live workflow proves duplicate fail-fast and rename recovery |
| ✓ | drive +push | shortcut | drive_push_dryrun_test.go::TestDrive_PushDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--if-exists`; `--on-duplicate-remote=newest\|oldest`; `--delete-remote --yes` | dry-run locks flag/validate shape; live workflow proves overwrite + duplicate cleanup converges status |