mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
8 Commits
codex/fix-
...
feat/slide
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21d73b239c | ||
|
|
8a271cbf27 | ||
|
|
8b1e2d600e | ||
|
|
2717ad0aac | ||
|
|
4471ebf60c | ||
|
|
d4af1be8db | ||
|
|
f68be6cd5a | ||
|
|
58b5880cad |
@@ -104,22 +104,6 @@ func TestDryRunFieldOps(t *testing.T) {
|
||||
assertDryRunContains(t, dryRunFieldUpdate(ctx, rt), "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1")
|
||||
assertDryRunContains(t, dryRunFieldDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1")
|
||||
assertDryRunContains(t, dryRunFieldSearchOptions(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1/options", "offset=3", "limit=30", "query=open")
|
||||
|
||||
autoNumberRT := newBaseTestRuntime(
|
||||
map[string]string{
|
||||
"base-token": "app_x",
|
||||
"table-id": "tbl_1",
|
||||
"field-id": "fld_1",
|
||||
"json": `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}`,
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
autoNumberDR := dryRunFieldUpdate(ctx, autoNumberRT)
|
||||
assertDryRunContains(t, autoNumberDR, "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1", `"name":"编号"`, `"type":"auto_number"`, `"rules":[`, `"length":4`)
|
||||
if out := autoNumberDR.Format(); strings.Contains(out, "auto_serial") || strings.Contains(out, "reformat_existing_records") || strings.Contains(out, "/open-apis/bitable/v1/") {
|
||||
t.Fatalf("auto_number dry-run must stay on v3 field JSON, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunRecordOps(t *testing.T) {
|
||||
@@ -133,7 +117,7 @@ func TestDryRunRecordOps(t *testing.T) {
|
||||
)
|
||||
assertDryRunContains(t, dryRunRecordList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "offset=0", "limit=200", "view_id=viw_1", "field_id=Name", "field_id=Age")
|
||||
|
||||
listFieldNamesAliasRT := newBaseTestRuntimeWithArrays(
|
||||
listFieldNamesAliasRT := newBaseTestRuntimeWithSlices(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
|
||||
map[string][]string{"field-names": {"Name", "Age"}},
|
||||
nil,
|
||||
|
||||
@@ -81,37 +81,6 @@ func runShortcutWithAuthTypes(t *testing.T, shortcut common.Shortcut, authTypes
|
||||
return parent.ExecuteContext(context.Background())
|
||||
}
|
||||
|
||||
func assertInvalidArgumentValidation(t *testing.T, err error, wantParam string, wantParams []string, messageContains string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid-argument validation error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected invalid-argument validation problem, got %T %v", err, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected ValidationError, got %T %v", err, err)
|
||||
}
|
||||
if validationErr.Param != wantParam {
|
||||
t.Fatalf("param=%q, want %q", validationErr.Param, wantParam)
|
||||
}
|
||||
if wantParams != nil {
|
||||
if len(validationErr.Params) != len(wantParams) {
|
||||
t.Fatalf("params=%#v, want %v", validationErr.Params, wantParams)
|
||||
}
|
||||
for i, want := range wantParams {
|
||||
if validationErr.Params[i].Name != want {
|
||||
t.Fatalf("params=%#v, want %v", validationErr.Params, wantParams)
|
||||
}
|
||||
}
|
||||
}
|
||||
if messageContains != "" && !strings.Contains(err.Error(), messageContains) {
|
||||
t.Fatalf("err=%v, want message containing %q", err, messageContains)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCreate(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
stderr, _ := factory.IOStreams.ErrOut.(*bytes.Buffer)
|
||||
@@ -849,189 +818,8 @@ func TestBaseFieldExecuteUpdate(t *testing.T) {
|
||||
if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", `{"name":"Amount","type":"number"}`, "--yes"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{`"updated": true`, `"fld_x"`, `"field_get_recommended": true`, `"next_step": "field_get"`, `"verification_hint"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldUpdateResultAlwaysRecommendsReadback(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
field interface{}
|
||||
submitted map[string]interface{}
|
||||
hintContains []string
|
||||
}{
|
||||
{
|
||||
name: "direct complex server type overrides simple submitted type",
|
||||
field: map[string]interface{}{"type": "auto_number"},
|
||||
submitted: map[string]interface{}{"type": "number"},
|
||||
hintContains: []string{`submitted type "number"`, `server returned type "auto_number"`},
|
||||
},
|
||||
{
|
||||
name: "nested simple server type still recommends readback",
|
||||
field: map[string]interface{}{"field": map[string]interface{}{"type": "number"}},
|
||||
submitted: map[string]interface{}{"type": "auto_number"},
|
||||
hintContains: []string{`submitted type "auto_number"`, `server returned type "number"`},
|
||||
},
|
||||
{
|
||||
name: "submitted simple type still recommends readback when response omits type",
|
||||
field: map[string]interface{}{"id": "fld_x"},
|
||||
submitted: map[string]interface{}{"type": "text"},
|
||||
hintContains: []string{`type "text"`, "cannot determine the previous type"},
|
||||
},
|
||||
{
|
||||
name: "missing type is conservative",
|
||||
field: map[string]interface{}{"id": "fld_x"},
|
||||
submitted: map[string]interface{}{"name": "Amount"},
|
||||
hintContains: []string{"unknown or uncommon field type", "+field-get"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := fieldUpdateResult(map[string]interface{}{"field": tc.field, "updated": true}, tc.submitted)
|
||||
if got["field_get_recommended"] != true || got["next_step"] != "field_get" {
|
||||
t.Fatalf("result=%#v, want readback recommendation", got)
|
||||
}
|
||||
hint, _ := got["verification_hint"].(string)
|
||||
for _, want := range tc.hintContains {
|
||||
if !strings.Contains(hint, want) {
|
||||
t.Fatalf("verification_hint=%q, want substring %q", hint, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFieldExecuteUpdateNoopReturnsAPIError(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x",
|
||||
Body: map[string]interface{}{
|
||||
"code": 800070003,
|
||||
"msg": "no operation produced",
|
||||
},
|
||||
})
|
||||
err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", `{"name":"Amount","type":"number"}`, "--yes"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected the API no-op response to surface as an error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed API error, got %T %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeUnknown || p.Code != 800070003 {
|
||||
t.Fatalf("category/subtype/code=%s/%s/%d", p.Category, p.Subtype, p.Code)
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("expected APIError, got %T %v", err, err)
|
||||
}
|
||||
if got := stdout.String(); strings.TrimSpace(got) != "" {
|
||||
t.Fatalf("no success envelope should be emitted on a no-op API error:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFieldExecuteUpdateAutoNumberUsesV3FieldJSON(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"field": map[string]interface{}{"id": "fld_x", "name": "编号", "type": "auto_number"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
jsonBody := `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}`
|
||||
if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", jsonBody, "--yes"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
gotBody := string(stub.CapturedBody)
|
||||
for _, want := range []string{
|
||||
`"name":"编号"`,
|
||||
`"type":"auto_number"`,
|
||||
`"rules":[`,
|
||||
`"date_format":"yyyyMM"`,
|
||||
`"length":4`,
|
||||
} {
|
||||
if !strings.Contains(gotBody, want) {
|
||||
t.Fatalf("request body missing %q:\n%s", want, gotBody)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"auto_serial", "reformat_existing_records", `"type":1005`} {
|
||||
if strings.Contains(gotBody, forbidden) {
|
||||
t.Fatalf("request body must not contain v1 field %q:\n%s", forbidden, gotBody)
|
||||
}
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{`"updated": true`, `"fld_x"`, `"field_get_recommended": true`, `"next_step": "field_get"`, `"verification_hint"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{`"reformat_existing_records"`} {
|
||||
if strings.Contains(got, forbidden) {
|
||||
t.Fatalf("stdout must not expose %q:\n%s", forbidden, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFieldExecuteUpdateDoesNotRejectExtraJSONKeys(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"id": "fld_x", "name": "编号", "type": "auto_number"},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
// Unknown v3 keys are forwarded unchanged; the server remains the source of
|
||||
// truth for whether a field-update property is supported.
|
||||
jsonBody := `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"incremental_number","length":4}]},"reformat_existing_records":true}`
|
||||
if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", jsonBody, "--yes"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if gotBody := string(stub.CapturedBody); !strings.Contains(gotBody, `"reformat_existing_records":true`) {
|
||||
t.Fatalf("request body must preserve unknown v3 key:\n%s", gotBody)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"updated": true`) {
|
||||
t.Fatalf("expected successful update, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFieldValidateAllowsRatingMaxAboveLimit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
runtime *common.RuntimeContext
|
||||
}{
|
||||
{
|
||||
name: "create",
|
||||
shortcut: BaseFieldCreate,
|
||||
runtime: newBaseTestRuntime(map[string]string{"base-token": "app_x", "table-id": "tbl_x", "json": `{"name":"评分","type":"number","style":{"type":"rating","icon":"star","min":0,"max":20}}`}, nil, nil),
|
||||
},
|
||||
{
|
||||
name: "update",
|
||||
shortcut: BaseFieldUpdate,
|
||||
runtime: newBaseTestRuntime(map[string]string{"base-token": "app_x", "table-id": "tbl_x", "field-id": "fld_x", "json": `{"name":"评分","type":"number","style":{"type":"rating","icon":"star","min":0,"max":20}}`}, nil, nil),
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := tc.shortcut.Validate(ctx, tc.runtime); err != nil {
|
||||
t.Fatalf("rating max above 10 should not be blocked by CLI validation: %v", err)
|
||||
}
|
||||
})
|
||||
if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"fld_x"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1303,32 +1091,8 @@ func TestBaseFieldExecuteCRUD(t *testing.T) {
|
||||
if err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"name":"Status","type":"text"}`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{`"created": true`, `"fld_new"`, `"field_get_recommended": false`, `"next_step": "done"`, `"verification_hint"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create generated field recommends readback", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"id": "fld_auto", "name": "编号", "type": "auto_number"},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"name":"编号","type":"auto_number"}`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{`"created": true`, `"fld_auto"`, `"field_get_recommended": true`, `"next_step": "field_get"`, `"verification_hint"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"fld_new"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1375,58 +1139,11 @@ func TestBaseFieldExecuteCRUD(t *testing.T) {
|
||||
if len(fields) != 2 {
|
||||
t.Fatalf("fields len=%d output=%#v", len(fields), data)
|
||||
}
|
||||
if data["field_get_recommended"] != false || data["next_step"] != "done" || data["verification_hint"] == nil {
|
||||
t.Fatalf("simple batch create must carry field_get_recommended:false + next_step:done + verification_hint: %#v", data)
|
||||
}
|
||||
if !strings.Contains(string(firstStub.CapturedBody), `"name":"A"`) || !strings.Contains(string(secondStub.CapturedBody), `"name":"B"`) {
|
||||
t.Fatalf("unexpected request bodies: %s / %s", firstStub.CapturedBody, secondStub.CapturedBody)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create array with generated field recommends readback", func(t *testing.T) {
|
||||
oldDelay := fieldCreateBatchDelay
|
||||
fieldCreateBatchDelay = 0
|
||||
t.Cleanup(func() { fieldCreateBatchDelay = oldDelay })
|
||||
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
|
||||
BodyFilter: func(body []byte) bool {
|
||||
return strings.Contains(string(body), `"name":"Title"`)
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"id": "fld_title", "name": "Title", "type": "text"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
|
||||
BodyFilter: func(body []byte) bool {
|
||||
return strings.Contains(string(body), `"name":"编号"`)
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"id": "fld_no", "name": "编号", "type": "auto_number"},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[{"name":"Title","type":"text"},{"name":"编号","type":"auto_number"}]`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["created"] != true || data["total"] != float64(2) {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
if _, ok := data["fields"].([]interface{}); !ok {
|
||||
t.Fatalf("batch create must keep fields array: %#v", data)
|
||||
}
|
||||
if data["field_get_recommended"] != true || data["next_step"] != "field_get" || data["verification_hint"] == nil {
|
||||
t.Fatalf("batch with auto_number must recommend readback: %#v", data)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1601,32 +1318,6 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list field names alias preserves quoted commas and at-sign names", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "field_id=A%2CB&field_id=%40Owner&limit=1&offset=0",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"A,B", "@Owner"},
|
||||
"record_id_list": []interface{}{"rec_alias_special"},
|
||||
"data": []interface{}{[]interface{}{"value-1", "value-2"}},
|
||||
"total": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{
|
||||
"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1",
|
||||
"--field-names", `"A,B",@Owner`, "--format", "json",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"rec_alias_special"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list json format", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1923,162 +1614,28 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list fields alias accepts JSON array projection", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "field_id=Name&field_id=Age&limit=1&offset=0",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"record_id_list": []interface{}{"rec_fields"},
|
||||
"data": []interface{}{[]interface{}{"Alice", 18}},
|
||||
"total": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--fields", `["Name","Age"]`, "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"rec_fields"`) || !strings.Contains(got, `"Alice"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list field names alias accepts repeated projection", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "field_id=Name&field_id=Age&limit=1&offset=0",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"record_id_list": []interface{}{"rec_fields"},
|
||||
"data": []interface{}{[]interface{}{"Alice", 18}},
|
||||
"total": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--field-names", "Name", "--field-names", "Age", "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"rec_fields"`) || !strings.Contains(got, `"Alice"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list projection aliases report only supplied ambiguous inputs", func(t *testing.T) {
|
||||
baseArgs := []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x"}
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantParam string
|
||||
wantParams []string
|
||||
}{
|
||||
{name: "canonical and fields alias", args: []string{"--field-id", "Name", "--fields", `["Age"]`}, wantParam: "--field-id", wantParams: []string{"--field-id", "--fields"}},
|
||||
{name: "canonical and field names alias", args: []string{"--field-id", "Name", "--field-names", "Age"}, wantParam: "--field-id", wantParams: []string{"--field-id", "--field-names"}},
|
||||
{name: "compatibility aliases", args: []string{"--fields", `["Name"]`, "--field-names", "Age"}, wantParam: "--fields", wantParams: []string{"--fields", "--field-names"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := append(append([]string{}, baseArgs...), tc.args...)
|
||||
err := runShortcut(t, BaseRecordList, args, factory, stdout)
|
||||
assertInvalidArgumentValidation(t, err, tc.wantParam, tc.wantParams, "mutually exclusive")
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Hint != "Use only --field-id for projection." {
|
||||
t.Fatalf("hint=%q, want canonical projection guidance", validationErr.Hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search json conflict reports each supplied projection parameter", func(t *testing.T) {
|
||||
t.Run("list legacy fields flag rejected", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordSearch, []string{
|
||||
"+record-search", "--base-token", "app_x", "--table-id", "tbl_x",
|
||||
"--json", `{"keyword":"Alice","search_fields":["Name"]}`,
|
||||
"--field-names", "Age",
|
||||
}, factory, stdout)
|
||||
assertInvalidArgumentValidation(t, err, "--json", []string{"--json", "--field-names"}, "mutually exclusive")
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || !strings.Contains(validationErr.Hint, "inside --json") {
|
||||
t.Fatalf("hint=%q, want JSON-body guidance", validationErr.Hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list canonical and alias projections reject duplicates consistently", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
param string
|
||||
}{
|
||||
{name: "canonical", args: []string{"--field-id", "Cost--USD", "--field-id", "Cost--USD"}, param: "--field-id"},
|
||||
{name: "fields alias", args: []string{"--fields", `["Cost--USD","Cost--USD"]`}, param: "--fields"},
|
||||
{name: "field names alias", args: []string{"--field-names", "Cost--USD", "--field-names", "Cost--USD"}, param: "--field-names"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := append([]string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x"}, tc.args...)
|
||||
err := runShortcut(t, BaseRecordList, args, factory, stdout)
|
||||
assertInvalidArgumentValidation(t, err, tc.param, []string{tc.param}, "duplicate field id")
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search fields alias accepts JSON array projection", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
searchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"record_id_list": []interface{}{"rec_search"},
|
||||
"data": []interface{}{[]interface{}{"Alice", 18}},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(searchStub)
|
||||
if err := runShortcut(t, BaseRecordSearch, []string{
|
||||
"+record-search", "--base-token", "app_x", "--table-id", "tbl_x",
|
||||
"--keyword", "Alice", "--search-field", "Name", "--fields", `["Name","Age"]`, "--format", "json",
|
||||
}, factory, stdout); err != nil {
|
||||
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if body := string(searchStub.CapturedBody); !strings.Contains(body, `"select_fields":["Name","Age"]`) {
|
||||
t.Fatalf("captured body=%s", body)
|
||||
})
|
||||
|
||||
t.Run("list field ids and field names alias are mutually exclusive", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "Name", "--field-names", "Age"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--field-id and --field-names are mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get field names alias accepts repeated projection", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"data": []interface{}{[]interface{}{"Alice", 18}},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordGet, []string{
|
||||
"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1",
|
||||
"--field-names", "Name", "--field-names", "Age", "--format", "json",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Run("list legacy fields flag rejected in dry-run", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name", "--dry-run"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if body := string(batchStub.CapturedBody); !strings.Contains(body, `"select_fields":["Name","Age"]`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get", func(t *testing.T) {
|
||||
|
||||
@@ -28,16 +28,23 @@ func newBaseTestRuntime(stringFlags map[string]string, boolFlags map[string]bool
|
||||
}
|
||||
|
||||
func newBaseTestRuntimeWithArrays(stringFlags map[string]string, stringArrayFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
|
||||
return newBaseTestRuntimeWithArraysAndSlices(stringFlags, stringArrayFlags, nil, boolFlags, intFlags)
|
||||
}
|
||||
|
||||
func newBaseTestRuntimeWithSlices(stringFlags map[string]string, stringSliceFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
|
||||
return newBaseTestRuntimeWithArraysAndSlices(stringFlags, nil, stringSliceFlags, boolFlags, intFlags)
|
||||
}
|
||||
|
||||
func newBaseTestRuntimeWithArraysAndSlices(stringFlags map[string]string, stringArrayFlags map[string][]string, stringSliceFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
for name := range stringFlags {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
for name := range stringArrayFlags {
|
||||
if name == "field-names" {
|
||||
cmd.Flags().StringSlice(name, nil, "")
|
||||
} else {
|
||||
cmd.Flags().StringArray(name, nil, "")
|
||||
}
|
||||
cmd.Flags().StringArray(name, nil, "")
|
||||
}
|
||||
for name := range stringSliceFlags {
|
||||
cmd.Flags().StringSlice(name, nil, "")
|
||||
}
|
||||
for name := range boolFlags {
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
@@ -54,6 +61,11 @@ func newBaseTestRuntimeWithArrays(stringFlags map[string]string, stringArrayFlag
|
||||
_ = cmd.Flags().Set(name, value)
|
||||
}
|
||||
}
|
||||
for name, values := range stringSliceFlags {
|
||||
for _, value := range values {
|
||||
_ = cmd.Flags().Set(name, value)
|
||||
}
|
||||
}
|
||||
for name, value := range boolFlags {
|
||||
if value {
|
||||
_ = cmd.Flags().Set(name, "true")
|
||||
@@ -465,40 +477,6 @@ func TestBaseLimitPageSizeAliasIsHidden(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRecordProjectionAliasesAreHidden(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
}{
|
||||
{name: "record list", shortcut: BaseRecordList},
|
||||
{name: "record search", shortcut: BaseRecordSearch},
|
||||
{name: "record get", shortcut: BaseRecordGet},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
tt.shortcut.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
|
||||
primary := cmd.Flags().Lookup("field-id")
|
||||
if primary == nil || primary.Hidden {
|
||||
t.Fatalf("public projection flag --field-id missing or hidden: %#v", primary)
|
||||
}
|
||||
help := cmd.Flags().FlagUsages()
|
||||
for _, aliasName := range []string{"fields", "field-names"} {
|
||||
alias := cmd.Flags().Lookup(aliasName)
|
||||
if alias == nil || !alias.Hidden {
|
||||
t.Fatalf("projection alias --%s should exist and be hidden: %#v", aliasName, alias)
|
||||
}
|
||||
if strings.Contains(help, "--"+aliasName) {
|
||||
t.Fatalf("help should not include hidden --%s:\n%s", aliasName, help)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseDashboardHelpGuidesAgents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -845,10 +823,6 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
|
||||
"does not auto-upsert by business key",
|
||||
"use +field-list to confirm real writable fields",
|
||||
"do not write system fields, formula, lookup, or attachment fields",
|
||||
"Sub-record/child-record path",
|
||||
"set that link field to a parent record reference array",
|
||||
`{"Parent Link":[{"id":"rec_xxx"}]}`,
|
||||
"do not look for parent_record_id or a separate child-record API",
|
||||
"CellValue happy path: text/phone/url",
|
||||
"select -> \"Todo\"",
|
||||
"multi-select -> [\"Tag A\",\"Tag B\"]",
|
||||
@@ -999,17 +973,11 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
||||
t.Fatalf("flag help missing %q:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
if strings.Contains(help, "reformat-existing-records") {
|
||||
t.Fatalf("+field-update must not expose a --reformat-existing-records flag:\n%s", help)
|
||||
}
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
wantTips := []string{
|
||||
`lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`,
|
||||
`"type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]`,
|
||||
`Example auto_number update: lark-cli base +field-update`,
|
||||
`When --json.type is "auto_number", updating the numbering rules also reapplies them to existing numbers`,
|
||||
"just submit the target field definition and do not add extra low-level parameters",
|
||||
"full field-definition PUT semantics",
|
||||
"Read the current field first with +field-get",
|
||||
"Type conversion is allowlist-based",
|
||||
@@ -1022,9 +990,6 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
if strings.Contains(tips, "--reformat-existing-records") {
|
||||
t.Fatalf("+field-update tips must not ask agents to pass --reformat-existing-records:\n%s", tips)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAttachmentHelpGuidesAgents(t *testing.T) {
|
||||
@@ -1147,10 +1112,6 @@ func TestBaseFieldValidate(t *testing.T) {
|
||||
if err := BaseFieldUpdate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "field-id": "fld_1", "json": `{"name":"f1","type":"formula"}`}, map[string]bool{"i-have-read-guide": true}, nil)); err != nil {
|
||||
t.Fatalf("formula update validate err=%v", err)
|
||||
}
|
||||
autoNumberJSON := `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"incremental_number","length":4}]}}`
|
||||
if err := BaseFieldUpdate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "field-id": "fld_1", "json": autoNumberJSON}, nil, nil)); err != nil {
|
||||
t.Fatalf("auto number update validate err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseTableValidate(t *testing.T) {
|
||||
@@ -1272,89 +1233,13 @@ func TestBaseRecordValidate(t *testing.T) {
|
||||
)); err != nil {
|
||||
t.Fatalf("record search json with sort-json validate err=%v", err)
|
||||
}
|
||||
err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
|
||||
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"keyword":"Alice","search_fields":["Name"]}`, "keyword": "Bob"},
|
||||
nil,
|
||||
nil,
|
||||
))
|
||||
assertInvalidArgumentValidation(t, err, "--json", []string{"--json", "--keyword"}, "mutually exclusive")
|
||||
err = BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"keyword":"Alice","search_fields":["Name"]}`, "fields": "Name"},
|
||||
map[string][]string{"field-id": {"fld_name"}},
|
||||
nil,
|
||||
nil,
|
||||
))
|
||||
assertInvalidArgumentValidation(t, err, "--json", []string{"--json", "--field-id", "--fields"}, "mutually exclusive")
|
||||
}
|
||||
|
||||
func TestBaseRecordSearchProjectionLimit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fields := make([]string, 51)
|
||||
for i := range fields {
|
||||
fields[i] = "Field " + strconv.Itoa(i+1)
|
||||
)); err == nil || !strings.Contains(err.Error(), "--json is mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
|
||||
map[string][]string{"search-field": {"Name"}, "field-id": fields[:50]},
|
||||
nil,
|
||||
nil,
|
||||
)); err != nil {
|
||||
t.Fatalf("50 projection fields should be accepted: %v", err)
|
||||
}
|
||||
|
||||
err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
|
||||
map[string][]string{"search-field": {"Name"}, "field-id": fields},
|
||||
nil,
|
||||
nil,
|
||||
))
|
||||
assertInvalidArgumentValidation(t, err, "--field-id", []string{"--field-id"}, "maximum limit of 50")
|
||||
|
||||
body, marshalErr := json.Marshal(map[string]interface{}{
|
||||
"keyword": "Alice",
|
||||
"search_fields": []string{"Name"},
|
||||
"select_fields": fields,
|
||||
})
|
||||
if marshalErr != nil {
|
||||
t.Fatalf("marshal search body: %v", marshalErr)
|
||||
}
|
||||
err = BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "json": string(body)},
|
||||
nil,
|
||||
nil,
|
||||
))
|
||||
assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "maximum limit of 50")
|
||||
}
|
||||
|
||||
func TestRecordSearchJSONNullProjectionIsOmitted(t *testing.T) {
|
||||
runtime := newBaseTestRuntime(map[string]string{
|
||||
"json": `{"keyword":"Alice","search_fields":["Name"],"select_fields":null,"sort":{"sort_config":[{"field":"Updated","desc":true}]}}`,
|
||||
}, nil, nil)
|
||||
body, err := recordSearchJSONBody(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("recordSearchJSONBody() error = %v", err)
|
||||
}
|
||||
if _, exists := body["select_fields"]; exists {
|
||||
t.Fatalf("select_fields:null must normalize to omitted, body=%#v", body)
|
||||
}
|
||||
if sortConfig, ok := body["sort"].([]interface{}); !ok || len(sortConfig) != 1 {
|
||||
t.Fatalf("sort normalization must continue after omitting null select_fields, body=%#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRecordSearchJSONProjectionParamIgnoresFlagLikeFieldNames(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
|
||||
map[string]string{
|
||||
"base-token": "b",
|
||||
"table-id": "tbl_1",
|
||||
"json": `{"keyword":"cost","search_fields":["Name"],"select_fields":["Cost--USD","Cost--USD"]}`,
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
))
|
||||
assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "duplicate field id")
|
||||
}
|
||||
|
||||
func TestBasePaginationValidationRejectsOutOfRange(t *testing.T) {
|
||||
|
||||
@@ -5,7 +5,6 @@ package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -37,10 +36,7 @@ func dryRunFieldGet(_ context.Context, runtime *common.RuntimeContext) *common.D
|
||||
|
||||
func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
pc := newParseCtx(runtime)
|
||||
bodies, err := parseFieldCreateBodies(pc, runtime.Str("json"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc(fmt.Sprintf("dry-run validation failed: %v", err))
|
||||
}
|
||||
bodies, _ := parseFieldCreateBodies(pc, runtime.Str("json"))
|
||||
dr := common.NewDryRunAPI().
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", baseTableID(runtime))
|
||||
@@ -52,10 +48,7 @@ func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *commo
|
||||
|
||||
func dryRunFieldUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
pc := newParseCtx(runtime)
|
||||
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc(fmt.Sprintf("dry-run validation failed: %v", err))
|
||||
}
|
||||
body, _ := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
return common.NewDryRunAPI().
|
||||
PUT("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id").
|
||||
Body(body).
|
||||
@@ -173,10 +166,10 @@ func executeFieldCreate(runtime *common.RuntimeContext) error {
|
||||
fields = append(fields, data)
|
||||
}
|
||||
if len(fields) == 1 {
|
||||
runtime.Out(fieldCreateResult(map[string]interface{}{"field": fields[0], "created": true}, bodies[0]), nil)
|
||||
runtime.Out(map[string]interface{}{"field": fields[0], "created": true}, nil)
|
||||
return nil
|
||||
}
|
||||
runtime.Out(fieldCreateBatchResult(map[string]interface{}{"fields": fields, "created": true, "total": len(fields)}, bodies), nil)
|
||||
runtime.Out(map[string]interface{}{"fields": fields, "created": true, "total": len(fields)}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -204,101 +197,10 @@ func executeFieldUpdate(runtime *common.RuntimeContext) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(fieldUpdateResult(map[string]interface{}{"field": data, "updated": true}, body), nil)
|
||||
runtime.Out(map[string]interface{}{"field": data, "updated": true}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func fieldCreateResult(result map[string]interface{}, submitted map[string]interface{}) map[string]interface{} {
|
||||
readbackRecommended, reason := fieldWriteReadbackRecommendation(submitted, "create")
|
||||
return attachFieldReadbackRecommendation(result, readbackRecommended, reason)
|
||||
}
|
||||
|
||||
// fieldCreateBatchResult attaches the same top-level readback contract to a
|
||||
// multi-field create. It recommends +field-get when any submitted field is a
|
||||
// computed/linked/generated (or unknown) type, so agents know when to verify
|
||||
// server state without breaking the existing fields/total structure.
|
||||
func fieldCreateBatchResult(result map[string]interface{}, submitted []map[string]interface{}) map[string]interface{} {
|
||||
recommend := false
|
||||
reason := "simple fields created successfully; use +field-get only when extra properties or explicit verification are needed"
|
||||
for _, body := range submitted {
|
||||
if rec, r := fieldWriteReadbackRecommendation(body, "create"); rec {
|
||||
recommend = true
|
||||
reason = r
|
||||
break
|
||||
}
|
||||
}
|
||||
return attachFieldReadbackRecommendation(result, recommend, reason)
|
||||
}
|
||||
|
||||
func fieldUpdateResult(result map[string]interface{}, submitted map[string]interface{}) map[string]interface{} {
|
||||
returnedType := normalizeFieldType(fieldResultType(result["field"]))
|
||||
submittedType := normalizeFieldType(common.GetString(submitted, "type"))
|
||||
readbackRecommended, reason := fieldUpdateReadbackRecommendation(returnedType, submittedType)
|
||||
return attachFieldReadbackRecommendation(result, readbackRecommended, reason)
|
||||
}
|
||||
|
||||
func fieldUpdateReadbackRecommendation(returnedType, submittedType string) (bool, string) {
|
||||
if returnedType != "" && submittedType != "" && returnedType != submittedType {
|
||||
return true, fmt.Sprintf("field update submitted type %q but the server returned type %q; run +field-get and verify record values before declaring completion", submittedType, returnedType)
|
||||
}
|
||||
|
||||
fieldType := returnedType
|
||||
if fieldType == "" {
|
||||
fieldType = submittedType
|
||||
}
|
||||
if recommended, reason := fieldTypeReadbackRecommendation(fieldType, "update"); recommended {
|
||||
return true, reason + "; sample record values when generated, computed, or converted values are in scope"
|
||||
}
|
||||
return true, fmt.Sprintf("field update request succeeded for type %q, but +field-update cannot determine the previous type; run +field-get and sample record values if the type changed before declaring completion", fieldType)
|
||||
}
|
||||
|
||||
func attachFieldReadbackRecommendation(result map[string]interface{}, readbackRecommended bool, reason string) map[string]interface{} {
|
||||
result["field_get_recommended"] = readbackRecommended
|
||||
result["verification_hint"] = reason
|
||||
if readbackRecommended {
|
||||
result["next_step"] = "field_get"
|
||||
} else {
|
||||
result["next_step"] = "done"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func fieldWriteReadbackRecommendation(submitted map[string]interface{}, operation string) (bool, string) {
|
||||
fieldType := normalizeFieldType(common.GetString(submitted, "type"))
|
||||
return fieldTypeReadbackRecommendation(fieldType, operation)
|
||||
}
|
||||
|
||||
func fieldTypeReadbackRecommendation(fieldType, operation string) (bool, string) {
|
||||
fieldType = normalizeFieldType(fieldType)
|
||||
switch fieldType {
|
||||
case "formula", "lookup", "auto_number", "link":
|
||||
return true, fmt.Sprintf("computed, linked, or generated field %s should be verified with +field-get before declaring completion", operation)
|
||||
case "text", "number", "select", "datetime", "checkbox", "user", "group_chat", "attachment", "location":
|
||||
return false, fmt.Sprintf("simple field %s returned successfully; use +field-get only when extra properties or explicit verification are needed", operation)
|
||||
default:
|
||||
return true, "unknown or uncommon field type; run +field-get to avoid assuming the submitted JSON fully describes server state"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeFieldType(fieldType string) string {
|
||||
return strings.ToLower(strings.TrimSpace(fieldType))
|
||||
}
|
||||
|
||||
func fieldResultType(value interface{}) string {
|
||||
field, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if fieldType := strings.ToLower(strings.TrimSpace(common.GetString(field, "type"))); fieldType != "" {
|
||||
return fieldType
|
||||
}
|
||||
nested, ok := field["field"].(map[string]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(common.GetString(nested, "type")))
|
||||
}
|
||||
|
||||
func executeFieldDelete(runtime *common.RuntimeContext) error {
|
||||
baseToken := runtime.Str("base-token")
|
||||
tableIDValue := baseTableID(runtime)
|
||||
|
||||
@@ -27,9 +27,7 @@ var BaseFieldUpdate = common.Shortcut{
|
||||
baseHighRiskYesTip,
|
||||
`Example text: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`,
|
||||
`Example select: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}' --yes`,
|
||||
`Example auto_number update: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "编号" --json '{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}' --yes`,
|
||||
"Update uses full field-definition PUT semantics. Read the current field first with +field-get, then send the target state.",
|
||||
`When --json.type is "auto_number", updating the numbering rules also reapplies them to existing numbers; just submit the target field definition and do not add extra low-level parameters.`,
|
||||
"Type conversion is allowlist-based: only use CLI for safe conversions; otherwise migrate through a new field, or ask the user to finish high-risk conversions in the web UI.",
|
||||
"Formula and lookup updates require reading the corresponding guide first.",
|
||||
"Agent hint: use the lark-base skill's field-update guide for JSON shape, type-conversion rules, and limits.",
|
||||
|
||||
@@ -238,14 +238,14 @@ func TestRecordSelectionHelpers(t *testing.T) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
fields, err = resolveRecordGetSelectFields(nil, "--field-id", map[string]interface{}{"select_fields": []interface{}{"Name"}})
|
||||
fields, err = resolveRecordGetSelectFields(nil, map[string]interface{}{"select_fields": []interface{}{"Name"}})
|
||||
if err != nil || !reflect.DeepEqual(fields, []string{"Name"}) {
|
||||
t.Fatalf("fields=%v err=%v", fields, err)
|
||||
}
|
||||
if _, err := resolveRecordGetSelectFields([]string{"Name"}, "--field-id", map[string]interface{}{"select_fields": []interface{}{"Age"}}); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
if _, err := resolveRecordGetSelectFields([]string{"Name"}, map[string]interface{}{"select_fields": []interface{}{"Age"}}); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := resolveRecordGetSelectFields(nil, "--field-id", map[string]interface{}{"select_fields": []interface{}{}}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
|
||||
if _, err := resolveRecordGetSelectFields(nil, map[string]interface{}{"select_fields": []interface{}{}}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,9 +21,7 @@ var BaseRecordGet = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"},
|
||||
recordProjectionFieldFlag("field ID or name to project; repeat to keep only needed columns"),
|
||||
recordProjectionAliasFlag("fields"),
|
||||
recordProjectionAliasFlag("field-names"),
|
||||
{Name: "field-id", Type: "string_array", Desc: "field ID or name to project; repeat to keep only needed columns"},
|
||||
{Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`},
|
||||
recordReadFormatFlag(),
|
||||
},
|
||||
|
||||
@@ -20,9 +20,8 @@ var BaseRecordList = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
recordProjectionFieldFlag("field ID or name to include; repeat to project only needed fields"),
|
||||
recordProjectionAliasFlag("fields"),
|
||||
recordProjectionAliasFlag("field-names"),
|
||||
recordListFieldRefFlag(),
|
||||
recordListFieldNamesAliasFlag(),
|
||||
recordListViewRefFlag(),
|
||||
recordFilterFlag(),
|
||||
recordSortFlag(),
|
||||
@@ -45,6 +44,9 @@ var BaseRecordList = common.Shortcut{
|
||||
"Use --field-id repeatedly to keep output small and aligned with the task.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateRecordListFieldAlias(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRecordReadFormat(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -59,9 +61,6 @@ var BaseRecordList = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := recordProjectionFields(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateRecordQueryOptions(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordList,
|
||||
@@ -73,6 +72,22 @@ var BaseRecordList = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
func recordListFieldRefFlag() common.Flag {
|
||||
flag := fieldRefFlag(false)
|
||||
flag.Type = "string_array"
|
||||
flag.Desc = "field ID or name to include; repeat to project only needed fields"
|
||||
return flag
|
||||
}
|
||||
|
||||
func recordListFieldNamesAliasFlag() common.Flag {
|
||||
return common.Flag{
|
||||
Name: "field-names",
|
||||
Type: "string_slice",
|
||||
Desc: "hidden alias for --field-id; accepts comma-separated field names",
|
||||
Hidden: true,
|
||||
}
|
||||
}
|
||||
|
||||
func recordListViewRefFlag() common.Flag {
|
||||
flag := viewRefFlag(false)
|
||||
flag.Desc = "view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view"
|
||||
@@ -87,3 +102,10 @@ func recordReadFormatFlag() common.Flag {
|
||||
Desc: "output format: markdown (default) | json",
|
||||
}
|
||||
}
|
||||
|
||||
func validateRecordListFieldAlias(runtime *common.RuntimeContext) error {
|
||||
if runtime.Changed("field-id") && runtime.Changed("field-names") {
|
||||
return baseFlagErrorf("--field-id and --field-names are mutually exclusive; use --field-id")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,18 +5,15 @@ package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const maxRecordSelectionCount = 200
|
||||
const maxBatchGetSelectFieldCount = 100
|
||||
const maxRecordSearchSelectFieldCount = 50
|
||||
|
||||
var recordCellValueHappyPathTips = []string{
|
||||
`CellValue happy path: text/phone/url -> "text"; number/currency/percent/rating -> 12.5; select -> "Todo"; multi-select -> ["Tag A","Tag B"]; datetime -> "2026-03-24 10:00:00"; checkbox -> true/false.`,
|
||||
@@ -49,6 +46,7 @@ func validateRecordSelection(runtime *common.RuntimeContext) error {
|
||||
|
||||
func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, error) {
|
||||
recordIDs := runtime.StrArray("record-id")
|
||||
fieldIDs := runtime.StrArray("field-id")
|
||||
jsonRaw := strings.TrimSpace(runtime.Str("json"))
|
||||
if len(recordIDs) > 0 && jsonRaw != "" {
|
||||
return recordSelection{}, baseFlagErrorf("--record-id and --json are mutually exclusive")
|
||||
@@ -71,11 +69,7 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
projectionFields, err := recordProjectionFields(runtime)
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
selectFields, err := resolveRecordGetSelectFields(projectionFields, recordProjectionParam(runtime), body)
|
||||
selectFields, err := resolveRecordGetSelectFields(fieldIDs, body)
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
@@ -89,11 +83,7 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
projectionFields, err := recordProjectionFields(runtime)
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
selectFields, err := resolveRecordGetSelectFields(projectionFields, recordProjectionParam(runtime), nil)
|
||||
selectFields, err := resolveRecordGetSelectFields(fieldIDs, nil)
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
@@ -114,20 +104,20 @@ func normalizeRecordIDs(values interface{}) ([]string, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func resolveRecordGetSelectFields(flagFields []string, projectionParam string, body map[string]interface{}) ([]string, error) {
|
||||
func resolveRecordGetSelectFields(flagFields []string, body map[string]interface{}) ([]string, error) {
|
||||
fromFlags, err := normalizeRecordGetSelectFields(flagFields)
|
||||
if err != nil {
|
||||
return nil, withValidationParam(err, projectionParam)
|
||||
return nil, err
|
||||
}
|
||||
if body == nil {
|
||||
return fromFlags, nil
|
||||
}
|
||||
rawJSONFields, ok := body["select_fields"]
|
||||
if !ok || rawJSONFields == nil {
|
||||
if !ok {
|
||||
return fromFlags, nil
|
||||
}
|
||||
if len(fromFlags) > 0 {
|
||||
return nil, baseFlagErrorf(`%s and --json field "select_fields" are mutually exclusive`, projectionParam)
|
||||
return nil, baseFlagErrorf(`--field-id and --json field "select_fields" are mutually exclusive`)
|
||||
}
|
||||
items, ok := rawJSONFields.([]interface{})
|
||||
if !ok {
|
||||
@@ -138,26 +128,18 @@ func resolveRecordGetSelectFields(flagFields []string, projectionParam string, b
|
||||
}
|
||||
normalized, err := normalizeRecordGetSelectFields(items)
|
||||
if err != nil {
|
||||
return nil, withValidationParam(err, "--json")
|
||||
return nil, err
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeRecordGetSelectFields(values interface{}) ([]string, error) {
|
||||
return normalizeRecordSelectFields(values, maxBatchGetSelectFieldCount)
|
||||
}
|
||||
|
||||
func normalizeRecordSearchSelectFields(values interface{}) ([]string, error) {
|
||||
return normalizeRecordSelectFields(values, maxRecordSearchSelectFieldCount)
|
||||
}
|
||||
|
||||
func normalizeRecordSelectFields(values interface{}, max int) ([]string, error) {
|
||||
return normalizeStringList(values, stringListNormalizeOptions{
|
||||
typeError: "field selection must be a string array",
|
||||
itemName: "field selection item",
|
||||
duplicateName: "field id",
|
||||
limitName: "field selection",
|
||||
max: max,
|
||||
max: maxBatchGetSelectFieldCount,
|
||||
allowNil: true,
|
||||
allowEmpty: true,
|
||||
})
|
||||
@@ -229,11 +211,7 @@ func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common
|
||||
params := url.Values{}
|
||||
params.Set("offset", strconv.Itoa(offset))
|
||||
params.Set("limit", strconv.Itoa(limit))
|
||||
fields, err := recordProjectionFields(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI()
|
||||
}
|
||||
for _, field := range fields {
|
||||
for _, field := range recordListFields(runtime) {
|
||||
params.Add("field_id", field)
|
||||
}
|
||||
if viewID := runtime.Str("view-id"); viewID != "" {
|
||||
@@ -397,121 +375,11 @@ func validateRecordJSON(runtime *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func recordProjectionFieldFlag(desc string) common.Flag {
|
||||
flag := fieldRefFlag(false)
|
||||
flag.Type = "string_array"
|
||||
flag.Desc = desc
|
||||
return flag
|
||||
}
|
||||
|
||||
func recordProjectionAliasFlag(name string) common.Flag {
|
||||
flagType := "string_array"
|
||||
if name == "field-names" {
|
||||
// Preserve the original compatibility contract: --field-names uses
|
||||
// pflag's CSV parser, including quoted commas, and treats @ literally.
|
||||
flagType = "string_slice"
|
||||
func recordListFields(runtime *common.RuntimeContext) []string {
|
||||
if runtime.Changed("field-names") {
|
||||
return runtime.StrSlice("field-names")
|
||||
}
|
||||
return common.Flag{
|
||||
Name: name,
|
||||
Type: flagType,
|
||||
Desc: "hidden alias for --field-id projection",
|
||||
Hidden: true,
|
||||
}
|
||||
}
|
||||
|
||||
func recordProjectionParam(runtime *common.RuntimeContext) string {
|
||||
switch {
|
||||
case runtime.Changed("fields"):
|
||||
return "--fields"
|
||||
case runtime.Changed("field-names"):
|
||||
return "--field-names"
|
||||
default:
|
||||
return "--field-id"
|
||||
}
|
||||
}
|
||||
|
||||
func withValidationParam(err error, param string) error {
|
||||
if err == nil || param == "" {
|
||||
return err
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
return err
|
||||
}
|
||||
reason := validationErr.Error()
|
||||
// The caller knows which input produced this validation error. Replace any
|
||||
// params inferred from the rendered message: field values such as Cost--USD
|
||||
// must not be mistaken for a --USD flag.
|
||||
validationErr.Param = param
|
||||
validationErr.Params = []errs.InvalidParam{{Name: param, Reason: reason}}
|
||||
return err
|
||||
}
|
||||
|
||||
func recordProjectionFields(runtime *common.RuntimeContext) ([]string, error) {
|
||||
return recordProjectionFieldsWithLimit(runtime, maxBatchGetSelectFieldCount)
|
||||
}
|
||||
|
||||
func recordSearchProjectionFields(runtime *common.RuntimeContext) ([]string, error) {
|
||||
return recordProjectionFieldsWithLimit(runtime, maxRecordSearchSelectFieldCount)
|
||||
}
|
||||
|
||||
func recordProjectionFieldsWithLimit(runtime *common.RuntimeContext, max int) ([]string, error) {
|
||||
fieldIDs := runtime.StrArray("field-id")
|
||||
fieldIDsSet := runtime.Changed("field-id")
|
||||
fieldsSet := runtime.Changed("fields")
|
||||
fieldNamesSet := runtime.Changed("field-names")
|
||||
projectionParams := make([]string, 0, 3)
|
||||
if fieldIDsSet {
|
||||
projectionParams = append(projectionParams, "--field-id")
|
||||
}
|
||||
if fieldsSet {
|
||||
projectionParams = append(projectionParams, "--fields")
|
||||
}
|
||||
if fieldNamesSet {
|
||||
projectionParams = append(projectionParams, "--field-names")
|
||||
}
|
||||
if len(projectionParams) > 1 {
|
||||
invalidParams := make([]errs.InvalidParam, 0, len(projectionParams))
|
||||
for _, param := range projectionParams {
|
||||
invalidParams = append(invalidParams, errs.InvalidParam{Name: param, Reason: "mutually exclusive"})
|
||||
}
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s are mutually exclusive", strings.Join(projectionParams, " and ")).
|
||||
WithParam(projectionParams[0]).
|
||||
WithParams(invalidParams...).
|
||||
WithHint("Use only --field-id for projection.")
|
||||
}
|
||||
if fieldsSet {
|
||||
return recordProjectionAliasFields(runtime, "fields", max)
|
||||
}
|
||||
if fieldNamesSet {
|
||||
return recordProjectionAliasFields(runtime, "field-names", max)
|
||||
}
|
||||
fields, err := normalizeRecordSelectFields(fieldIDs, max)
|
||||
return fields, withValidationParam(err, "--field-id")
|
||||
}
|
||||
|
||||
func recordProjectionAliasFields(runtime *common.RuntimeContext, flagName string, max int) ([]string, error) {
|
||||
var fields []string
|
||||
if flagName == "field-names" {
|
||||
fields = runtime.StrSlice(flagName)
|
||||
} else {
|
||||
pc := newParseCtx(runtime)
|
||||
values := runtime.StrArray(flagName)
|
||||
fields = make([]string, 0, len(values))
|
||||
for _, raw := range values {
|
||||
parsed, err := parseStringListFlexible(pc, raw, flagName)
|
||||
if err != nil {
|
||||
return nil, withValidationParam(err, "--"+flagName)
|
||||
}
|
||||
fields = append(fields, parsed...)
|
||||
}
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
err := baseFlagErrorf("--%s must include at least one field", flagName)
|
||||
return nil, withValidationParam(err, "--"+flagName)
|
||||
}
|
||||
normalized, err := normalizeRecordSelectFields(fields, max)
|
||||
return normalized, withValidationParam(err, "--"+flagName)
|
||||
return runtime.StrArray("field-id")
|
||||
}
|
||||
|
||||
func executeRecordList(runtime *common.RuntimeContext) error {
|
||||
@@ -524,10 +392,7 @@ func executeRecordList(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
limit := getPaginationLimit(runtime)
|
||||
params := map[string]interface{}{"offset": offset, "limit": limit}
|
||||
fields, err := recordProjectionFields(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields := recordListFields(runtime)
|
||||
if len(fields) > 0 {
|
||||
params["field_id"] = fields
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -175,10 +174,7 @@ func recordSearchFlagBody(runtime *common.RuntimeContext) (map[string]interface{
|
||||
if len(searchFields) > 0 {
|
||||
body["search_fields"] = searchFields
|
||||
}
|
||||
selectFields, err := recordSearchProjectionFields(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
selectFields := recordListFields(runtime)
|
||||
if len(selectFields) > 0 {
|
||||
body["select_fields"] = selectFields
|
||||
}
|
||||
@@ -207,19 +203,6 @@ func recordSearchJSONBody(runtime *common.RuntimeContext) (map[string]interface{
|
||||
}
|
||||
|
||||
func normalizeRecordSearchJSONBody(body map[string]interface{}) error {
|
||||
if rawSelectFields, ok := body["select_fields"]; ok {
|
||||
if rawSelectFields == nil {
|
||||
delete(body, "select_fields")
|
||||
} else {
|
||||
selectFields, err := normalizeRecordSearchSelectFields(rawSelectFields)
|
||||
if err != nil {
|
||||
return withValidationParam(err, "--json")
|
||||
}
|
||||
if len(selectFields) > 0 {
|
||||
body["select_fields"] = selectFields
|
||||
}
|
||||
}
|
||||
}
|
||||
if rawSort, ok := body["sort"]; ok {
|
||||
if sortConfig, err := normalizeRecordSortValue(rawSort, "--json.sort"); err == nil {
|
||||
body["sort"] = sortConfig
|
||||
@@ -236,20 +219,8 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
jsonRaw := strings.TrimSpace(runtime.Str("json"))
|
||||
if jsonRaw != "" {
|
||||
if exclusiveParams := recordSearchJSONExclusiveFlagParams(runtime); len(exclusiveParams) > 0 {
|
||||
allParams := append([]string{"--json"}, exclusiveParams...)
|
||||
invalidParams := make([]errs.InvalidParam, 0, len(allParams))
|
||||
for _, param := range allParams {
|
||||
invalidParams = append(invalidParams, errs.InvalidParam{Name: param, Reason: "mutually exclusive"})
|
||||
}
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--json is mutually exclusive with %s",
|
||||
strings.Join(exclusiveParams, " and "),
|
||||
).
|
||||
WithParam("--json").
|
||||
WithParams(invalidParams...).
|
||||
WithHint("Put keyword, search, projection, view, and pagination fields inside --json, or omit --json.")
|
||||
if recordSearchHasJSONExclusiveFlagInputs(runtime) {
|
||||
return baseFlagErrorf("--json is mutually exclusive with keyword/search/projection/pagination flags; put those fields inside --json, or omit --json")
|
||||
}
|
||||
_, err := recordSearchJSONBody(runtime)
|
||||
return err
|
||||
@@ -271,31 +242,17 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := recordSearchProjectionFields(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateRecordQueryOptions(runtime)
|
||||
}
|
||||
|
||||
func recordSearchJSONExclusiveFlagParams(runtime *common.RuntimeContext) []string {
|
||||
names := []string{
|
||||
"keyword",
|
||||
"search-field",
|
||||
"field-id",
|
||||
"fields",
|
||||
"field-names",
|
||||
"view-id",
|
||||
"offset",
|
||||
"limit",
|
||||
"page-size",
|
||||
}
|
||||
params := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
if runtime.Changed(name) {
|
||||
params = append(params, "--"+name)
|
||||
}
|
||||
}
|
||||
return params
|
||||
func recordSearchHasJSONExclusiveFlagInputs(runtime *common.RuntimeContext) bool {
|
||||
return strings.TrimSpace(runtime.Str("keyword")) != "" ||
|
||||
len(runtime.StrArray("search-field")) > 0 ||
|
||||
len(recordListFields(runtime)) > 0 ||
|
||||
runtime.Str("view-id") != "" ||
|
||||
runtime.Changed("offset") ||
|
||||
runtime.Changed("limit") ||
|
||||
runtime.Changed("page-size")
|
||||
}
|
||||
|
||||
func formatRecordQueryPriorityTip() string {
|
||||
|
||||
@@ -23,9 +23,7 @@ var BaseRecordSearch = common.Shortcut{
|
||||
{Name: "json", Desc: `record search JSON object for the full request body, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"filter":{"logic":"and","conditions":[]},"sort":[{"field":"Updated","desc":true}],"limit":50}; escape hatch for advanced cases`},
|
||||
{Name: "keyword", Desc: "keyword for record search; required unless --json is used"},
|
||||
{Name: "search-field", Type: "string_array", Desc: "field ID or name to search; repeat for multiple fields; required unless --json is used"},
|
||||
recordProjectionFieldFlag("field ID or name to include; repeat to project only needed fields"),
|
||||
recordProjectionAliasFlag("fields"),
|
||||
recordProjectionAliasFlag("field-names"),
|
||||
recordListFieldRefFlag(),
|
||||
recordListViewRefFlag(),
|
||||
recordFilterFlag(),
|
||||
recordSortFlag(),
|
||||
|
||||
@@ -26,7 +26,6 @@ var BaseRecordUpsert = common.Shortcut{
|
||||
"Happy path JSON is a top-level field map: each key is a real field name or field ID, each value is that field's CellValue.",
|
||||
"Without --record-id this creates a record; with --record-id this updates that record. It does not auto-upsert by business key.",
|
||||
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
|
||||
"Sub-record/child-record path: when a one-way/two-way link field represents hierarchy, create a normal record and set that link field to a parent record reference array, e.g. {\"Parent Link\":[{\"id\":\"rec_xxx\"}]}; do not look for parent_record_id or a separate child-record API.",
|
||||
"Use the record-upsert guide for command limits and edge cases.",
|
||||
}, recordCellValueHappyPathTips...),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -67,25 +67,6 @@ func parseAttendees(attendeesStr string, currentUserId string) ([]map[string]str
|
||||
return attendees, nil
|
||||
}
|
||||
|
||||
// selfAttendeeId resolves the open_id of the identity running the command so it
|
||||
// can be auto-added to the attendee list, mirroring how a human user is joined
|
||||
// to their own events. For a user it comes from config; for a bot it is fetched
|
||||
// from /bot/v3/info. If the bot lookup fails, we warn and return "" so the event
|
||||
// is still created with the explicitly requested attendees.
|
||||
func selfAttendeeId(runtime *common.RuntimeContext) string {
|
||||
if !runtime.IsBot() {
|
||||
return runtime.UserOpenId()
|
||||
}
|
||||
info, err := runtime.BotInfo()
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"[calendar +create] warning: could not resolve bot identity to add it as an attendee (%v); proceeding without the bot\n",
|
||||
err)
|
||||
return ""
|
||||
}
|
||||
return info.OpenID
|
||||
}
|
||||
|
||||
func attendeesIncludeRoom(attendees []map[string]string) bool {
|
||||
for _, attendee := range attendees {
|
||||
if attendee["type"] == "resource" || attendee["room_id"] != "" {
|
||||
@@ -195,9 +176,7 @@ var CalendarCreate = common.Shortcut{
|
||||
eventData := buildEventData(runtime, startTs, endTs)
|
||||
attendeesStr := runtime.Str("attendee-ids")
|
||||
if attendeesStr != "" {
|
||||
// Note: dry-run doesn't network resolve the running identity's own
|
||||
// open_id (user from config, bot from /bot/v3/info), so the auto-joined
|
||||
// self attendee is not shown here.
|
||||
// Note: dry-run doesn't network resolve the current user's open_id.
|
||||
attendees, err := parseAttendees(attendeesStr, "")
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
@@ -249,8 +228,11 @@ var CalendarCreate = common.Shortcut{
|
||||
|
||||
// Add attendees if specified
|
||||
if attendeesStr := runtime.Str("attendee-ids"); attendeesStr != "" {
|
||||
selfId := selfAttendeeId(runtime)
|
||||
attendees, err := parseAttendees(attendeesStr, selfId)
|
||||
currentUserId := ""
|
||||
if !runtime.IsBot() {
|
||||
currentUserId = runtime.UserOpenId()
|
||||
}
|
||||
attendees, err := parseAttendees(attendeesStr, currentUserId)
|
||||
if err != nil {
|
||||
return withParam(err, "--attendee-ids")
|
||||
}
|
||||
|
||||
@@ -251,136 +251,6 @@ func TestCreate_WithAttendees_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_WithAttendees_AsBot_AddsBotSelf(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"bot": map[string]interface{}{
|
||||
"open_id": "ou_botself",
|
||||
"app_name": "Test Bot",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_bot",
|
||||
"summary": "Bot Sync",
|
||||
"start_time": map[string]interface{}{
|
||||
"timestamp": "1742515200",
|
||||
},
|
||||
"end_time": map[string]interface{}{
|
||||
"timestamp": "1742518800",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
attendeesStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/events/evt_bot/attendees",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(attendeesStub)
|
||||
|
||||
err := mountAndRun(t, CalendarCreate, []string{
|
||||
"+create",
|
||||
"--summary", "Bot Sync",
|
||||
"--start", "2025-03-21T00:00:00+08:00",
|
||||
"--end", "2025-03-21T01:00:00+08:00",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--attendee-ids", "ou_user1",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if attendeesStub.CapturedBody == nil {
|
||||
t.Fatal("attendees API was not called")
|
||||
}
|
||||
if !bytes.Contains(attendeesStub.CapturedBody, []byte("ou_botself")) {
|
||||
t.Fatalf("expected bot open_id ou_botself in attendees request, got: %s", attendeesStub.CapturedBody)
|
||||
}
|
||||
if !bytes.Contains(attendeesStub.CapturedBody, []byte("ou_user1")) {
|
||||
t.Fatalf("expected requested attendee ou_user1 in attendees request, got: %s", attendeesStub.CapturedBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_WithAttendees_AsBot_BotInfoFails_ProceedsWithoutBot(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991663, "msg": "app ticket invalid",
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_nobot",
|
||||
"summary": "Bot Sync",
|
||||
"start_time": map[string]interface{}{
|
||||
"timestamp": "1742515200",
|
||||
},
|
||||
"end_time": map[string]interface{}{
|
||||
"timestamp": "1742518800",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
attendeesStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/events/evt_nobot/attendees",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(attendeesStub)
|
||||
|
||||
err := mountAndRun(t, CalendarCreate, []string{
|
||||
"+create",
|
||||
"--summary", "Bot Sync",
|
||||
"--start", "2025-03-21T00:00:00+08:00",
|
||||
"--end", "2025-03-21T01:00:00+08:00",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--attendee-ids", "ou_user1",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if attendeesStub.CapturedBody == nil {
|
||||
t.Fatal("attendees API was not called")
|
||||
}
|
||||
if !bytes.Contains(attendeesStub.CapturedBody, []byte("ou_user1")) {
|
||||
t.Fatalf("expected requested attendee ou_user1 in attendees request, got: %s", attendeesStub.CapturedBody)
|
||||
}
|
||||
if bytes.Contains(attendeesStub.CapturedBody, []byte("ou_botself")) {
|
||||
t.Fatalf("bot open_id should be absent when /bot/v3/info fails, got: %s", attendeesStub.CapturedBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_WithAttendees_APIError_RollsBack(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
|
||||
@@ -356,26 +356,11 @@ func TestValidateUpdateV2Contract(t *testing.T) {
|
||||
str: map[string]string{"doc": testDocxToken, "command": "str_replace"},
|
||||
wantParam: "--pattern",
|
||||
},
|
||||
{
|
||||
name: "XML str_replace rejects multiline pattern",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "str_replace", "doc-format": "xml", "pattern": "line one\nline two", "content": "replacement"},
|
||||
wantParam: "--pattern",
|
||||
},
|
||||
{
|
||||
name: "block_delete without block id",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "block_delete"},
|
||||
wantParam: "--block-id",
|
||||
},
|
||||
{
|
||||
name: "block_delete rejects empty ID",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "block_delete", "block-id": "blkA,,blkB"},
|
||||
wantParam: "--block-id",
|
||||
},
|
||||
{
|
||||
name: "block_delete rejects duplicate ID",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "block_delete", "block-id": "blkA, blkA"},
|
||||
wantParam: "--block-id",
|
||||
},
|
||||
{
|
||||
name: "block_insert_after without block id",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "block_insert_after"},
|
||||
|
||||
@@ -17,46 +17,6 @@ import (
|
||||
|
||||
// ── V2 (OpenAPI) tests ──
|
||||
|
||||
func TestStripTopLevelXMLTitles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "single title",
|
||||
content: "<title>Content title</title><p>body</p>",
|
||||
want: "<p>body</p>",
|
||||
},
|
||||
{
|
||||
name: "multiple titles",
|
||||
content: "<title>First</title>\n<p>body</p>\n<title>Second</title>",
|
||||
want: "<p>body</p>",
|
||||
},
|
||||
{
|
||||
name: "nested title is preserved",
|
||||
content: "<callout><title>Nested</title></callout><p>body</p>",
|
||||
want: "<callout><title>Nested</title></callout><p>body</p>",
|
||||
},
|
||||
{
|
||||
name: "malformed XML is preserved",
|
||||
content: "<title>Content title</title><p>A & B</p>",
|
||||
want: "<title>Content title</title><p>A & B</p>",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := stripTopLevelXMLTitles(tt.content); got != tt.want {
|
||||
t.Fatalf("stripTopLevelXMLTitles() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2BotAutoGrantSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -18,7 +16,7 @@ import (
|
||||
// v2CreateFlags returns the flag definitions for the v2 (OpenAPI) create path.
|
||||
func v2CreateFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "title", Desc: "document title; the CLI prepends it to --content as <title>...</title>. In XML mode, top-level <title> elements in --content are removed so this flag wins without duplicate-title warnings"},
|
||||
{Name: "title", Desc: "document title; when provided, the CLI prepends it to --content as <title>...</title> so the title wins over later content titles"},
|
||||
{Name: "content", Desc: "document body; XML by default or Markdown when --doc-format markdown. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "reference-map", Desc: docsReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "doc-format", Desc: "content format; xml is default and supports richer DocxXML blocks, markdown imports plain Markdown", Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
@@ -110,9 +108,6 @@ func buildCreateContentWithBody(runtime *common.RuntimeContext, content string)
|
||||
if title == "" {
|
||||
return content
|
||||
}
|
||||
if runtime.Str("doc-format") == "xml" {
|
||||
content = stripTopLevelXMLTitles(content)
|
||||
}
|
||||
|
||||
titleTag := "<title>" + escapeDocTitleText(title) + "</title>"
|
||||
if content == "" {
|
||||
@@ -121,62 +116,6 @@ func buildCreateContentWithBody(runtime *common.RuntimeContext, content string)
|
||||
return titleTag + "\n" + content
|
||||
}
|
||||
|
||||
type docContentRange struct {
|
||||
start int64
|
||||
end int64
|
||||
}
|
||||
|
||||
// stripTopLevelXMLTitles preserves the established --title-wins contract while
|
||||
// avoiding duplicate-title warnings from XML content. If the fragment is not
|
||||
// well-formed XML, it is left untouched for the service to diagnose.
|
||||
func stripTopLevelXMLTitles(content string) string {
|
||||
const wrapperStart = "<root>"
|
||||
wrapped := wrapperStart + content + "</root>"
|
||||
decoder := xml.NewDecoder(strings.NewReader(wrapped))
|
||||
wrapperLen := int64(len(wrapperStart))
|
||||
depth := 0
|
||||
activeStart := int64(-1)
|
||||
ranges := make([]docContentRange, 0, 1)
|
||||
|
||||
for {
|
||||
tokenStart := decoder.InputOffset()
|
||||
token, err := decoder.Token()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return content
|
||||
}
|
||||
|
||||
switch value := token.(type) {
|
||||
case xml.StartElement:
|
||||
if depth == 1 && value.Name.Space == "" && value.Name.Local == "title" {
|
||||
activeStart = tokenStart - wrapperLen
|
||||
}
|
||||
depth++
|
||||
case xml.EndElement:
|
||||
depth--
|
||||
if activeStart >= 0 && depth == 1 && value.Name.Space == "" && value.Name.Local == "title" {
|
||||
ranges = append(ranges, docContentRange{start: activeStart, end: decoder.InputOffset() - wrapperLen})
|
||||
activeStart = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(ranges) == 0 {
|
||||
return content
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
cursor := int64(0)
|
||||
for _, item := range ranges {
|
||||
result.WriteString(content[int(cursor):int(item.start)])
|
||||
cursor = item.end
|
||||
}
|
||||
result.WriteString(content[int(cursor):])
|
||||
return strings.TrimSpace(result.String())
|
||||
}
|
||||
|
||||
func escapeDocTitleText(title string) string {
|
||||
var buf bytes.Buffer
|
||||
_ = xml.EscapeText(&buf, []byte(title))
|
||||
|
||||
@@ -35,8 +35,8 @@ func v2UpdateFlags() []common.Flag {
|
||||
{Name: "doc-format", Desc: "content format for --content; xml is default for precise rich edits, markdown for user-provided Markdown or plain append/overwrite", Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "content", Desc: "replacement or inserted content; XML by default or Markdown when --doc-format markdown; empty with str_replace deletes match. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "reference-map", Desc: docsUpdateReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "pattern", Desc: "str_replace match pattern; XML mode accepts inline text only, Markdown mode can match multiline text"},
|
||||
{Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated unique IDs for batch delete); -1 means document end where supported"},
|
||||
{Name: "pattern", Desc: "str_replace match pattern; XML mode is inline text, Markdown mode can match multiline text"},
|
||||
{Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated for batch delete); -1 means document end where supported"},
|
||||
{Name: "src-block-ids", Desc: "comma-separated source block ids for block_copy_insert_after and block_move_after"},
|
||||
{Name: "revision-id", Desc: "base revision id; -1 means latest", Type: "int", Default: "-1"},
|
||||
}
|
||||
@@ -73,16 +73,10 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if pattern == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command str_replace requires --pattern").WithParam("--pattern")
|
||||
}
|
||||
if runtime.Str("doc-format") == "xml" && strings.ContainsAny(pattern, "\r\n") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "XML str_replace --pattern must be inline and cannot contain line breaks; use --doc-format markdown or a block operation for multiline changes").WithParam("--pattern")
|
||||
}
|
||||
case "block_delete":
|
||||
if blockID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_delete requires --block-id").WithParam("--block-id")
|
||||
}
|
||||
if err := validateBlockDeleteIDs(blockID); err != nil {
|
||||
return err
|
||||
}
|
||||
case "block_insert_after":
|
||||
if blockID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_insert_after requires --block-id").WithParam("--block-id")
|
||||
@@ -130,29 +124,6 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBlockDeleteIDs(raw string) error {
|
||||
seen := make(map[string]struct{})
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
blockID := strings.TrimSpace(part)
|
||||
if blockID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id contains an empty ID; provide a comma-separated list of non-empty block IDs").WithParam("--block-id")
|
||||
}
|
||||
if _, ok := seen[blockID]; ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id contains duplicate ID %q; each block may be deleted only once per request", blockID).WithParam("--block-id")
|
||||
}
|
||||
seen[blockID] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeBlockDeleteIDs(raw string) string {
|
||||
parts := strings.Split(raw, ",")
|
||||
for i := range parts {
|
||||
parts[i] = strings.TrimSpace(parts[i])
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
// Validate has already accepted --doc; parseDocumentRef cannot fail here.
|
||||
ref, _ := parseDocumentRef(runtime.Str("doc"))
|
||||
@@ -228,9 +199,6 @@ func buildUpdateBodyBase(runtime *common.RuntimeContext) map[string]interface{}
|
||||
body["pattern"] = v
|
||||
}
|
||||
if blockID != "" {
|
||||
if cmd == "block_delete" {
|
||||
blockID = normalizeBlockDeleteIDs(blockID)
|
||||
}
|
||||
body["block_id"] = blockID
|
||||
}
|
||||
if v := runtime.Str("src-block-ids"); v != "" {
|
||||
|
||||
@@ -24,12 +24,9 @@ type batchCreateKR struct {
|
||||
|
||||
// batchCreateObjective represents an objective in the batch create input.
|
||||
type batchCreateObjective struct {
|
||||
Text string `json:"text"`
|
||||
Mention []string `json:"mention,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
NotesMention []string `json:"notes_mention,omitempty"`
|
||||
CategoryID string `json:"category_id,omitempty"`
|
||||
KRs []batchCreateKR `json:"krs,omitempty"`
|
||||
Text string `json:"text"`
|
||||
Mention []string `json:"mention,omitempty"`
|
||||
KRs []batchCreateKR `json:"krs,omitempty"`
|
||||
}
|
||||
|
||||
// createdObjective tracks a created objective and its KR IDs for output.
|
||||
@@ -52,25 +49,6 @@ func parseBatchCreateInput(input string) ([]batchCreateObjective, error) {
|
||||
if strings.TrimSpace(obj.Text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].text is required and cannot be empty", i).WithParam("--input")
|
||||
}
|
||||
if obj.Notes != "" && strings.TrimSpace(obj.Notes) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].notes cannot be blank when provided", i).WithParam("--input")
|
||||
}
|
||||
if obj.Notes == "" && len(obj.NotesMention) > 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].notes is required when notes_mention is provided", i).WithParam("--input")
|
||||
}
|
||||
for j, mention := range obj.NotesMention {
|
||||
if strings.TrimSpace(mention) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].notes_mention[%d] cannot be empty", i, j).WithParam("--input")
|
||||
}
|
||||
}
|
||||
if obj.CategoryID != "" {
|
||||
if strings.TrimSpace(obj.CategoryID) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].category_id cannot be blank when provided", i).WithParam("--input")
|
||||
}
|
||||
if id, err := strconv.ParseInt(obj.CategoryID, 10, 64); err != nil || id <= 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].category_id must be a positive int64", i).WithParam("--input")
|
||||
}
|
||||
}
|
||||
for j, kr := range obj.KRs {
|
||||
if strings.TrimSpace(kr.Text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].krs[%d].text is required and cannot be empty", i, j).WithParam("--input")
|
||||
@@ -81,24 +59,11 @@ func parseBatchCreateInput(input string) ([]batchCreateObjective, error) {
|
||||
}
|
||||
|
||||
// createObjective calls the API to create an objective.
|
||||
func effectiveBatchObjectiveCategoryID(defaultCategoryID string, obj batchCreateObjective) string {
|
||||
if obj.CategoryID != "" {
|
||||
return obj.CategoryID
|
||||
}
|
||||
return defaultCategoryID
|
||||
}
|
||||
|
||||
func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleID, userIDType, defaultCategoryID string, obj batchCreateObjective) (string, error) {
|
||||
func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleID, userIDType string, obj batchCreateObjective) (string, error) {
|
||||
content := BuildContentBlock(obj.Text, obj.Mention)
|
||||
body := map[string]interface{}{
|
||||
"content": content,
|
||||
}
|
||||
if obj.Notes != "" {
|
||||
body["notes"] = BuildContentBlock(obj.Notes, obj.NotesMention)
|
||||
}
|
||||
if categoryID := effectiveBatchObjectiveCategoryID(defaultCategoryID, obj); categoryID != "" {
|
||||
body["category_id"] = categoryID
|
||||
}
|
||||
queryParams := map[string]interface{}{
|
||||
"cycle_id": cycleID,
|
||||
"user_id_type": userIDType,
|
||||
@@ -191,7 +156,6 @@ var OKRBatchCreate = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "cycle-id", Desc: "OKR cycle ID (int64)", Required: true},
|
||||
{Name: "input", Desc: "JSON array of objectives: [{\"text\":\"...\",\"mention\":[\"...\"],\"krs\":[{\"text\":\"...\",\"mention\":[\"...\"]}]}]", Input: []string{common.File, common.Stdin}, Required: true},
|
||||
{Name: "category-id", Desc: "default objective category ID for objectives that do not set category_id"},
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -207,15 +171,6 @@ var OKRBatchCreate = common.Shortcut{
|
||||
if _, err := parseBatchCreateInput(input); err != nil {
|
||||
return err
|
||||
}
|
||||
categoryID := runtime.Str("category-id")
|
||||
if categoryID != "" {
|
||||
if err := common.RejectDangerousCharsTyped("--category-id", categoryID); err != nil {
|
||||
return err
|
||||
}
|
||||
if id, err := strconv.ParseInt(categoryID, 10, 64); err != nil || id <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--category-id must be a positive int64").WithParam("--category-id")
|
||||
}
|
||||
}
|
||||
|
||||
idType := runtime.Str("user-id-type")
|
||||
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
|
||||
@@ -227,7 +182,6 @@ var OKRBatchCreate = common.Shortcut{
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
userIDType := runtime.Str("user-id-type")
|
||||
defaultCategoryID := runtime.Str("category-id")
|
||||
objectives, _ := parseBatchCreateInput(runtime.Str("input"))
|
||||
|
||||
apis := common.NewDryRunAPI()
|
||||
@@ -238,12 +192,6 @@ var OKRBatchCreate = common.Shortcut{
|
||||
objBody := map[string]interface{}{
|
||||
"content": objContent,
|
||||
}
|
||||
if obj.Notes != "" {
|
||||
objBody["notes"] = BuildContentBlock(obj.Notes, obj.NotesMention)
|
||||
}
|
||||
if categoryID := effectiveBatchObjectiveCategoryID(defaultCategoryID, obj); categoryID != "" {
|
||||
objBody["category_id"] = categoryID
|
||||
}
|
||||
objParams := map[string]interface{}{
|
||||
"cycle_id": cycleID,
|
||||
"user_id_type": userIDType,
|
||||
@@ -279,7 +227,6 @@ var OKRBatchCreate = common.Shortcut{
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
userIDType := runtime.Str("user-id-type")
|
||||
defaultCategoryID := runtime.Str("category-id")
|
||||
objectives, err := parseBatchCreateInput(runtime.Str("input"))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -294,7 +241,7 @@ var OKRBatchCreate = common.Shortcut{
|
||||
}
|
||||
|
||||
// Create objective
|
||||
objectiveID, err := createObjective(ctx, runtime, cycleID, userIDType, defaultCategoryID, obj)
|
||||
objectiveID, err := createObjective(ctx, runtime, cycleID, userIDType, obj)
|
||||
if err != nil {
|
||||
if len(created) == 0 {
|
||||
return err
|
||||
|
||||
@@ -6,8 +6,6 @@ package okr
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -16,7 +14,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func batchCreateTestConfig(t *testing.T) *core.CliConfig {
|
||||
@@ -46,15 +43,6 @@ const validBatchCreateInput = `[
|
||||
{"text":"Objective 2","krs":[{"text":"KR 2.1"},{"text":"KR 2.2"}]}
|
||||
]`
|
||||
|
||||
const validBatchCreateInputWithNotes = `[
|
||||
{"text":"Objective 1","notes":"Objective notes","notes_mention":["ou_note"],"krs":[{"text":"KR 1.1"}]}
|
||||
]`
|
||||
|
||||
const validBatchCreateInputWithCategory = `[
|
||||
{"text":"Objective 1","category_id":"222","krs":[{"text":"KR 1.1"}]},
|
||||
{"text":"Objective 2","krs":[]}
|
||||
]`
|
||||
|
||||
// --- Validate tests ---
|
||||
|
||||
func TestBatchCreateValidate_MissingCycleID(t *testing.T) {
|
||||
@@ -209,46 +197,6 @@ func TestBatchCreateValidate_EmptyKRText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateValidate_EmptyObjectiveNotesMention(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", `[{"text":"Obj 1","notes":"Notes","notes_mention":[" "]}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty objective notes mention")
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok || validationErr.Param != "--input" {
|
||||
t.Fatalf("expected param --input, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "objective[0].notes_mention[0]") {
|
||||
t.Fatalf("expected error to mention objective[0].notes_mention[0], got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateValidate_NotesMentionRequiresNotes(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", `[{"text":"Obj 1","notes_mention":["ou_note"]}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for notes_mention without notes")
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok || validationErr.Param != "--input" {
|
||||
t.Fatalf("expected param --input, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "objective[0].notes is required when notes_mention is provided") {
|
||||
t.Fatalf("expected error to mention missing notes, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateValidate_InvalidUserIDType(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
@@ -375,49 +323,6 @@ func TestBatchCreateDryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateDryRun_WithObjectiveNotes(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", validBatchCreateInputWithNotes,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "Objective notes") {
|
||||
t.Fatalf("dry-run output should contain objective notes, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "ou_note") {
|
||||
t.Fatalf("dry-run output should contain objective notes mention, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateDryRun_WithCategoryID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--category-id", "111",
|
||||
"--input", validBatchCreateInputWithCategory,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if got := gjson.Get(output, "data.api.0.body.category_id").String(); got != "222" {
|
||||
t.Fatalf("first objective category_id = %q, want per-objective override 222; output: %s", got, output)
|
||||
}
|
||||
if got := gjson.Get(output, "data.api.2.body.category_id").String(); got != "111" {
|
||||
t.Fatalf("second objective category_id = %q, want default 111; output: %s", got, output)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Execute tests ---
|
||||
|
||||
func TestBatchCreateExecute_Success(t *testing.T) {
|
||||
@@ -475,94 +380,6 @@ func TestBatchCreateExecute_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateExecute_ObjectiveWithNotes(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
var objectiveBody []byte
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
OnMatch: func(req *http.Request) {
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read objective request body: %v", err)
|
||||
}
|
||||
objectiveBody = body
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"objective_id": "100",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/objectives/100/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"key_result_id": "200",
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", validBatchCreateInputWithNotes,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !gjson.GetBytes(objectiveBody, "notes.blocks.0.paragraph.elements.0.text_run.text").Exists() {
|
||||
t.Fatalf("objective request body missing notes: %s", string(objectiveBody))
|
||||
}
|
||||
if got := gjson.GetBytes(objectiveBody, "notes.blocks.0.paragraph.elements.0.text_run.text").String(); got != "Objective notes" {
|
||||
t.Fatalf("notes text = %q, want Objective notes; body: %s", got, string(objectiveBody))
|
||||
}
|
||||
if got := gjson.GetBytes(objectiveBody, "notes.blocks.0.paragraph.elements.1.mention.user_id").String(); got != "ou_note" {
|
||||
t.Fatalf("notes mention = %q, want ou_note; body: %s", got, string(objectiveBody))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateExecute_ObjectiveWithCategoryID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
var objectiveBody []byte
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
OnMatch: func(req *http.Request) {
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read objective request body: %v", err)
|
||||
}
|
||||
objectiveBody = body
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"objective_id": "100",
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--category-id", "7249339036661170180",
|
||||
"--input", `[{"text":"Obj 1"}]`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := gjson.GetBytes(objectiveBody, "category_id").String(); got != "7249339036661170180" {
|
||||
t.Fatalf("category_id = %q, want 7249339036661170180; body: %s", got, string(objectiveBody))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateExecute_APIErrorOnObjective(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
|
||||
@@ -1,394 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// createParams holds the parsed parameters for single-object create operations.
|
||||
type createParams struct {
|
||||
Level string
|
||||
CycleID string
|
||||
ObjectiveID string
|
||||
Style string
|
||||
Content *ContentBlock
|
||||
Notes *ContentBlock
|
||||
CategoryID string
|
||||
UserIDType string
|
||||
}
|
||||
|
||||
type createContentMultipleJSONValuesError struct{}
|
||||
|
||||
func (createContentMultipleJSONValuesError) Error() string {
|
||||
return "multiple JSON values"
|
||||
}
|
||||
|
||||
var errCreateContentMultipleJSONValues createContentMultipleJSONValuesError
|
||||
|
||||
type okrCreateRequestBody struct {
|
||||
Content *ContentBlock `json:"content"`
|
||||
Notes *ContentBlock `json:"notes,omitempty"`
|
||||
CategoryID string `json:"category_id,omitempty"`
|
||||
}
|
||||
|
||||
type okrCreateObjectiveQuery struct {
|
||||
CycleID string
|
||||
UserIDType string
|
||||
}
|
||||
|
||||
type okrCreateKeyResultQuery struct {
|
||||
ObjectiveID string
|
||||
UserIDType string
|
||||
}
|
||||
|
||||
type okrCreateObjectiveResponse struct {
|
||||
ObjectiveID string
|
||||
}
|
||||
|
||||
type okrCreateKeyResultResponse struct {
|
||||
KeyResultID string
|
||||
}
|
||||
|
||||
type okrCreateObjectiveOutput struct {
|
||||
Level string `json:"level"`
|
||||
ObjectiveID string `json:"objective_id"`
|
||||
}
|
||||
|
||||
type okrCreateKeyResultOutput struct {
|
||||
Level string `json:"level"`
|
||||
ObjectiveID string `json:"objective_id"`
|
||||
KeyResultID string `json:"key_result_id"`
|
||||
}
|
||||
|
||||
func decodeCreateContentStrict(inputStr string, target interface{}, param, message string) error {
|
||||
dec := json.NewDecoder(bytes.NewReader([]byte(inputStr)))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(target); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, message, err).
|
||||
WithParam(param).
|
||||
WithCause(err)
|
||||
}
|
||||
var trailing interface{}
|
||||
if err := dec.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
err = errCreateContentMultipleJSONValues
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, message, err).
|
||||
WithParam(param).
|
||||
WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseCreateContentValue(inputStr, param, style string) (*ContentBlock, error) {
|
||||
if style == "simple" {
|
||||
var sp SemiPlainContent
|
||||
if err := decodeCreateContentStrict(inputStr, &sp, param, fmt.Sprintf("%s must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %%s", param)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(sp.Text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s text is required and cannot be empty", param).WithParam(param)
|
||||
}
|
||||
for i, mention := range sp.Mention {
|
||||
if strings.TrimSpace(mention) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s mention[%d] cannot be empty", param, i).WithParam(param)
|
||||
}
|
||||
}
|
||||
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s docs and images are not supported in simple style input; use richtext style or remove these fields", param).WithParam(param)
|
||||
}
|
||||
return sp.ToContentBlock(), nil
|
||||
}
|
||||
|
||||
var cb ContentBlock
|
||||
if err := decodeCreateContentStrict(inputStr, &cb, param, fmt.Sprintf("%s must be valid ContentBlock JSON: %%s", param)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(cb.Blocks) == 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s must contain at least one block", param).WithParam(param)
|
||||
}
|
||||
|
||||
hasNonEmptyParagraph := false
|
||||
for _, block := range cb.Blocks {
|
||||
if block.Paragraph != nil && len(block.Paragraph.Elements) > 0 {
|
||||
hasNonEmptyParagraph = true
|
||||
break
|
||||
}
|
||||
if block.Gallery != nil && len(block.Gallery.Images) > 0 {
|
||||
hasNonEmptyParagraph = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasNonEmptyParagraph {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s cannot be empty", param).WithParam(param)
|
||||
}
|
||||
return &cb, nil
|
||||
}
|
||||
|
||||
func projectCreateRequestBody(body okrCreateRequestBody) map[string]interface{} {
|
||||
result := map[string]interface{}{
|
||||
"content": body.Content,
|
||||
}
|
||||
if body.Notes != nil {
|
||||
result["notes"] = body.Notes
|
||||
}
|
||||
if body.CategoryID != "" {
|
||||
result["category_id"] = body.CategoryID
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func projectCreateObjectiveQuery(query okrCreateObjectiveQuery) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"cycle_id": query.CycleID,
|
||||
"user_id_type": query.UserIDType,
|
||||
}
|
||||
}
|
||||
|
||||
func projectCreateKeyResultQuery(query okrCreateKeyResultQuery) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"objective_id": query.ObjectiveID,
|
||||
"user_id_type": query.UserIDType,
|
||||
}
|
||||
}
|
||||
|
||||
func projectCreateObjectiveResponse(data map[string]interface{}) (*okrCreateObjectiveResponse, error) {
|
||||
objectiveID, ok := data["objective_id"].(string)
|
||||
if !ok || objectiveID == "" {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown, "create objective response missing objective_id")
|
||||
}
|
||||
return &okrCreateObjectiveResponse{ObjectiveID: objectiveID}, nil
|
||||
}
|
||||
|
||||
func projectCreateKeyResultResponse(data map[string]interface{}) (*okrCreateKeyResultResponse, error) {
|
||||
keyResultID, ok := data["key_result_id"].(string)
|
||||
if !ok || keyResultID == "" {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown, "create key result response missing key_result_id")
|
||||
}
|
||||
return &okrCreateKeyResultResponse{KeyResultID: keyResultID}, nil
|
||||
}
|
||||
|
||||
// parseCreateParams parses and validates flags from runtime into request-ready parameters.
|
||||
func parseCreateParams(runtime *common.RuntimeContext) (*createParams, error) {
|
||||
p := &createParams{
|
||||
Level: runtime.Str("level"),
|
||||
CycleID: runtime.Str("cycle-id"),
|
||||
ObjectiveID: runtime.Str("objective-id"),
|
||||
Style: runtime.Str("style"),
|
||||
CategoryID: runtime.Str("category-id"),
|
||||
UserIDType: runtime.Str("user-id-type"),
|
||||
}
|
||||
|
||||
contentStr := runtime.Str("content")
|
||||
if contentStr == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required").WithParam("--content")
|
||||
}
|
||||
if err := common.RejectDangerousCharsTyped("--content", contentStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
content, err := parseCreateContentValue(contentStr, "--content", p.Style)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Content = content
|
||||
|
||||
if notesStr := runtime.Str("notes"); notesStr != "" {
|
||||
if p.Level != "objective" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes is only supported when --level=objective").WithParam("--notes")
|
||||
}
|
||||
if err := common.RejectDangerousCharsTyped("--notes", notesStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notes, err := parseCreateContentValue(notesStr, "--notes", p.Style)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Notes = notes
|
||||
}
|
||||
if p.CategoryID != "" {
|
||||
if p.Level != "objective" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--category-id is only supported when --level=objective").WithParam("--category-id")
|
||||
}
|
||||
if err := common.RejectDangerousCharsTyped("--category-id", p.CategoryID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if id, err := strconv.ParseInt(p.CategoryID, 10, 64); err != nil || id <= 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--category-id must be a positive int64").WithParam("--category-id")
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// OKRCreate creates a single objective or key result.
|
||||
var OKRCreate = common.Shortcut{
|
||||
Service: "okr",
|
||||
Command: "+create",
|
||||
Description: "Create a single OKR objective or key result",
|
||||
Risk: "write",
|
||||
Scopes: []string{"okr:okr.content:writeonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "level", Desc: "create level: objective | key-result", Required: true, Enum: []string{"objective", "key-result"}},
|
||||
{Name: "cycle-id", Desc: "OKR cycle ID (required for level=objective)"},
|
||||
{Name: "objective-id", Desc: "objective ID (required for level=key-result)"},
|
||||
{Name: "style", Default: "simple", Desc: "input style for content: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
|
||||
{Name: "content", Desc: "content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple) or ContentBlock JSON (richtext)", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "notes", Desc: "objective notes: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple) or ContentBlock JSON (richtext)", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "category-id", Desc: "objective category ID; use only when classification is requested or the tenant requires categories"},
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
level := runtime.Str("level")
|
||||
if level != "objective" && level != "key-result" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level must be one of: objective | key-result").WithParam("--level")
|
||||
}
|
||||
|
||||
style := runtime.Str("style")
|
||||
if style != "simple" && style != "richtext" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
|
||||
}
|
||||
|
||||
idType := runtime.Str("user-id-type")
|
||||
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
|
||||
}
|
||||
|
||||
switch level {
|
||||
case "objective":
|
||||
if runtime.Str("objective-id") != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id cannot be used when --level=objective").WithParam("--objective-id")
|
||||
}
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
if cycleID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id is required when --level=objective").WithParam("--cycle-id")
|
||||
}
|
||||
if err := common.RejectDangerousCharsTyped("--cycle-id", cycleID); err != nil {
|
||||
return err
|
||||
}
|
||||
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
|
||||
}
|
||||
case "key-result":
|
||||
if runtime.Str("cycle-id") != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id cannot be used when --level=key-result").WithParam("--cycle-id")
|
||||
}
|
||||
objectiveID := runtime.Str("objective-id")
|
||||
if objectiveID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id is required when --level=key-result").WithParam("--objective-id")
|
||||
}
|
||||
if err := common.RejectDangerousCharsTyped("--objective-id", objectiveID); err != nil {
|
||||
return err
|
||||
}
|
||||
if id, err := strconv.ParseInt(objectiveID, 10, 64); err != nil || id <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id must be a positive int64").WithParam("--objective-id")
|
||||
}
|
||||
}
|
||||
|
||||
_, err := parseCreateParams(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
p, err := parseCreateParams(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().
|
||||
POST("").
|
||||
Desc(fmt.Sprintf("Dry-run skipped: %s", err.Error()))
|
||||
}
|
||||
|
||||
body := projectCreateRequestBody(okrCreateRequestBody{Content: p.Content, Notes: p.Notes, CategoryID: p.CategoryID})
|
||||
|
||||
if p.Level == "objective" {
|
||||
params := projectCreateObjectiveQuery(okrCreateObjectiveQuery{
|
||||
CycleID: p.CycleID,
|
||||
UserIDType: p.UserIDType,
|
||||
})
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/okr/v2/cycles/:cycle_id/objectives").
|
||||
Set("cycle_id", p.CycleID).
|
||||
Params(params).
|
||||
Body(body).
|
||||
Desc("Create OKR objective")
|
||||
}
|
||||
|
||||
params := projectCreateKeyResultQuery(okrCreateKeyResultQuery{
|
||||
ObjectiveID: p.ObjectiveID,
|
||||
UserIDType: p.UserIDType,
|
||||
})
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/okr/v2/objectives/:objective_id/key_results").
|
||||
Set("objective_id", p.ObjectiveID).
|
||||
Params(params).
|
||||
Body(body).
|
||||
Desc("Create OKR key result")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
p, err := parseCreateParams(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body := projectCreateRequestBody(okrCreateRequestBody{Content: p.Content, Notes: p.Notes, CategoryID: p.CategoryID})
|
||||
|
||||
if p.Level == "objective" {
|
||||
queryParams := projectCreateObjectiveQuery(okrCreateObjectiveQuery{
|
||||
CycleID: p.CycleID,
|
||||
UserIDType: p.UserIDType,
|
||||
})
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", p.CycleID)
|
||||
data, err := runtime.CallAPITyped("POST", path, queryParams, body)
|
||||
if err != nil {
|
||||
return wrapOkrNetworkErr(err, "failed to create objective")
|
||||
}
|
||||
resp, err := projectCreateObjectiveResponse(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result := okrCreateObjectiveOutput{
|
||||
Level: p.Level,
|
||||
ObjectiveID: resp.ObjectiveID,
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Created OKR objective [%s]\n", resp.ObjectiveID)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
queryParams := projectCreateKeyResultQuery(okrCreateKeyResultQuery{
|
||||
ObjectiveID: p.ObjectiveID,
|
||||
UserIDType: p.UserIDType,
|
||||
})
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", p.ObjectiveID)
|
||||
data, err := runtime.CallAPITyped("POST", path, queryParams, body)
|
||||
if err != nil {
|
||||
return wrapOkrNetworkErr(err, "failed to create key result")
|
||||
}
|
||||
resp, err := projectCreateKeyResultResponse(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result := okrCreateKeyResultOutput{
|
||||
Level: p.Level,
|
||||
ObjectiveID: p.ObjectiveID,
|
||||
KeyResultID: resp.KeyResultID,
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Created OKR key-result [%s] under objective [%s]\n", resp.KeyResultID, p.ObjectiveID)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,707 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func createTestConfig(t *testing.T) *core.CliConfig {
|
||||
t.Helper()
|
||||
return &core.CliConfig{
|
||||
AppID: "test-okr-create",
|
||||
AppSecret: patchTestValue(),
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
}
|
||||
|
||||
func runCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "okr"}
|
||||
OKRCreate.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
func runCreateShortcutWithStdin(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, stdin string, args []string) error {
|
||||
t.Helper()
|
||||
f.IOStreams.In = strings.NewReader(stdin)
|
||||
return runCreateShortcut(t, f, stdout, args)
|
||||
}
|
||||
|
||||
const (
|
||||
validCreateSimpleJSON = `{"text":"test objective","mention":["ou_123"]}`
|
||||
validCreateRichTextJSON = `{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"test content"}}]}}]}`
|
||||
emptyCreateRichTextJSON = `{"blocks":[]}`
|
||||
blankCreateRichTextJSON = `{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[]}}]}`
|
||||
validCreateObjectiveArgs1 = "+create"
|
||||
)
|
||||
|
||||
func TestCreateValidate_MissingLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
validCreateObjectiveArgs1,
|
||||
"--cycle-id", "123",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "level") {
|
||||
t.Fatalf("expected --level required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_InvalidLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "invalid",
|
||||
"--cycle-id", "123",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid level error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected typed invalid argument error, got: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--level" {
|
||||
t.Fatalf("expected param --level, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_MissingCycleIDForObjective(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected missing cycle-id error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected typed invalid argument error, got: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--cycle-id" {
|
||||
t.Fatalf("expected param --cycle-id, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_InvalidCycleID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "abc",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid cycle-id error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected typed invalid argument error, got: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--cycle-id" {
|
||||
t.Fatalf("expected param --cycle-id, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_MissingObjectiveIDForKR(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "key-result",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected missing objective-id error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected typed invalid argument error, got: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--objective-id" {
|
||||
t.Fatalf("expected param --objective-id, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_RejectObjectiveIDForObjective(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--objective-id", "456",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected objective-id rejection")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--objective-id" {
|
||||
t.Fatalf("expected param --objective-id, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_RejectCycleIDForKeyResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "key-result",
|
||||
"--cycle-id", "123",
|
||||
"--objective-id", "456",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected cycle-id rejection")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--cycle-id" {
|
||||
t.Fatalf("expected param --cycle-id, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_RejectNotesForKeyResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "key-result",
|
||||
"--objective-id", "456",
|
||||
"--content", validCreateSimpleJSON,
|
||||
"--notes", `{"text":"objective only notes"}`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected notes rejection for key-result")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--notes" {
|
||||
t.Fatalf("expected param --notes, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_RejectCategoryIDForKeyResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "key-result",
|
||||
"--objective-id", "456",
|
||||
"--content", validCreateSimpleJSON,
|
||||
"--category-id", "123",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected category-id rejection for key-result")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--category-id" {
|
||||
t.Fatalf("expected param --category-id, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_ContentAndNotesCannotBothReadStdin(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcutWithStdin(t, f, stdout, `{"text":"stdin content"}`, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--content", "-",
|
||||
"--notes", "-",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate stdin error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--notes" {
|
||||
t.Fatalf("expected param --notes, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "stdin (-) can only be used by one flag") {
|
||||
t.Fatalf("expected duplicate stdin error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_InvalidObjectiveID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "key-result",
|
||||
"--objective-id", "0",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid objective-id error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected typed invalid argument error, got: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--objective-id" {
|
||||
t.Fatalf("expected param --objective-id, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_InvalidStyle(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--style", "invalid",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid style error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--style" {
|
||||
t.Fatalf("expected param --style, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_InvalidUserIDType(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--content", validCreateSimpleJSON,
|
||||
"--user-id-type", "invalid",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid user-id-type error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--user-id-type" {
|
||||
t.Fatalf("expected param --user-id-type, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_MissingContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "content") {
|
||||
t.Fatalf("expected required content error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_InvalidSimpleContentJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--style", "simple",
|
||||
"--content", "not-json",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid simple json error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--content" {
|
||||
t.Fatalf("expected param --content, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_EmptySimpleText(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--style", "simple",
|
||||
"--content", `{"text":" "}`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected empty simple text error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--content" {
|
||||
t.Fatalf("expected param --content, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_EmptySimpleMention(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--style", "simple",
|
||||
"--content", `{"text":"test","mention":[""]}`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected empty simple mention error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--content" {
|
||||
t.Fatalf("expected param --content, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_SimpleContentRejectsDocsImages(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--style", "simple",
|
||||
"--content", `{"text":"test","docs":[{"title":"doc","url":"https://example.com"}],"images":["img"]}`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected docs/images rejection")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--content" {
|
||||
t.Fatalf("expected param --content, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_SimpleContentRejectsUnknownFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--style", "simple",
|
||||
"--content", `{"text":"test","mentions":["ou_123"]}`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unknown simple content field error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--content" {
|
||||
t.Fatalf("expected param --content, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown field") {
|
||||
t.Fatalf("expected unknown field error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_InvalidRichTextJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--style", "richtext",
|
||||
"--content", "not-json",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid richtext json error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--content" {
|
||||
t.Fatalf("expected param --content, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_RichTextRejectsUnknownFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--style", "richtext",
|
||||
"--content", `{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"test content"}}]}}],"mentions":["ou_123"]}`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unknown richtext content field error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--content" {
|
||||
t.Fatalf("expected param --content, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown field") {
|
||||
t.Fatalf("expected unknown field error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_EmptyRichTextContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
for _, content := range []string{emptyCreateRichTextJSON, blankCreateRichTextJSON} {
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--style", "richtext",
|
||||
"--content", content,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected empty richtext error for %s", content)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--content" {
|
||||
t.Fatalf("expected param --content, got: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDryRun_Objective(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--content", validCreateSimpleJSON,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if got := gjson.Get(output, "data.api.0.method").String(); got != "POST" {
|
||||
t.Fatalf("dry-run method = %q, want POST; output: %s", got, output)
|
||||
}
|
||||
if got := gjson.Get(output, "data.api.0.url").String(); got != "/open-apis/okr/v2/cycles/123/objectives" {
|
||||
t.Fatalf("dry-run url = %q, want objective create path; output: %s", got, output)
|
||||
}
|
||||
if gjson.Get(output, "data.api.0.params.cycle_id").String() != "123" {
|
||||
t.Fatalf("expected query params in dry-run, got: %s", output)
|
||||
}
|
||||
if gjson.Get(output, "data.api.0.params.user_id_type").String() != "open_id" {
|
||||
t.Fatalf("expected default user-id-type in dry-run, got: %s", output)
|
||||
}
|
||||
if got := gjson.Get(output, "data.api.0.body.content.blocks.0.paragraph.elements.0.text_run.text").String(); got != "test objective" {
|
||||
t.Fatalf("dry-run content text = %q, want test objective; output: %s", got, output)
|
||||
}
|
||||
if got := gjson.Get(output, "data.api.0.body.content.blocks.0.paragraph.elements.1.mention.user_id").String(); got != "ou_123" {
|
||||
t.Fatalf("dry-run mention user_id = %q, want ou_123; output: %s", got, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDryRun_ObjectiveWithNotes(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--content", validCreateSimpleJSON,
|
||||
"--notes", `{"text":"objective notes","mention":["ou_note"]}`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if got := gjson.Get(output, "data.api.0.body.notes.blocks.0.paragraph.elements.0.text_run.text").String(); got != "objective notes" {
|
||||
t.Fatalf("dry-run notes text = %q, want objective notes; output: %s", got, output)
|
||||
}
|
||||
if got := gjson.Get(output, "data.api.0.body.notes.blocks.0.paragraph.elements.1.mention.user_id").String(); got != "ou_note" {
|
||||
t.Fatalf("dry-run notes mention user_id = %q, want ou_note; output: %s", got, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDryRun_ObjectiveWithCategoryID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--content", validCreateSimpleJSON,
|
||||
"--category-id", "7249339036661170180",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if got := gjson.Get(output, "data.api.0.body.category_id").String(); got != "7249339036661170180" {
|
||||
t.Fatalf("dry-run category_id = %q, want 7249339036661170180; output: %s", got, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDryRun_KeyResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "key-result",
|
||||
"--objective-id", "456",
|
||||
"--style", "richtext",
|
||||
"--content", validCreateRichTextJSON,
|
||||
"--user-id-type", "union_id",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if got := gjson.Get(output, "data.api.0.method").String(); got != "POST" {
|
||||
t.Fatalf("dry-run method = %q, want POST; output: %s", got, output)
|
||||
}
|
||||
if got := gjson.Get(output, "data.api.0.url").String(); got != "/open-apis/okr/v2/objectives/456/key_results" {
|
||||
t.Fatalf("dry-run url = %q, want key result create path; output: %s", got, output)
|
||||
}
|
||||
if gjson.Get(output, "data.api.0.params.objective_id").String() != "456" {
|
||||
t.Fatalf("expected objective-id query param in dry-run, got: %s", output)
|
||||
}
|
||||
if gjson.Get(output, "data.api.0.params.user_id_type").String() != "union_id" {
|
||||
t.Fatalf("expected query params in dry-run, got: %s", output)
|
||||
}
|
||||
if got := gjson.Get(output, "data.api.0.body.content.blocks.0.paragraph.elements.0.text_run.text").String(); got != "test content" {
|
||||
t.Fatalf("dry-run richtext content = %q, want test content; output: %s", got, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateExecute_ObjectiveSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"objective_id": "1001",
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
level, _ := data["level"].(string)
|
||||
if level != "objective" {
|
||||
t.Fatalf("expected level objective, got %v", data["level"])
|
||||
}
|
||||
if data["objective_id"] != "1001" {
|
||||
t.Fatalf("expected objective_id=1001, got %v", data["objective_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateExecute_KeyResultSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/objectives/456/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"key_result_id": "2001",
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "key-result",
|
||||
"--objective-id", "456",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
level, _ := data["level"].(string)
|
||||
if level != "key-result" {
|
||||
t.Fatalf("expected level key-result, got %v", data["level"])
|
||||
}
|
||||
if data["key_result_id"] != "2001" {
|
||||
t.Fatalf("expected key_result_id=2001, got %v", data["key_result_id"])
|
||||
}
|
||||
if data["objective_id"] != "456" {
|
||||
t.Fatalf("expected objective_id=456, got %v", data["objective_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateExecute_ObjectiveAPITypedErrorPassThrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{
|
||||
"code": 1001001,
|
||||
"msg": "invalid parameters",
|
||||
},
|
||||
})
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected API error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryAPI {
|
||||
t.Fatalf("expected typed API error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateExecute_KeyResultRawErrorWrappedAsNetworkError(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
raw := errors.New("dial tcp: i/o timeout")
|
||||
got := wrapOkrNetworkErr(raw, "failed to create key result")
|
||||
problem, ok := errs.ProblemOf(got)
|
||||
if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("expected network transport error, got: %v", got)
|
||||
}
|
||||
if !errors.Is(got, raw) {
|
||||
t.Fatal("expected wrapped raw error to be preserved")
|
||||
}
|
||||
if stdout.String() != "" || f == nil {
|
||||
// keep the test factory referenced so the helper wiring stays exercised
|
||||
}
|
||||
}
|
||||
@@ -64,10 +64,6 @@ func isCurrentActiveCycle(cycle *Cycle, now time.Time) bool {
|
||||
cycleStart := time.UnixMilli(startMs).UTC()
|
||||
cycleEnd := time.UnixMilli(endMs).UTC()
|
||||
nowUTC := now.UTC()
|
||||
// Month cycles only
|
||||
if cycleStart.AddDate(1, 0, -1) == cycleEnd {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check time range: now must be >= start and <= end
|
||||
if nowUTC.Before(cycleStart) || nowUTC.After(cycleEnd) {
|
||||
@@ -82,7 +78,6 @@ func isCurrentActiveCycle(cycle *Cycle, now time.Time) bool {
|
||||
return status == CycleStatusDefault || status == CycleStatusNormal
|
||||
}
|
||||
|
||||
// OKRListCycles
|
||||
var OKRListCycles = common.Shortcut{
|
||||
Service: "okr",
|
||||
Command: "+cycle-list",
|
||||
@@ -94,9 +89,7 @@ var OKRListCycles = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "user-id", Desc: "user ID", Required: true},
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||
{Name: "time-range", Desc: "local post-filter applied after the requested page is fetched. Format: YYYY-MM--YYYY-MM. Leave empty to keep the page unfiltered."},
|
||||
{Name: "page-size", Type: "int", Default: "100", Desc: "page size, range 1-100"},
|
||||
{Name: "page-token", Desc: "pagination token from previous response"},
|
||||
{Name: "time-range", Desc: "specify time range. Use Format as YYYY-MM--YYYY-MM. leave empty to fetch all user cycles."},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
idType := runtime.Str("user-id-type")
|
||||
@@ -117,29 +110,18 @@ var OKRListCycles = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", 100, 1, 100); err != nil {
|
||||
return err
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
if err := common.RejectDangerousCharsTyped("--page-token", pageToken); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
params := map[string]interface{}{
|
||||
"user_id": runtime.Str("user-id"),
|
||||
"user_id_type": runtime.Str("user-id-type"),
|
||||
"page_size": runtime.Int("page-size"),
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
"page_size": 100,
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/okr/v2/cycles").
|
||||
Params(params).
|
||||
Desc("List one page of OKR cycles for user; --time-range is a local post-filter on the returned page")
|
||||
Desc("List OKR cycles for user, paginated at 100 per page, filtered by time-range")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
userID := runtime.Str("user-id")
|
||||
@@ -158,34 +140,52 @@ var OKRListCycles = common.Shortcut{
|
||||
hasRange = true
|
||||
}
|
||||
|
||||
// Paginated fetch of all cycles
|
||||
queryParams := map[string]interface{}{
|
||||
"user_id": userID,
|
||||
"user_id_type": userIDType,
|
||||
"page_size": runtime.Int("page-size"),
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
queryParams["page_token"] = pageToken
|
||||
"page_size": "100",
|
||||
}
|
||||
|
||||
var allCycles []Cycle
|
||||
data, err := runtime.CallAPITyped("GET", "/open-apis/okr/v2/cycles", queryParams, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
page := 0
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if page > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
page++
|
||||
|
||||
itemsRaw, _ := data["items"].([]interface{})
|
||||
for _, item := range itemsRaw {
|
||||
raw, err := json.Marshal(item)
|
||||
data, err := runtime.CallAPITyped("GET", "/open-apis/okr/v2/cycles", queryParams, nil)
|
||||
if err != nil {
|
||||
continue
|
||||
return err
|
||||
}
|
||||
var cycle Cycle
|
||||
if err := json.Unmarshal(raw, &cycle); err != nil {
|
||||
continue
|
||||
|
||||
itemsRaw, _ := data["items"].([]interface{})
|
||||
for _, item := range itemsRaw {
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var cycle Cycle
|
||||
if err := json.Unmarshal(raw, &cycle); err != nil {
|
||||
continue
|
||||
}
|
||||
allCycles = append(allCycles, cycle)
|
||||
}
|
||||
allCycles = append(allCycles, cycle)
|
||||
|
||||
hasMore, pageToken := common.PaginationMeta(data)
|
||||
if !hasMore || pageToken == "" {
|
||||
break
|
||||
}
|
||||
queryParams["page_token"] = pageToken
|
||||
}
|
||||
hasMore, nextPageToken := common.PaginationMeta(data)
|
||||
|
||||
// Filter by time-range overlap
|
||||
var filtered []Cycle
|
||||
@@ -212,8 +212,7 @@ var OKRListCycles = common.Shortcut{
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"cycles": respCycles,
|
||||
"has_more": hasMore,
|
||||
"page_token": nextPageToken,
|
||||
"total": len(respCycles),
|
||||
"current_active_cycles": currentActiveCycles,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Found %d cycle(s)\n", len(respCycles))
|
||||
|
||||
@@ -5,8 +5,6 @@ package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -14,7 +12,6 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -123,27 +120,6 @@ func TestCycleListValidate_StartAfterEndTimeRange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListValidate_InvalidPageSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
"--page-size", "101",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --page-size")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected validation invalid_argument problem, got: %v", err)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok || validationErr.Param != "--page-size" {
|
||||
t.Fatalf("expected param --page-size, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListValidate_ValidNoTimeRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
@@ -238,9 +214,6 @@ func TestCycleListDryRun(t *testing.T) {
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/cycles") {
|
||||
t.Fatalf("dry-run output should contain API path, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "\"page_size\": 100") {
|
||||
t.Fatalf("dry-run output should contain default page_size=100, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListDryRun_WithTimeRange(t *testing.T) {
|
||||
@@ -261,28 +234,6 @@ func TestCycleListDryRun_WithTimeRange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListDryRun_WithPagination(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-789",
|
||||
"--page-size", "20",
|
||||
"--page-token", "next-page",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "\"page_size\": 20") {
|
||||
t.Fatalf("dry-run output should contain page_size=20, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "\"page_token\": \"next-page\"") {
|
||||
t.Fatalf("dry-run output should contain page_token, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Execute tests ---
|
||||
|
||||
func TestCycleListExecute_NoCycles(t *testing.T) {
|
||||
@@ -503,11 +454,9 @@ func TestCycleListExecute_WithCycles(t *testing.T) {
|
||||
if len(cycles) != 2 {
|
||||
t.Fatalf("cycles count = %d, want 2", len(cycles))
|
||||
}
|
||||
if _, ok := data["total"]; ok {
|
||||
t.Fatal("total should not be present in response")
|
||||
}
|
||||
if hasMore, _ := data["has_more"].(bool); hasMore {
|
||||
t.Fatalf("has_more = %v, want false", hasMore)
|
||||
total, _ := data["total"].(float64)
|
||||
if int(total) != 2 {
|
||||
t.Fatalf("total = %v, want 2", total)
|
||||
}
|
||||
|
||||
// Check current_active_cycles - should only contain cycle-active
|
||||
@@ -606,13 +555,10 @@ func TestCycleListExecute_Pagination(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
|
||||
var gotQuery url.Values
|
||||
// First page
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
OnMatch: func(req *http.Request) {
|
||||
gotQuery = req.URL.Query()
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
@@ -632,31 +578,38 @@ func TestCycleListExecute_Pagination(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
// Second page
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "cycle-p2",
|
||||
"start_time": "1738368000000",
|
||||
"end_time": "1743465600000",
|
||||
"cycle_status": 1,
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
"--page-size", "1",
|
||||
"--page-token", "start_page",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := gotQuery.Get("page_size"); got != "1" {
|
||||
t.Fatalf("query page_size = %q, want 1", got)
|
||||
}
|
||||
if got := gotQuery.Get("page_token"); got != "start_page" {
|
||||
t.Fatalf("query page_token = %q, want start_page", got)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
cycles, _ := data["cycles"].([]interface{})
|
||||
if len(cycles) != 1 {
|
||||
t.Fatalf("cycles count = %d, want 1", len(cycles))
|
||||
}
|
||||
if hasMore, _ := data["has_more"].(bool); !hasMore {
|
||||
t.Fatalf("has_more = %v, want true", hasMore)
|
||||
}
|
||||
if pageToken, _ := data["page_token"].(string); pageToken != "next_page" {
|
||||
t.Fatalf("page_token = %q, want next_page", pageToken)
|
||||
if len(cycles) != 2 {
|
||||
t.Fatalf("cycles count = %d, want 2", len(cycles))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,6 @@ var OKRListProgress = common.Shortcut{
|
||||
{Name: "target-type", Desc: "target type: objective | key_result", Required: true, Enum: []string{"objective", "key_result"}},
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||
{Name: "department-id-type", Default: "open_department_id", Desc: "department ID type: department_id | open_department_id"},
|
||||
{Name: "page-size", Type: "int", Default: "100", Desc: "page size, range 1-100"},
|
||||
{Name: "page-token", Desc: "pagination token from previous response"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
targetID := runtime.Str("target-id")
|
||||
@@ -57,14 +55,6 @@ var OKRListProgress = common.Shortcut{
|
||||
if deptIDType != "department_id" && deptIDType != "open_department_id" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--department-id-type must be one of: department_id | open_department_id").WithParam("--department-id-type")
|
||||
}
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", 100, 1, 100); err != nil {
|
||||
return err
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
if err := common.RejectDangerousCharsTyped("--page-token", pageToken); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -73,10 +63,7 @@ var OKRListProgress = common.Shortcut{
|
||||
params := map[string]interface{}{
|
||||
"user_id_type": runtime.Str("user-id-type"),
|
||||
"department_id_type": runtime.Str("department-id-type"),
|
||||
"page_size": runtime.Int("page-size"),
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
"page_size": 100,
|
||||
}
|
||||
|
||||
switch targetType {
|
||||
@@ -104,10 +91,7 @@ var OKRListProgress = common.Shortcut{
|
||||
queryParams := map[string]interface{}{
|
||||
"user_id_type": userIDType,
|
||||
"department_id_type": deptIDType,
|
||||
"page_size": runtime.Int("page-size"),
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
queryParams["page_token"] = pageToken
|
||||
"page_size": "100",
|
||||
}
|
||||
|
||||
var apiPath string
|
||||
@@ -119,28 +103,35 @@ var OKRListProgress = common.Shortcut{
|
||||
}
|
||||
|
||||
var allProgress []*Progress
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("GET", apiPath, queryParams, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
itemsRaw, _ := data["items"].([]interface{})
|
||||
for _, item := range itemsRaw {
|
||||
raw, err := json.Marshal(item)
|
||||
data, err := runtime.CallAPITyped("GET", apiPath, queryParams, nil)
|
||||
if err != nil {
|
||||
continue
|
||||
return err
|
||||
}
|
||||
var progress Progress
|
||||
if err := json.Unmarshal(raw, &progress); err != nil {
|
||||
continue
|
||||
|
||||
itemsRaw, _ := data["items"].([]interface{})
|
||||
for _, item := range itemsRaw {
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var progress Progress
|
||||
if err := json.Unmarshal(raw, &progress); err != nil {
|
||||
continue
|
||||
}
|
||||
allProgress = append(allProgress, &progress)
|
||||
}
|
||||
allProgress = append(allProgress, &progress)
|
||||
|
||||
hasMore, pageToken := common.PaginationMeta(data)
|
||||
if !hasMore || pageToken == "" {
|
||||
break
|
||||
}
|
||||
queryParams["page_token"] = pageToken
|
||||
}
|
||||
hasMore, pageToken := common.PaginationMeta(data)
|
||||
|
||||
// Convert to response format
|
||||
respProgress := make([]*RespProgress, 0, len(allProgress))
|
||||
@@ -150,8 +141,7 @@ var OKRListProgress = common.Shortcut{
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"progress_list": respProgress,
|
||||
"has_more": hasMore,
|
||||
"page_token": pageToken,
|
||||
"total": len(respProgress),
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Found %d progress(es)\n", len(respProgress))
|
||||
for _, p := range respProgress {
|
||||
|
||||
@@ -5,14 +5,11 @@ package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -126,28 +123,6 @@ func TestProgressListValidate_InvalidDepartmentIDType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressListValidate_InvalidPageSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, progressListTestConfig(t))
|
||||
err := runProgressListShortcut(t, f, stdout, []string{
|
||||
"+progress-list",
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
"--page-size", "0",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --page-size")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected validation invalid_argument problem, got: %v", err)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok || validationErr.Param != "--page-size" {
|
||||
t.Fatalf("expected param --page-size, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- DryRun tests ---
|
||||
|
||||
func TestProgressListDryRun_Objective(t *testing.T) {
|
||||
@@ -169,9 +144,6 @@ func TestProgressListDryRun_Objective(t *testing.T) {
|
||||
if !strings.Contains(output, "GET") {
|
||||
t.Fatalf("dry-run output should contain GET method, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "\"page_size\": 100") {
|
||||
t.Fatalf("dry-run output should contain default page_size=100, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressListDryRun_KeyResult(t *testing.T) {
|
||||
@@ -192,41 +164,14 @@ func TestProgressListDryRun_KeyResult(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressListDryRun_WithPagination(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, progressListTestConfig(t))
|
||||
err := runProgressListShortcut(t, f, stdout, []string{
|
||||
"+progress-list",
|
||||
"--target-id", "123456789",
|
||||
"--target-type", "objective",
|
||||
"--page-size", "25",
|
||||
"--page-token", "next-page",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "\"page_size\": 25") {
|
||||
t.Fatalf("dry-run output should contain page_size=25, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "\"page_token\": \"next-page\"") {
|
||||
t.Fatalf("dry-run output should contain page_token, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Execute tests ---
|
||||
|
||||
func TestProgressListExecute_Success_Objective(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, progressListTestConfig(t))
|
||||
var gotQuery url.Values
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/123456789/progresses",
|
||||
OnMatch: func(req *http.Request) {
|
||||
gotQuery = req.URL.Query()
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
@@ -246,8 +191,7 @@ func TestProgressListExecute_Success_Objective(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "next_page",
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -255,32 +199,15 @@ func TestProgressListExecute_Success_Objective(t *testing.T) {
|
||||
"+progress-list",
|
||||
"--target-id", "123456789",
|
||||
"--target-type", "objective",
|
||||
"--page-size", "50",
|
||||
"--page-token", "start_page",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := gotQuery.Get("page_size"); got != "50" {
|
||||
t.Fatalf("query page_size = %q, want 50", got)
|
||||
}
|
||||
if got := gotQuery.Get("page_token"); got != "start_page" {
|
||||
t.Fatalf("query page_token = %q, want start_page", got)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
records, _ := data["progress_list"].([]interface{})
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("expected 1 progress, got %d", len(records))
|
||||
}
|
||||
if _, ok := data["total"]; ok {
|
||||
t.Fatal("total should not be present in response")
|
||||
}
|
||||
if hasMore, _ := data["has_more"].(bool); !hasMore {
|
||||
t.Fatalf("has_more = %v, want true", hasMore)
|
||||
}
|
||||
if pageToken, _ := data["page_token"].(string); pageToken != "next_page" {
|
||||
t.Fatalf("page_token = %q, want next_page", pageToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressListExecute_Success_KeyResult(t *testing.T) {
|
||||
|
||||
@@ -18,7 +18,6 @@ func Shortcuts() []common.Shortcut {
|
||||
OKRUpdateProgressRecord,
|
||||
OKRDeleteProgressRecord,
|
||||
OKRUploadImage,
|
||||
OKRCreate,
|
||||
OKRBatchCreate,
|
||||
OKRReorder,
|
||||
OKRWeight,
|
||||
|
||||
@@ -12,12 +12,6 @@ import (
|
||||
func TestShortcutsRegistration(t *testing.T) {
|
||||
convey.Convey("Shortcuts() returns all commands", t, func() {
|
||||
list := Shortcuts()
|
||||
commands := make([]string, 0, len(list))
|
||||
for _, shortcut := range list {
|
||||
commands = append(commands, shortcut.Command)
|
||||
}
|
||||
convey.So(commands, convey.ShouldContain, "+create")
|
||||
convey.So(commands, convey.ShouldContain, "+batch-create")
|
||||
convey.So(commands, convey.ShouldContain, "+patch")
|
||||
convey.So(len(list), convey.ShouldBeGreaterThan, 0)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
WhiteboardUpdate,
|
||||
WhiteboardUpdateOld,
|
||||
WhiteboardExport,
|
||||
WhiteboardQuery,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,728 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
const (
|
||||
// WhiteboardExportAsPreview exports a whiteboard preview image.
|
||||
WhiteboardExportAsPreview = "preview"
|
||||
// WhiteboardExportAsSvg exports a whiteboard as SVG.
|
||||
WhiteboardExportAsSvg = "svg"
|
||||
// WhiteboardExportAsSource exports Mermaid or PlantUML source extracted from the whiteboard.
|
||||
WhiteboardExportAsSource = "source"
|
||||
// WhiteboardExportAsRaw exports the raw whiteboard node payload.
|
||||
WhiteboardExportAsRaw = "raw"
|
||||
|
||||
// Legacy output type names accepted for backward compatibility.
|
||||
WhiteboardQueryAsImage = "image"
|
||||
// WhiteboardQueryAsSvg is deprecated; use WhiteboardExportAsSvg.
|
||||
WhiteboardQueryAsSvg = WhiteboardExportAsSvg
|
||||
WhiteboardQueryAsCode = "code"
|
||||
// WhiteboardQueryAsRaw is deprecated; use WhiteboardExportAsRaw.
|
||||
WhiteboardQueryAsRaw = WhiteboardExportAsRaw
|
||||
)
|
||||
|
||||
// SyntaxType identifies the diagram syntax extracted from whiteboard code blocks.
|
||||
type SyntaxType int
|
||||
|
||||
const (
|
||||
// SyntaxTypePlantUML marks PlantUML code blocks.
|
||||
SyntaxTypePlantUML SyntaxType = 1
|
||||
// SyntaxTypeMermaid marks Mermaid code blocks.
|
||||
SyntaxTypeMermaid SyntaxType = 2
|
||||
)
|
||||
|
||||
// SyntaxTypeNameMap maps whiteboard syntax types to their CLI output names.
|
||||
var SyntaxTypeNameMap = map[SyntaxType]string{
|
||||
SyntaxTypePlantUML: "plantuml",
|
||||
SyntaxTypeMermaid: "mermaid",
|
||||
}
|
||||
|
||||
// SyntaxTypeExtensionMap maps whiteboard syntax types to their default file extensions.
|
||||
var SyntaxTypeExtensionMap = map[SyntaxType]string{
|
||||
SyntaxTypePlantUML: ".puml",
|
||||
SyntaxTypeMermaid: ".mmd",
|
||||
}
|
||||
|
||||
// String returns the CLI-facing name for the syntax type.
|
||||
func (s SyntaxType) String() string {
|
||||
return SyntaxTypeNameMap[s]
|
||||
}
|
||||
|
||||
// ExtensionName returns the default file extension for the syntax type.
|
||||
func (s SyntaxType) ExtensionName() string {
|
||||
return SyntaxTypeExtensionMap[s]
|
||||
}
|
||||
|
||||
// IsValid reports whether the syntax type is one of the supported whiteboard code syntaxes.
|
||||
func (s SyntaxType) IsValid() bool {
|
||||
return s == SyntaxTypePlantUML || s == SyntaxTypeMermaid
|
||||
}
|
||||
|
||||
var wbExportScopes = []string{"board:whiteboard:node:read"}
|
||||
var wbExportAuthTypes = []string{"user", "bot"}
|
||||
var wbExportFlags = []common.Flag{
|
||||
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard. You will need read permission to download preview image.", Required: true},
|
||||
{Name: "output-type", Desc: "output whiteboard as: preview | svg | source | raw.", Required: true, Enum: []string{"preview", "svg", "source", "raw"}},
|
||||
{Name: "output", Desc: "output path. It is required when --output-type preview. If not specified when --output-type svg/source/raw, it will output directly.", Required: false},
|
||||
{Name: "overwrite", Desc: "overwrite existing file if it exists", Required: false, Type: "bool"},
|
||||
}
|
||||
|
||||
var wbQueryFlags = []common.Flag{
|
||||
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard. You will need read permission to download preview image.", Required: true},
|
||||
{Name: "output_as", Desc: "output whiteboard as: image | svg | code | raw.", Required: true, Enum: []string{"image", "svg", "code", "raw"}},
|
||||
{Name: "output", Desc: "output path. It is required when output as image. If not specified when --output_as svg/code/raw, it will output directly.", Required: false},
|
||||
{Name: "overwrite", Desc: "overwrite existing file if it exists", Required: false, Type: "bool"},
|
||||
}
|
||||
|
||||
func wbExportOutputType(runtime *common.RuntimeContext) (string, string) {
|
||||
normalized, ok := normalizeWhiteboardExportOutputType(runtime.Str("output-type"))
|
||||
if !ok {
|
||||
return "", "--output-type"
|
||||
}
|
||||
return normalized, "--output-type"
|
||||
}
|
||||
|
||||
func wbQueryOutputType(runtime *common.RuntimeContext) (string, string) {
|
||||
normalized, ok := normalizeLegacyWhiteboardExportOutputType(runtime.Str("output_as"))
|
||||
if !ok {
|
||||
return "", "--output_as"
|
||||
}
|
||||
return normalized, "--output_as"
|
||||
}
|
||||
|
||||
func normalizeWhiteboardExportOutputType(outputType string) (string, bool) {
|
||||
switch outputType {
|
||||
case WhiteboardExportAsPreview:
|
||||
return WhiteboardExportAsPreview, true
|
||||
case WhiteboardExportAsSvg:
|
||||
return WhiteboardExportAsSvg, true
|
||||
case WhiteboardExportAsSource:
|
||||
return WhiteboardExportAsSource, true
|
||||
case WhiteboardExportAsRaw:
|
||||
return WhiteboardExportAsRaw, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLegacyWhiteboardExportOutputType(outputType string) (string, bool) {
|
||||
switch outputType {
|
||||
case WhiteboardQueryAsImage:
|
||||
return WhiteboardExportAsPreview, true
|
||||
case WhiteboardQueryAsCode:
|
||||
return WhiteboardExportAsSource, true
|
||||
default:
|
||||
return normalizeWhiteboardExportOutputType(outputType)
|
||||
}
|
||||
}
|
||||
|
||||
func wbExportOutputTypeError(param string) *errs.ValidationError {
|
||||
if param == "--output_as" {
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--output_as flag must be one of: image | svg | code | raw",
|
||||
).WithParam("--output_as")
|
||||
}
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--output-type flag must be one of: preview | svg | source | raw",
|
||||
).WithParam("--output-type")
|
||||
}
|
||||
|
||||
func wbExportValidate(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return wbExportValidateWithOutputType(ctx, runtime, wbExportOutputType)
|
||||
}
|
||||
|
||||
func wbQueryValidate(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return wbExportValidateWithOutputType(ctx, runtime, wbQueryOutputType)
|
||||
}
|
||||
|
||||
func wbExportValidateWithOutputType(ctx context.Context, runtime *common.RuntimeContext, outputTypeFn func(*common.RuntimeContext) (string, string)) error {
|
||||
// Check if token contains control characters
|
||||
token := runtime.Str("whiteboard-token")
|
||||
if err := common.RejectDangerousCharsTyped("--whiteboard-token", token); err != nil {
|
||||
return err
|
||||
}
|
||||
outputType, outputTypeParam := outputTypeFn(runtime)
|
||||
if outputType == "" {
|
||||
return wbExportOutputTypeError(outputTypeParam)
|
||||
}
|
||||
|
||||
out := runtime.Str("output")
|
||||
if out != "" {
|
||||
if _, err := runtime.ResolveSavePath(out); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
}
|
||||
if out == "" && outputType == WhiteboardExportAsPreview {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "need a output path to export whiteboard as preview").WithParam("--output")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func wbExportDryRun(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return wbExportDryRunWithOutputType(ctx, runtime, wbExportOutputType)
|
||||
}
|
||||
|
||||
func wbQueryDryRun(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return wbExportDryRunWithOutputType(ctx, runtime, wbQueryOutputType)
|
||||
}
|
||||
|
||||
func wbExportDryRunWithOutputType(ctx context.Context, runtime *common.RuntimeContext, outputTypeFn func(*common.RuntimeContext) (string, string)) *common.DryRunAPI {
|
||||
outputType, outputTypeParam := outputTypeFn(runtime)
|
||||
token := runtime.Str("whiteboard-token")
|
||||
switch outputType {
|
||||
case WhiteboardExportAsPreview:
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", common.MaskToken(url.PathEscape(token)))).
|
||||
Desc("Export preview image of given whiteboard")
|
||||
case WhiteboardExportAsSource:
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).
|
||||
Desc("Extract Mermaid/Plantuml source from given whiteboard")
|
||||
case WhiteboardExportAsRaw:
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).
|
||||
Desc("Extract raw nodes structure from given whiteboard")
|
||||
case WhiteboardExportAsSvg:
|
||||
return common.NewDryRunAPI().
|
||||
POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", common.MaskToken(url.PathEscape(token)))).
|
||||
Body(map[string]string{"export_type": "svg"}).
|
||||
Desc("Export SVG of given whiteboard")
|
||||
default:
|
||||
if outputTypeParam == "--output_as" {
|
||||
return common.NewDryRunAPI().Desc("invalid --output_as flag, must be one of: image | svg | code | raw")
|
||||
}
|
||||
return common.NewDryRunAPI().Desc("invalid --output-type flag, must be one of: preview | svg | source | raw")
|
||||
}
|
||||
}
|
||||
|
||||
func wbExportExecute(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return wbExportExecuteWithOutputType(ctx, runtime, wbExportOutputType)
|
||||
}
|
||||
|
||||
func wbQueryExecute(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return wbExportExecuteWithOutputType(ctx, runtime, wbQueryOutputType)
|
||||
}
|
||||
|
||||
func wbExportExecuteWithOutputType(ctx context.Context, runtime *common.RuntimeContext, outputTypeFn func(*common.RuntimeContext) (string, string)) error {
|
||||
token := runtime.Str("whiteboard-token")
|
||||
outDir := runtime.Str("output")
|
||||
outputType, outputTypeParam := outputTypeFn(runtime)
|
||||
switch outputType {
|
||||
case WhiteboardExportAsPreview:
|
||||
return exportWhiteboardPreview(ctx, runtime, token, outDir)
|
||||
case WhiteboardExportAsSvg:
|
||||
return exportWhiteboardSvg(runtime, token, outDir)
|
||||
case WhiteboardExportAsSource:
|
||||
return exportWhiteboardCode(runtime, token, outDir)
|
||||
case WhiteboardExportAsRaw:
|
||||
return exportWhiteboardRaw(runtime, token, outDir)
|
||||
default:
|
||||
return wbExportOutputTypeError(outputTypeParam)
|
||||
}
|
||||
}
|
||||
|
||||
const WhiteboardExportDescription = "Export an existing whiteboard as preview image, SVG, source code or raw nodes structure."
|
||||
|
||||
// WhiteboardExport registers the `whiteboard +export` shortcut.
|
||||
var WhiteboardExport = common.Shortcut{
|
||||
Service: "whiteboard",
|
||||
Command: "+export",
|
||||
Description: WhiteboardExportDescription,
|
||||
Risk: "read",
|
||||
Scopes: wbExportScopes,
|
||||
AuthTypes: wbExportAuthTypes,
|
||||
Flags: wbExportFlags,
|
||||
HasFormat: true,
|
||||
Validate: wbExportValidate,
|
||||
DryRun: wbExportDryRun,
|
||||
Execute: wbExportExecute,
|
||||
}
|
||||
|
||||
// WhiteboardQuery registers the hidden, backward-compatible `whiteboard +query` shortcut.
|
||||
var WhiteboardQuery = common.Shortcut{
|
||||
Service: "whiteboard",
|
||||
Command: "+query",
|
||||
Description: WhiteboardExportDescription,
|
||||
Risk: "read",
|
||||
Scopes: wbExportScopes,
|
||||
AuthTypes: wbExportAuthTypes,
|
||||
Flags: wbQueryFlags,
|
||||
HasFormat: true,
|
||||
Hidden: true,
|
||||
Validate: wbQueryValidate,
|
||||
DryRun: wbQueryDryRun,
|
||||
Execute: wbQueryExecute,
|
||||
}
|
||||
|
||||
// exportReq defines the request body for whiteboard export APIs.
|
||||
type exportReq struct {
|
||||
ExportType string `json:"export_type"`
|
||||
}
|
||||
|
||||
// exportResp models the whiteboard export response envelope.
|
||||
type exportResp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
Content string `json:"content"`
|
||||
MimeType string `json:"mime_type"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// exportWhiteboardSvg exports a whiteboard as SVG and writes it to stdout or a file.
|
||||
func exportWhiteboardSvg(runtime *common.RuntimeContext, wbToken, outDir string) error {
|
||||
reqBody := exportReq{ExportType: "svg"}
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", url.PathEscape(wbToken)),
|
||||
Body: reqBody,
|
||||
}
|
||||
|
||||
resp, err := runtime.DoAPI(req)
|
||||
if err != nil {
|
||||
return wrapWbNetworkErr(err, "export whiteboard svg failed: %v", err)
|
||||
}
|
||||
|
||||
var exportData exportResp
|
||||
if err := json.Unmarshal(resp.RawBody, &exportData); err == nil {
|
||||
if exportData.Code != 0 {
|
||||
subtype := errs.SubtypeUnknown
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
return errs.NewAPIError(subtype, "export whiteboard svg failed: %s", exportData.Msg).WithCode(exportData.Code)
|
||||
}
|
||||
} else if resp.StatusCode == http.StatusOK {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "parse export response failed: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body := common.TruncateStr(strings.TrimSpace(string(resp.RawBody)), 500)
|
||||
if resp.StatusCode >= 500 {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkServer, "export whiteboard svg failed: HTTP %d: %s", resp.StatusCode, body).
|
||||
WithCode(resp.StatusCode).
|
||||
WithRetryable()
|
||||
}
|
||||
subtype := errs.SubtypeUnknown
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
return errs.NewAPIError(subtype, "export whiteboard svg failed: HTTP %d: %s", resp.StatusCode, body).
|
||||
WithCode(resp.StatusCode)
|
||||
}
|
||||
|
||||
svgBytes, err := base64.StdEncoding.DecodeString(exportData.Data.Content)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "decode svg base64 failed: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
if outDir == "" {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"svg_content": string(svgBytes),
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%s\n", string(svgBytes))
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
finalPath, size, err := saveOutputFile(outDir, ".svg", wbToken, runtime, bytes.NewReader(svgBytes))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"svg_path": finalPath,
|
||||
"size_bytes": size,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "SVG saved to %s\n", finalPath)
|
||||
fmt.Fprintf(w, "File size: %d bytes", size)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func exportWhiteboardPreview(ctx context.Context, runtime *common.RuntimeContext, wbToken, outDir string) error {
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", url.PathEscape(wbToken)),
|
||||
}
|
||||
// Execute API request. The preview endpoint streams raw image bytes (not a
|
||||
// JSON envelope), so classify by HTTP status: 5xx is retryable network,
|
||||
// while 4xx remains an API-side rejection.
|
||||
resp, err := runtime.DoAPI(req, larkcore.WithFileDownload())
|
||||
if err != nil {
|
||||
return wrapWbNetworkErr(err, "get whiteboard preview failed: %v", err)
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
body := common.TruncateStr(strings.TrimSpace(string(resp.RawBody)), 500)
|
||||
if resp.StatusCode >= 500 {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkServer, "get whiteboard preview failed: HTTP %d: %s", resp.StatusCode, body).
|
||||
WithCode(resp.StatusCode).
|
||||
WithRetryable()
|
||||
}
|
||||
subtype := errs.SubtypeUnknown
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
return errs.NewAPIError(subtype, "get whiteboard preview failed: HTTP %d: %s", resp.StatusCode, body).
|
||||
WithCode(resp.StatusCode)
|
||||
}
|
||||
|
||||
finalPath, size, err := saveWhiteboardPreviewOutput(outDir, wbToken, runtime, resp.Header, bytes.NewReader(resp.RawBody))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"preview_image_path": finalPath,
|
||||
"size_bytes": size,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Preview image saved to %s\n", finalPath)
|
||||
fmt.Fprintf(w, "Image size: %d bytes", size)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
type wbNodesResp struct {
|
||||
Data struct {
|
||||
Nodes []interface{} `json:"nodes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func fetchWhiteboardNodes(runtime *common.RuntimeContext, wbToken string) (*wbNodesResp, error) {
|
||||
data, err := runtime.CallAPITyped(http.MethodGet, fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(wbToken)), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var nodes wbNodesResp
|
||||
rawNodes, _ := data["nodes"]
|
||||
if rawNodes != nil {
|
||||
var ok bool
|
||||
nodes.Data.Nodes, ok = rawNodes.([]interface{})
|
||||
if !ok {
|
||||
return nil, wbInvalidResponse("get whiteboard nodes failed: data.nodes must be an array")
|
||||
}
|
||||
}
|
||||
return &nodes, nil
|
||||
}
|
||||
|
||||
type syntaxInfo struct {
|
||||
code string
|
||||
syntaxType SyntaxType
|
||||
}
|
||||
|
||||
func exportWhiteboardCode(runtime *common.RuntimeContext, wbToken, outDir string) error {
|
||||
wbNodes, err := fetchWhiteboardNodes(runtime, wbToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if wbNodes == nil || wbNodes.Data.Nodes == nil {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"msg": "whiteboard is empty",
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Whiteboard is empty\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
var syntaxBlocks []syntaxInfo
|
||||
for _, node := range wbNodes.Data.Nodes {
|
||||
nodeMap, ok := node.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
syntax, ok := nodeMap["syntax"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
syntaxMap, ok := syntax.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
code, _ := syntaxMap["code"].(string)
|
||||
var syntaxType SyntaxType
|
||||
switch v := syntaxMap["syntax_type"].(type) {
|
||||
case json.Number:
|
||||
// runtime.ClassifyAPIResponse decodes the response with UseNumber,
|
||||
// so numeric fields arrive as json.Number rather than float64.
|
||||
if n, err := v.Int64(); err == nil {
|
||||
syntaxType = SyntaxType(n)
|
||||
}
|
||||
case float64:
|
||||
syntaxType = SyntaxType(v)
|
||||
case SyntaxType:
|
||||
syntaxType = v
|
||||
}
|
||||
if code != "" && syntaxType.IsValid() {
|
||||
syntaxBlocks = append(syntaxBlocks, syntaxInfo{code: code, syntaxType: syntaxType})
|
||||
}
|
||||
}
|
||||
|
||||
if len(syntaxBlocks) == 0 {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"msg": "no code blocks found in whiteboard",
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "No code blocks found in whiteboard\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
// 目前的标准操作是导出到单一文件,和 Doc 展示画板代码块采用相同的逻辑
|
||||
// 如果有需求,可以调整到导出到多个文件的模式
|
||||
if len(syntaxBlocks) > 1 {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"msg": "multiple code blocks found, cannot export directly",
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Multiple code blocks found, cannot export directly\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
block := syntaxBlocks[0]
|
||||
|
||||
if outDir == "" {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"code": block.code,
|
||||
"syntax_type": block.syntaxType.String(),
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%s\n", block.code)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
finalPath, _, err := saveOutputFile(outDir, block.syntaxType.ExtensionName(), wbToken, runtime, strings.NewReader(block.code))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"output_path": finalPath,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Whiteboard code saved to %s\n", finalPath)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func exportWhiteboardRaw(runtime *common.RuntimeContext, wbToken, outDir string) error {
|
||||
wbNodes, err := fetchWhiteboardNodes(runtime, wbToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if wbNodes == nil || wbNodes.Data.Nodes == nil {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"msg": "whiteboard is empty",
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Whiteboard is empty\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
jsonData, err := json.MarshalIndent(wbNodes.Data, "", " ")
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "cannot marshal whiteboard data: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
if outDir == "" {
|
||||
runtime.OutFormat(wbNodes.Data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%s\n", string(jsonData))
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
finalPath, _, err := saveOutputFile(outDir, ".json", wbToken, runtime, bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"output_path": finalPath,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Whiteboard raw node structure saved to %s\n", finalPath)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveOutputFile(outPath, ext, token string, runtime *common.RuntimeContext, data io.Reader) (string, int64, error) {
|
||||
// Step 1: Get final output path
|
||||
info, err := runtime.FileIO().Stat(outPath)
|
||||
var finalPath string
|
||||
if err == nil && info.IsDir() {
|
||||
finalPath = filepath.Join(outPath, fmt.Sprintf("whiteboard_%s%s", token, ext))
|
||||
} else {
|
||||
// Fix extension in path
|
||||
currentExt := filepath.Ext(outPath)
|
||||
if currentExt != ext {
|
||||
if currentExt != "" {
|
||||
outPath = outPath[:len(outPath)-len(currentExt)]
|
||||
}
|
||||
outPath += ext
|
||||
}
|
||||
finalPath = outPath
|
||||
}
|
||||
if _, err := runtime.ResolveSavePath(finalPath); err != nil { // double check
|
||||
return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
|
||||
// Step 2: Check overwrite
|
||||
_, err = runtime.FileIO().Stat(finalPath)
|
||||
if err == nil {
|
||||
if !runtime.Bool("overwrite") {
|
||||
return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "file already exists: %s (use --overwrite to overwrite)", finalPath).WithParam("--overwrite")
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return "", 0, errs.NewInternalError(errs.SubtypeFileIO, "cannot check file existence: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
// Step 3: Save file
|
||||
var contentType string
|
||||
switch ext {
|
||||
case ".png":
|
||||
contentType = "image/png"
|
||||
case ".jpg", ".jpeg":
|
||||
contentType = "image/jpeg"
|
||||
case ".svg":
|
||||
contentType = "image/svg+xml"
|
||||
case ".json":
|
||||
contentType = "application/json"
|
||||
case ".mmd", ".puml":
|
||||
contentType = "text/plain"
|
||||
}
|
||||
|
||||
savResult, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
|
||||
ContentType: contentType,
|
||||
}, data)
|
||||
if err != nil {
|
||||
return "", 0, wbSaveError(err)
|
||||
}
|
||||
|
||||
return finalPath, savResult.Size(), nil
|
||||
}
|
||||
|
||||
var whiteboardPreviewContentTypeExt = map[string]string{
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
}
|
||||
|
||||
func saveWhiteboardPreviewOutput(outPath, token string, runtime *common.RuntimeContext, header http.Header, data io.Reader) (string, int64, error) {
|
||||
contentType := header.Get("Content-Type")
|
||||
ext, err := whiteboardPreviewExtFromContentType(contentType)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
finalPath, err := whiteboardPreviewOutputPath(outPath, ext, token, runtime)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return saveResolvedOutputFile(finalPath, contentType, runtime, data)
|
||||
}
|
||||
|
||||
func whiteboardPreviewExtFromContentType(contentType string) (string, error) {
|
||||
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
mediaType = strings.TrimSpace(strings.Split(contentType, ";")[0])
|
||||
}
|
||||
if ext, ok := whiteboardPreviewContentTypeExt[strings.ToLower(mediaType)]; ok {
|
||||
return ext, nil
|
||||
}
|
||||
if strings.TrimSpace(contentType) == "" {
|
||||
contentType = "<empty>"
|
||||
}
|
||||
return "", errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"get whiteboard preview failed: expected image/png or image/jpeg response, got Content-Type: %s",
|
||||
contentType,
|
||||
)
|
||||
}
|
||||
|
||||
func whiteboardPreviewOutputPath(outPath, ext, token string, runtime *common.RuntimeContext) (string, error) {
|
||||
info, err := runtime.FileIO().Stat(outPath)
|
||||
if err == nil && info.IsDir() {
|
||||
finalPath := filepath.Join(outPath, fmt.Sprintf("whiteboard_%s%s", token, ext))
|
||||
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
return finalPath, nil
|
||||
}
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return "", errs.NewInternalError(errs.SubtypeFileIO, "cannot check output path: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
currentExt := strings.ToLower(filepath.Ext(outPath))
|
||||
if currentExt == "" || currentExt == "." {
|
||||
finalPath := strings.TrimSuffix(outPath, ".") + ext
|
||||
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
return finalPath, nil
|
||||
}
|
||||
if !isWhiteboardPreviewImageExt(currentExt) {
|
||||
return "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"invalid preview output extension %q; use .png, .jpg, .jpeg, a directory, or a path without extension",
|
||||
currentExt,
|
||||
).WithParam("--output")
|
||||
}
|
||||
if !whiteboardPreviewExtMatches(currentExt, ext) {
|
||||
return "", errs.NewValidationError(
|
||||
errs.SubtypeFailedPrecondition,
|
||||
"preview response is %s but output path has extension %s; use a matching extension or omit the extension",
|
||||
ext,
|
||||
currentExt,
|
||||
).WithParam("--output")
|
||||
}
|
||||
if _, err := runtime.ResolveSavePath(outPath); err != nil {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
return outPath, nil
|
||||
}
|
||||
|
||||
func isWhiteboardPreviewImageExt(ext string) bool {
|
||||
return ext == ".png" || ext == ".jpg" || ext == ".jpeg"
|
||||
}
|
||||
|
||||
func whiteboardPreviewExtMatches(outputExt, responseExt string) bool {
|
||||
if responseExt == ".jpg" {
|
||||
return outputExt == ".jpg" || outputExt == ".jpeg"
|
||||
}
|
||||
return outputExt == responseExt
|
||||
}
|
||||
|
||||
func saveResolvedOutputFile(finalPath, contentType string, runtime *common.RuntimeContext, data io.Reader) (string, int64, error) {
|
||||
_, err := runtime.FileIO().Stat(finalPath)
|
||||
if err == nil {
|
||||
if !runtime.Bool("overwrite") {
|
||||
return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "file already exists: %s (use --overwrite to overwrite)", finalPath).WithParam("--overwrite")
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return "", 0, errs.NewInternalError(errs.SubtypeFileIO, "cannot check file existence: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
savResult, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
|
||||
ContentType: contentType,
|
||||
}, data)
|
||||
if err != nil {
|
||||
return "", 0, wbSaveError(err)
|
||||
}
|
||||
return finalPath, savResult.Size(), nil
|
||||
}
|
||||
494
shortcuts/whiteboard/whiteboard_query.go
Normal file
494
shortcuts/whiteboard/whiteboard_query.go
Normal file
@@ -0,0 +1,494 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
const (
|
||||
// WhiteboardQueryAsImage exports a whiteboard preview image.
|
||||
WhiteboardQueryAsImage = "image"
|
||||
// WhiteboardQueryAsSvg exports a whiteboard as SVG.
|
||||
WhiteboardQueryAsSvg = "svg"
|
||||
// WhiteboardQueryAsCode exports Mermaid or PlantUML source extracted from the whiteboard.
|
||||
WhiteboardQueryAsCode = "code"
|
||||
// WhiteboardQueryAsRaw exports the raw whiteboard node payload.
|
||||
WhiteboardQueryAsRaw = "raw"
|
||||
)
|
||||
|
||||
// SyntaxType identifies the diagram syntax extracted from whiteboard code blocks.
|
||||
type SyntaxType int
|
||||
|
||||
const (
|
||||
// SyntaxTypePlantUML marks PlantUML code blocks.
|
||||
SyntaxTypePlantUML SyntaxType = 1
|
||||
// SyntaxTypeMermaid marks Mermaid code blocks.
|
||||
SyntaxTypeMermaid SyntaxType = 2
|
||||
)
|
||||
|
||||
// SyntaxTypeNameMap maps whiteboard syntax types to their CLI output names.
|
||||
var SyntaxTypeNameMap = map[SyntaxType]string{
|
||||
SyntaxTypePlantUML: "plantuml",
|
||||
SyntaxTypeMermaid: "mermaid",
|
||||
}
|
||||
|
||||
// SyntaxTypeExtensionMap maps whiteboard syntax types to their default file extensions.
|
||||
var SyntaxTypeExtensionMap = map[SyntaxType]string{
|
||||
SyntaxTypePlantUML: ".puml",
|
||||
SyntaxTypeMermaid: ".mmd",
|
||||
}
|
||||
|
||||
// String returns the CLI-facing name for the syntax type.
|
||||
func (s SyntaxType) String() string {
|
||||
return SyntaxTypeNameMap[s]
|
||||
}
|
||||
|
||||
// ExtensionName returns the default file extension for the syntax type.
|
||||
func (s SyntaxType) ExtensionName() string {
|
||||
return SyntaxTypeExtensionMap[s]
|
||||
}
|
||||
|
||||
// IsValid reports whether the syntax type is one of the supported whiteboard code syntaxes.
|
||||
func (s SyntaxType) IsValid() bool {
|
||||
return s == SyntaxTypePlantUML || s == SyntaxTypeMermaid
|
||||
}
|
||||
|
||||
// WhiteboardQuery registers the `whiteboard +query` shortcut.
|
||||
var WhiteboardQuery = common.Shortcut{
|
||||
Service: "whiteboard",
|
||||
Command: "+query",
|
||||
Description: "Query a existing whiteboard, export it as preview image or raw nodes structure.",
|
||||
Risk: "read",
|
||||
Scopes: []string{"board:whiteboard:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard. You will need read permission to download preview image.", Required: true},
|
||||
{Name: "output_as", Desc: "output whiteboard as: image | svg | code | raw.", Required: true},
|
||||
{Name: "output", Desc: "output directory. It is required when output as image. If not specified when --output_as svg/code/raw, it will output directly.", Required: false},
|
||||
{Name: "overwrite", Desc: "overwrite existing file if it exists", Required: false, Type: "bool"},
|
||||
},
|
||||
HasFormat: true,
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// Check if token contains control characters
|
||||
token := runtime.Str("whiteboard-token")
|
||||
if err := common.RejectDangerousCharsTyped("--whiteboard-token", token); err != nil {
|
||||
return err
|
||||
}
|
||||
out := runtime.Str("output")
|
||||
if out != "" {
|
||||
if _, err := runtime.ResolveSavePath(out); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
}
|
||||
if out == "" && runtime.Str("output_as") == WhiteboardQueryAsImage {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "need a output directory to query whiteboard as image").WithParam("--output")
|
||||
}
|
||||
|
||||
as := runtime.Str("output_as")
|
||||
if as != WhiteboardQueryAsImage && as != WhiteboardQueryAsSvg && as != WhiteboardQueryAsCode && as != WhiteboardQueryAsRaw {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output_as flag must be one of: image | svg | code | raw").WithParam("--output_as")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
as := runtime.Str("output_as")
|
||||
token := runtime.Str("whiteboard-token")
|
||||
switch as {
|
||||
case WhiteboardQueryAsImage:
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", common.MaskToken(url.PathEscape(token)))).
|
||||
Desc("Export preview image of given whiteboard")
|
||||
case WhiteboardQueryAsCode:
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).
|
||||
Desc("Extract Mermaid/Plantuml code from given whiteboard")
|
||||
case WhiteboardQueryAsRaw:
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).
|
||||
Desc("Extract raw nodes structure from given whiteboard")
|
||||
case WhiteboardQueryAsSvg:
|
||||
return common.NewDryRunAPI().
|
||||
POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", common.MaskToken(url.PathEscape(token)))).
|
||||
Body(map[string]string{"export_type": "svg"}).
|
||||
Desc("Export SVG of given whiteboard")
|
||||
default:
|
||||
return common.NewDryRunAPI().Desc("invalid --output_as flag, must be one of: image | svg | code | raw")
|
||||
}
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// 构建 API 请求
|
||||
token := runtime.Str("whiteboard-token")
|
||||
outDir := runtime.Str("output")
|
||||
as := runtime.Str("output_as")
|
||||
switch as {
|
||||
case WhiteboardQueryAsImage:
|
||||
return exportWhiteboardPreview(ctx, runtime, token, outDir)
|
||||
case WhiteboardQueryAsSvg:
|
||||
return exportWhiteboardSvg(runtime, token, outDir)
|
||||
case WhiteboardQueryAsCode:
|
||||
return exportWhiteboardCode(runtime, token, outDir)
|
||||
case WhiteboardQueryAsRaw:
|
||||
return exportWhiteboardRaw(runtime, token, outDir)
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output_as flag must be one of: image | svg | code | raw").WithParam("--output_as")
|
||||
}
|
||||
|
||||
},
|
||||
}
|
||||
|
||||
// exportReq defines the request body for whiteboard export APIs.
|
||||
type exportReq struct {
|
||||
ExportType string `json:"export_type"`
|
||||
}
|
||||
|
||||
// exportResp models the whiteboard export response envelope.
|
||||
type exportResp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
Content string `json:"content"`
|
||||
MimeType string `json:"mime_type"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// exportWhiteboardSvg exports a whiteboard as SVG and writes it to stdout or a file.
|
||||
func exportWhiteboardSvg(runtime *common.RuntimeContext, wbToken, outDir string) error {
|
||||
reqBody := exportReq{ExportType: "svg"}
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", url.PathEscape(wbToken)),
|
||||
Body: reqBody,
|
||||
}
|
||||
|
||||
resp, err := runtime.DoAPI(req)
|
||||
if err != nil {
|
||||
return wrapWbNetworkErr(err, "export whiteboard svg failed: %v", err)
|
||||
}
|
||||
|
||||
var exportData exportResp
|
||||
if err := json.Unmarshal(resp.RawBody, &exportData); err == nil {
|
||||
if exportData.Code != 0 {
|
||||
subtype := errs.SubtypeUnknown
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
return errs.NewAPIError(subtype, "export whiteboard svg failed: %s", exportData.Msg).WithCode(exportData.Code)
|
||||
}
|
||||
} else if resp.StatusCode == http.StatusOK {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "parse export response failed: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body := common.TruncateStr(strings.TrimSpace(string(resp.RawBody)), 500)
|
||||
if resp.StatusCode >= 500 {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkServer, "export whiteboard svg failed: HTTP %d: %s", resp.StatusCode, body).
|
||||
WithCode(resp.StatusCode).
|
||||
WithRetryable()
|
||||
}
|
||||
subtype := errs.SubtypeUnknown
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
return errs.NewAPIError(subtype, "export whiteboard svg failed: HTTP %d: %s", resp.StatusCode, body).
|
||||
WithCode(resp.StatusCode)
|
||||
}
|
||||
|
||||
svgBytes, err := base64.StdEncoding.DecodeString(exportData.Data.Content)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "decode svg base64 failed: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
if outDir == "" {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"svg_content": string(svgBytes),
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%s\n", string(svgBytes))
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
finalPath, size, err := saveOutputFile(outDir, ".svg", wbToken, runtime, bytes.NewReader(svgBytes))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"svg_path": finalPath,
|
||||
"size_bytes": size,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "SVG saved to %s\n", finalPath)
|
||||
fmt.Fprintf(w, "File size: %d bytes", size)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func exportWhiteboardPreview(ctx context.Context, runtime *common.RuntimeContext, wbToken, outDir string) error {
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", url.PathEscape(wbToken)),
|
||||
}
|
||||
// Execute API request. The preview endpoint streams raw image bytes (not a
|
||||
// JSON envelope), so classify by HTTP status: 5xx is retryable network,
|
||||
// while 4xx remains an API-side rejection.
|
||||
resp, err := runtime.DoAPI(req, larkcore.WithFileDownload())
|
||||
if err != nil {
|
||||
return wrapWbNetworkErr(err, "get whiteboard preview failed: %v", err)
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
body := common.TruncateStr(strings.TrimSpace(string(resp.RawBody)), 500)
|
||||
if resp.StatusCode >= 500 {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkServer, "get whiteboard preview failed: HTTP %d: %s", resp.StatusCode, body).
|
||||
WithCode(resp.StatusCode).
|
||||
WithRetryable()
|
||||
}
|
||||
subtype := errs.SubtypeUnknown
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
return errs.NewAPIError(subtype, "get whiteboard preview failed: HTTP %d: %s", resp.StatusCode, body).
|
||||
WithCode(resp.StatusCode)
|
||||
}
|
||||
|
||||
finalPath, size, err := saveOutputFile(outDir, ".png", wbToken, runtime, bytes.NewReader(resp.RawBody))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"preview_image_path": finalPath,
|
||||
"size_bytes": size,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Preview image saved to %s\n", finalPath)
|
||||
fmt.Fprintf(w, "Image size: %d bytes", size)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
type wbNodesResp struct {
|
||||
Data struct {
|
||||
Nodes []interface{} `json:"nodes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func fetchWhiteboardNodes(runtime *common.RuntimeContext, wbToken string) (*wbNodesResp, error) {
|
||||
data, err := runtime.CallAPITyped(http.MethodGet, fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(wbToken)), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var nodes wbNodesResp
|
||||
rawNodes, _ := data["nodes"]
|
||||
if rawNodes != nil {
|
||||
var ok bool
|
||||
nodes.Data.Nodes, ok = rawNodes.([]interface{})
|
||||
if !ok {
|
||||
return nil, wbInvalidResponse("get whiteboard nodes failed: data.nodes must be an array")
|
||||
}
|
||||
}
|
||||
return &nodes, nil
|
||||
}
|
||||
|
||||
type syntaxInfo struct {
|
||||
code string
|
||||
syntaxType SyntaxType
|
||||
}
|
||||
|
||||
func exportWhiteboardCode(runtime *common.RuntimeContext, wbToken, outDir string) error {
|
||||
wbNodes, err := fetchWhiteboardNodes(runtime, wbToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if wbNodes == nil || wbNodes.Data.Nodes == nil {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"msg": "whiteboard is empty",
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Whiteboard is empty\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
var syntaxBlocks []syntaxInfo
|
||||
for _, node := range wbNodes.Data.Nodes {
|
||||
nodeMap, ok := node.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
syntax, ok := nodeMap["syntax"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
syntaxMap, ok := syntax.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
code, _ := syntaxMap["code"].(string)
|
||||
var syntaxType SyntaxType
|
||||
switch v := syntaxMap["syntax_type"].(type) {
|
||||
case json.Number:
|
||||
// runtime.ClassifyAPIResponse decodes the response with UseNumber,
|
||||
// so numeric fields arrive as json.Number rather than float64.
|
||||
if n, err := v.Int64(); err == nil {
|
||||
syntaxType = SyntaxType(n)
|
||||
}
|
||||
case float64:
|
||||
syntaxType = SyntaxType(v)
|
||||
case SyntaxType:
|
||||
syntaxType = v
|
||||
}
|
||||
if code != "" && syntaxType.IsValid() {
|
||||
syntaxBlocks = append(syntaxBlocks, syntaxInfo{code: code, syntaxType: syntaxType})
|
||||
}
|
||||
}
|
||||
|
||||
if len(syntaxBlocks) == 0 {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"msg": "no code blocks found in whiteboard",
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "No code blocks found in whiteboard\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
// 目前的标准操作是导出到单一文件,和 Doc 展示画板代码块采用相同的逻辑
|
||||
// 如果有需求,可以调整到导出到多个文件的模式
|
||||
if len(syntaxBlocks) > 1 {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"msg": "multiple code blocks found, cannot export directly",
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Multiple code blocks found, cannot export directly\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
block := syntaxBlocks[0]
|
||||
|
||||
if outDir == "" {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"code": block.code,
|
||||
"syntax_type": block.syntaxType.String(),
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%s\n", block.code)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
finalPath, _, err := saveOutputFile(outDir, block.syntaxType.ExtensionName(), wbToken, runtime, strings.NewReader(block.code))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"output_path": finalPath,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Whiteboard code saved to %s\n", finalPath)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func exportWhiteboardRaw(runtime *common.RuntimeContext, wbToken, outDir string) error {
|
||||
wbNodes, err := fetchWhiteboardNodes(runtime, wbToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if wbNodes == nil || wbNodes.Data.Nodes == nil {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"msg": "whiteboard is empty",
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Whiteboard is empty\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
jsonData, err := json.MarshalIndent(wbNodes.Data, "", " ")
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "cannot marshal whiteboard data: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
if outDir == "" {
|
||||
runtime.OutFormat(wbNodes.Data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%s\n", string(jsonData))
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
finalPath, _, err := saveOutputFile(outDir, ".json", wbToken, runtime, bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"output_path": finalPath,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Whiteboard raw node structure saved to %s\n", finalPath)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveOutputFile(outPath, ext, token string, runtime *common.RuntimeContext, data io.Reader) (string, int64, error) {
|
||||
// Step 1: Get final output path
|
||||
info, err := runtime.FileIO().Stat(outPath)
|
||||
var finalPath string
|
||||
if err == nil && info.IsDir() {
|
||||
finalPath = filepath.Join(outPath, fmt.Sprintf("whiteboard_%s%s", token, ext))
|
||||
} else {
|
||||
// Fix extension in path
|
||||
currentExt := filepath.Ext(outPath)
|
||||
if currentExt != ext {
|
||||
if currentExt != "" {
|
||||
outPath = outPath[:len(outPath)-len(currentExt)]
|
||||
}
|
||||
outPath += ext
|
||||
}
|
||||
finalPath = outPath
|
||||
}
|
||||
if _, err := runtime.ResolveSavePath(finalPath); err != nil { // double check
|
||||
return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
|
||||
// Step 2: Check overwrite
|
||||
_, err = runtime.FileIO().Stat(finalPath)
|
||||
if err == nil {
|
||||
if !runtime.Bool("overwrite") {
|
||||
return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "file already exists: %s (use --overwrite to overwrite)", finalPath).WithParam("--overwrite")
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return "", 0, errs.NewInternalError(errs.SubtypeFileIO, "cannot check file existence: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
// Step 3: Save file
|
||||
var contentType string
|
||||
switch ext {
|
||||
case ".png":
|
||||
contentType = "image/png"
|
||||
case ".svg":
|
||||
contentType = "image/svg+xml"
|
||||
case ".json":
|
||||
contentType = "application/json"
|
||||
case ".mmd", ".puml":
|
||||
contentType = "text/plain"
|
||||
}
|
||||
|
||||
savResult, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
|
||||
ContentType: contentType,
|
||||
}, data)
|
||||
if err != nil {
|
||||
return "", 0, wbSaveError(err)
|
||||
}
|
||||
|
||||
return finalPath, savResult.Size(), nil
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -212,73 +211,6 @@ func TestWhiteboardQuery_Validate_TypedErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardExport_Validate verifies the canonical +export flag spelling
|
||||
// and output type names while legacy +query validation remains covered above.
|
||||
func TestWhiteboardExport_Validate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
chdirTemp(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
wantErr bool
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "valid: preview with output",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output-type": "preview",
|
||||
"output": "output",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid: source without output",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output-type": "source",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid: preview without output",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output-type": "preview",
|
||||
},
|
||||
wantErr: true,
|
||||
wantParam: "--output",
|
||||
},
|
||||
{
|
||||
name: "invalid: bad output-type value",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output-type": "image",
|
||||
},
|
||||
wantErr: true,
|
||||
wantParam: "--output-type",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := WhiteboardExport.Validate(ctx, newTestRuntime(tt.flags, nil))
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("WhiteboardExport.Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error is not *errs.ValidationError: %T", err)
|
||||
}
|
||||
if ve.Param != tt.wantParam {
|
||||
t.Fatalf("Param = %q, want %q", ve.Param, tt.wantParam)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportWhiteboardPreview_HTTPError locks the download-path failure
|
||||
// behavior: a failed preview download surfaces as a typed errs.* envelope, not
|
||||
// a flat legacy error.
|
||||
@@ -352,7 +284,7 @@ func TestWhiteboardQuery_DryRun(t *testing.T) {
|
||||
"output": "output.png",
|
||||
},
|
||||
wantMethod: "GET",
|
||||
wantPath: "/open-apis/board/v1/whiteboards/test...-123/download_as_image",
|
||||
wantPath: "/open-apis/board/v1/whiteboards/test-token-123/download_as_image",
|
||||
},
|
||||
{
|
||||
name: "dry run code",
|
||||
@@ -361,7 +293,7 @@ func TestWhiteboardQuery_DryRun(t *testing.T) {
|
||||
"output_as": "code",
|
||||
},
|
||||
wantMethod: "GET",
|
||||
wantPath: "/open-apis/board/v1/whiteboards/test...-123/nodes",
|
||||
wantPath: "/open-apis/board/v1/whiteboards/test-token-123/nodes",
|
||||
},
|
||||
{
|
||||
name: "dry run raw",
|
||||
@@ -370,7 +302,7 @@ func TestWhiteboardQuery_DryRun(t *testing.T) {
|
||||
"output_as": "raw",
|
||||
},
|
||||
wantMethod: "GET",
|
||||
wantPath: "/open-apis/board/v1/whiteboards/test...-123/nodes",
|
||||
wantPath: "/open-apis/board/v1/whiteboards/test-token-123/nodes",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -381,29 +313,6 @@ func TestWhiteboardQuery_DryRun(t *testing.T) {
|
||||
if dryRun == nil {
|
||||
t.Fatalf("WhiteboardQuery.DryRun() returned nil")
|
||||
}
|
||||
var got struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
data, err := json.Marshal(dryRun)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v; data=%s", err, string(data))
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("api len = %d, want 1; data=%s", len(got.API), string(data))
|
||||
}
|
||||
if got.API[0].Method != tt.wantMethod {
|
||||
t.Fatalf("method = %q, want %q; data=%s", got.API[0].Method, tt.wantMethod, string(data))
|
||||
}
|
||||
if got.API[0].URL != tt.wantPath {
|
||||
t.Fatalf("url = %q, want %q; data=%s", got.API[0].URL, tt.wantPath, string(data))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -482,32 +391,6 @@ func TestWhiteboardQuery_ShortcutRegistration(t *testing.T) {
|
||||
if len(WhiteboardQuery.Flags) == 0 {
|
||||
t.Errorf("WhiteboardQuery.Flags is empty, expected at least one flag")
|
||||
}
|
||||
if !WhiteboardQuery.Hidden {
|
||||
t.Errorf("WhiteboardQuery should be hidden because +export is the canonical command")
|
||||
}
|
||||
|
||||
// Verify WhiteboardExport is the visible canonical shortcut.
|
||||
if WhiteboardExport.Command != "+export" {
|
||||
t.Errorf("WhiteboardExport.Command = %q, want \"+export\"", WhiteboardExport.Command)
|
||||
}
|
||||
if WhiteboardExport.Service != "whiteboard" {
|
||||
t.Errorf("WhiteboardExport.Service = %q, want \"whiteboard\"", WhiteboardExport.Service)
|
||||
}
|
||||
if WhiteboardExport.Hidden {
|
||||
t.Errorf("WhiteboardExport should be visible")
|
||||
}
|
||||
if flag := shortcutFlag(WhiteboardExport, "output_as"); flag != nil {
|
||||
t.Errorf("WhiteboardExport --output_as should not be registered; got %#v", *flag)
|
||||
}
|
||||
if flag := shortcutFlag(WhiteboardExport, "output-type"); flag == nil || flag.Hidden {
|
||||
t.Errorf("WhiteboardExport --output-type should exist and be visible")
|
||||
}
|
||||
if flag := shortcutFlag(WhiteboardQuery, "output_as"); flag == nil || flag.Hidden {
|
||||
t.Errorf("WhiteboardQuery --output_as should exist and remain visible on the hidden legacy command")
|
||||
}
|
||||
if flag := shortcutFlag(WhiteboardQuery, "output-type"); flag != nil {
|
||||
t.Errorf("WhiteboardQuery --output-type should not be registered; got %#v", *flag)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveOutputFile verifies output saving, overwrite handling, and extension-specific paths.
|
||||
@@ -979,11 +862,10 @@ func TestExportWhiteboardPreview(t *testing.T) {
|
||||
|
||||
// Mock download preview image API response with RawBody
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-preview/download_as_image",
|
||||
Status: 200,
|
||||
RawBody: []byte("fake PNG image data"),
|
||||
ContentType: "image/png",
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-preview/download_as_image",
|
||||
Status: 200,
|
||||
RawBody: []byte("fake PNG image data"),
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-preview", "--output_as", "image", "--output", "output", "--overwrite"}
|
||||
@@ -1001,158 +883,6 @@ func TestExportWhiteboardPreview(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportWhiteboardPreview_UsesContentTypeExtension verifies preview image
|
||||
// downloads are saved according to the API response Content-Type rather than a
|
||||
// hard-coded PNG suffix.
|
||||
func TestExportWhiteboardPreview_UsesContentTypeExtension(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
chdirTemp(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-preview-jpeg/download_as_image",
|
||||
Status: 200,
|
||||
RawBody: []byte("fake JPEG image data"),
|
||||
ContentType: "image/jpeg",
|
||||
})
|
||||
|
||||
args := []string{"+export", "--whiteboard-token", "test-token-preview-jpeg", "--output-type", "preview", "--output", "output", "--overwrite"}
|
||||
if err := runShortcut(t, WhiteboardExport, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat("output.png"); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("output.png should not exist when response Content-Type is image/jpeg, stat err=%v", err)
|
||||
}
|
||||
data, err := os.ReadFile("output.jpg")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "fake JPEG image data" {
|
||||
t.Fatalf("image content = %q, want %q", string(data), "fake JPEG image data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportWhiteboardPreview_RejectsNonImageContentTypeWithoutSiblingOverwrite(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
chdirTemp(t)
|
||||
|
||||
if err := os.WriteFile("report.html", []byte("keep me"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-preview-html/download_as_image",
|
||||
Status: 200,
|
||||
RawBody: []byte("<html>bad gateway</html>"),
|
||||
ContentType: "text/html; charset=utf-8",
|
||||
})
|
||||
|
||||
args := []string{"+export", "--whiteboard-token", "test-token-preview-html", "--output-type", "preview", "--output", "report.png", "--overwrite"}
|
||||
err := runShortcut(t, WhiteboardExport, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-image preview response")
|
||||
}
|
||||
assertInvalidResponse(t, err)
|
||||
|
||||
data, readErr := os.ReadFile("report.html")
|
||||
if readErr != nil {
|
||||
t.Fatalf("ReadFile() error: %v", readErr)
|
||||
}
|
||||
if string(data) != "keep me" {
|
||||
t.Fatalf("report.html was overwritten: %q", string(data))
|
||||
}
|
||||
if _, statErr := os.Stat("report.png"); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("report.png should not be written on invalid response, stat err=%v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportWhiteboardPreview_IgnoresContentDispositionExtension(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
chdirTemp(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-preview-disposition/download_as_image",
|
||||
Status: 200,
|
||||
RawBody: []byte("fake JPEG image data"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"image/jpeg"},
|
||||
"Content-Disposition": []string{`attachment; filename="payload.sh"`},
|
||||
},
|
||||
})
|
||||
|
||||
args := []string{"+export", "--whiteboard-token", "test-token-preview-disposition", "--output-type", "preview", "--output", "output", "--overwrite"}
|
||||
if err := runShortcut(t, WhiteboardExport, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat("output.sh"); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("output.sh should not be created from Content-Disposition, stat err=%v", err)
|
||||
}
|
||||
data, err := os.ReadFile("output.jpg")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "fake JPEG image data" {
|
||||
t.Fatalf("image content = %q, want %q", string(data), "fake JPEG image data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportWhiteboardPreview_RejectsMismatchedExplicitExtension(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
chdirTemp(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-preview-mismatch/download_as_image",
|
||||
Status: 200,
|
||||
RawBody: []byte("fake JPEG image data"),
|
||||
ContentType: "image/jpeg",
|
||||
})
|
||||
|
||||
args := []string{"+export", "--whiteboard-token", "test-token-preview-mismatch", "--output-type", "preview", "--output", "report.png", "--overwrite"}
|
||||
err := runShortcut(t, WhiteboardExport, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for mismatched explicit extension")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error is not *errs.ValidationError: %T (%v)", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeFailedPrecondition || ve.Param != "--output" {
|
||||
t.Fatalf("validation details = subtype %q param %q, want %q --output", ve.Subtype, ve.Param, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
if _, statErr := os.Stat("report.jpg"); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("report.jpg should not be created when explicit path mismatches, stat err=%v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportWhiteboardPreview_AllowsMatchingExplicitExtension(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
chdirTemp(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-preview-matching/download_as_image",
|
||||
Status: 200,
|
||||
RawBody: []byte("fake JPEG image data"),
|
||||
ContentType: "image/jpeg",
|
||||
})
|
||||
|
||||
args := []string{"+export", "--whiteboard-token", "test-token-preview-matching", "--output-type", "preview", "--output", "report.jpeg", "--overwrite"}
|
||||
if err := runShortcut(t, WhiteboardExport, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data, err := os.ReadFile("report.jpeg")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "fake JPEG image data" {
|
||||
t.Fatalf("image content = %q, want %q", string(data), "fake JPEG image data")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportWhiteboardRaw_EmptyNodes verifies raw export reports empty whiteboards.
|
||||
func TestExportWhiteboardRaw_EmptyNodes(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
@@ -1792,12 +1522,3 @@ func chdirTemp(t *testing.T) {
|
||||
}
|
||||
t.Cleanup(func() { os.Chdir(orig) })
|
||||
}
|
||||
|
||||
func shortcutFlag(shortcut common.Shortcut, name string) *common.Flag {
|
||||
for i := range shortcut.Flags {
|
||||
if shortcut.Flags[i].Name == name {
|
||||
return &shortcut.Flags[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -255,7 +255,6 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
got := Shortcuts()
|
||||
want := []string{
|
||||
"+update",
|
||||
"+export",
|
||||
"+query",
|
||||
}
|
||||
|
||||
|
||||
@@ -87,20 +87,16 @@ POST /open-apis/base/v3/bases/:base_token/tables/:table_id/fields
|
||||
## 返回重点
|
||||
|
||||
- 返回 `field` 和 `created: true`。
|
||||
- 如果返回 `field_get_recommended:false` 且 `next_step:"done"`,表示本次是简单字段创建,通常不需要立刻执行 `+field-get`。
|
||||
- 如果返回 `field_get_recommended:true` 或 `next_step:"field_get"`,按 `verification_hint` 读回字段;`formula`、`lookup`、`link`、`auto_number` 等计算、关联或生成型字段更适合读回确认服务端最终结构。
|
||||
|
||||
## 工作流
|
||||
|
||||
|
||||
1. formula / lookup 字段必须先阅读对应指南;没读之前不要直接创建。
|
||||
2. 创建简单字段时,优先相信命令返回;只有用户要求精确核对额外属性,或返回建议读回时,才继续执行 `+field-get`。
|
||||
|
||||
## 坑点
|
||||
|
||||
- ⚠️ 这是写入操作,执行前必须确认。
|
||||
- ⚠️ 当 `type` 是 `formula` 或 `lookup` 时,先读对应 guide,再创建。
|
||||
- ⚠️ 不要把“每次创建后都 `+field-get`”当作固定流程;按返回里的 `field_get_recommended` 和 `next_step` 决定是否读回。
|
||||
|
||||
## 参考
|
||||
|
||||
|
||||
@@ -180,11 +180,11 @@
|
||||
|
||||
支持字段:`icon`、`min`、`max`
|
||||
|
||||
默认值 / 已知平台范围:
|
||||
默认值 / 约束:
|
||||
- `icon` 默认 `star`
|
||||
- `icon` 可用:`star`、`heart`、`thumbsup`、`fire`、`smile`、`lightning`、`flower`、`number`
|
||||
- `min` 取值 `0..1`,默认 `1`
|
||||
- `max` 默认 `5`;常见或已文档化的范围为 `1..10`,但 CLI 不强制上限为 `10`。如果用户明确需要更大评分范围,优先确认平台能力或用 `+field-create/update --dry-run` 检查请求形状;平台拒绝后再建议改用普通数字或进度字段。
|
||||
- `max` 取值 `1..10`,默认 `5`
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -419,7 +419,7 @@
|
||||
|
||||
### 3.11 auto_number
|
||||
|
||||
自动编号字段;创建时不写 `style.rules` 会使用默认规则:`NO.001`。更新已有自动编号字段时应显式提交目标 `style.rules`,因为 `+field-update` 会把新的编号规则重新应用到已有编号。
|
||||
自动编号字段;不写 `style.rules` 时使用默认规则:`NO.001`。
|
||||
|
||||
最小写法:
|
||||
|
||||
@@ -512,7 +512,7 @@
|
||||
## 4. 创建与更新
|
||||
|
||||
- `+field-create`:按目标字段配置直接构造 `--json`。
|
||||
- `+field-update`:使用同样的 JSON 结构,但语义是 `PUT`;建议先 `+field-get`,再按目标完整状态提交,并带 `--yes`。当 `type` 是 `auto_number` 时,更新编号规则本身就会把新规则应用到已有编号,无需额外参数,也不要在 JSON 里塞额外的底层实现参数。
|
||||
- `+field-update`:使用同样的 JSON 结构,但语义是 `PUT`;建议先 `+field-get`,再按目标完整状态提交,并带 `--yes`。
|
||||
|
||||
## 5. 暂不支持字段
|
||||
|
||||
|
||||
@@ -20,13 +20,6 @@ lark-cli base +field-update \
|
||||
--field-id <field_id> \
|
||||
--json '{"name":"负责人","type":"user","multiple":false,"default_value":null,"description":"用于标记记录的直接负责人"}' \
|
||||
--yes
|
||||
|
||||
lark-cli base +field-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--field-id <field_id> \
|
||||
--json '{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}' \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
@@ -49,8 +42,6 @@ lark-cli base +field-update \
|
||||
PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id
|
||||
```
|
||||
|
||||
当 `--json.type` 是 `auto_number` 时,仍然走同一个 v3 字段更新接口:更新自动编号规则后,接口现状就会把新规则应用到已有编号(这是接口默认行为,只是 agent 通常不知道),因此**不需要**任何额外开关或参数。只需要正常提交目标自动编号字段定义即可;如果用户要求“将修改用于已有编号”,直接执行这次 `+field-update` 就能达到效果,不要在 `--json` 里额外添加任何参数去“触发”重排。
|
||||
|
||||
## JSON 值规范
|
||||
|
||||
- `--json` 必须是 **JSON 对象**,顶层直接传字段定义。
|
||||
@@ -61,7 +52,6 @@ PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id
|
||||
- `link` 更新限制:
|
||||
- 不能把非 `link` 字段改成 `link`,也不能把 `link` 改成非 `link`。
|
||||
- 现有 `link` 字段的 `bidirectional` 不能改。
|
||||
- `auto_number` 更新的 `style.rules` 支持 `text`、`created_time`、`incremental_number`。
|
||||
|
||||
**推荐更新示例**
|
||||
|
||||
@@ -93,18 +83,13 @@ PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id
|
||||
## 返回重点
|
||||
|
||||
- 返回 `field` 和 `updated: true`。
|
||||
- `updated:true` 只表示更新请求成功,不表示字段结构、已有记录值或下游能力已经完成验证。`+field-update` 无法知道更新前的字段类型,因此成功响应会推荐执行 `+field-get`;若发生类型转换,还要抽样读取记录值。
|
||||
- 如果响应中的 `field.type` 与提交的 `type` 不一致,必须把它当作待核验的类型不匹配;不能返回完成态,也不能只根据其中任一类型推断更新成功。
|
||||
- 如果 API 报告本次更新没有产生任何变更(no-op),命令会如实返回该错误;这通常说明目标字段已是期望状态,不要机械重试同一份 `+field-update`。需要确认当前字段完整状态时执行 `+field-get`。
|
||||
- 如果返回 `field_get_recommended:true` 或 `next_step:"field_get"`,按提示读回字段;`auto_number` 更新后还应抽样读记录值确认编号已按新规则生成。
|
||||
|
||||
## 工作流
|
||||
|
||||
|
||||
1. 建议先用 `+field-get` 拉现状,再做最小化修改。
|
||||
2. `formula/lookup` 类型更新前先阅读对应指南。
|
||||
3. 如果更新 `auto_number`,理解为“更新编号规则,同时把新规则应用到已有编号”;执行后按返回提示读回字段并在必要时抽样记录值。
|
||||
4. 如果这次更新会改变字段 `type` 先按下方“字段类型变更规则”判断能否执行。如果不修改 `type`,大多数场景都相对安全。
|
||||
3. 如果这次更新会改变字段 `type` 先按下方“字段类型变更规则”判断能否执行。如果不修改 `type`,大多数场景都相对安全。
|
||||
|
||||
## 字段类型变更规则
|
||||
|
||||
@@ -170,7 +155,6 @@ PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id
|
||||
### 完成态验证
|
||||
|
||||
- `FieldReadback`: 读回字段结构,确认 `type` / `multiple` / `style` / `options`
|
||||
- `NoopReadback`: `+field-update` 返回 no-op 错误时,只能说明 API 报告没有产生变更;可以跳过重复 update,但不能替代 `FieldReadback`
|
||||
- `ValueReadback`: 抽样读回转换后的单元格值
|
||||
- `DownstreamReadback`: 若涉及看板 / 分组 / 排序 / lookup / 公式,继续读回结果
|
||||
- `CompletionRule`: 结构、值、下游能力都正确,才能回复“已完成”
|
||||
|
||||
@@ -44,7 +44,6 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
|
||||
> 自动设置 `reminders: [{"minutes": 5}]`,默认日程开始前 5 分钟提醒。
|
||||
> 自动设置 `vchat: {"vc_type": "vc"}`,默认日程包含飞书视频会议。如需其他视频会议类型或不含视频会议,请使用完整 API 命令。
|
||||
> 失败保护:若添加参会人失败(如 open_id 错误),CLI 会自动删除刚创建的空日程(回滚,不通知参会人)。
|
||||
> 搜索用户接口不支持 bot 身份,需用 `--as user` 进行搜索。
|
||||
> 审批会议室:`+create` 不暴露低频字段 `attendees[].approval_reason`。如果会议室要求审批,请使用用户身份先创建日程,再用完整 API `calendar event.attendees create --as user` 添加会议室并传 `approval_reason`。
|
||||
|
||||
## 高级用法(完整 API 命令)
|
||||
|
||||
@@ -87,21 +87,13 @@ lark-cli docs +fetch --doc Z1Fj...tnAc \
|
||||
"document": {
|
||||
"document_id": "doxcnXXXX",
|
||||
"revision_id": 12,
|
||||
"content": "<title>标题</title><p>文档内容...</p>",
|
||||
"reference_map": {
|
||||
"<block_type>": {
|
||||
"<ref>": {
|
||||
"<real-attr-key>": "<real-attr-value>"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tips": "<safe replay or degradation guidance>"
|
||||
"content": "<title>标题</title><p>文档内容...</p>"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`content` 的格式由 `--doc-format` 决定。`reference_map` 是正文引用数据的结构化 sidecar:一级键 `block_type` 表示引用所在的块类型,二级键 `ref` 对应正文中的临时引用;每个引用的值是由 `real-attr-key` 和 `real-attr-value` 组成的真实属性映射,具体属性由块类型决定。没有提取数据时,`reference_map` 可能为空。`content` 和 `reference_map` 属于同一份响应,保留或回放内容时应配套处理。`tips` 给出安全回放或降级提示。`im-markdown` 仅用于获取内容后在 `lark-im` 场景下使用。设置 `--scope` 时会被 `<fragment>` 包裹,详见上文"局部读取的输出结构"。
|
||||
`content` 的格式由 `--doc-format` 决定;`im-markdown` 仅用于获取内容后在 `lark-im` 场景下使用。设置 `--scope` 时会被 `<fragment>` 包裹,详见上文"局部读取的输出结构"。
|
||||
|
||||
## 参数
|
||||
|
||||
|
||||
@@ -125,9 +125,9 @@ Sub Agent 需要携带以下的最小上下文,以及后续的 [SVG 设计 Wor
|
||||
`../../lark-whiteboard/SKILL.md`](../../lark-whiteboard/SKILL.md) 编辑。
|
||||
|
||||
```bash
|
||||
lark-cli whiteboard +export \
|
||||
lark-cli whiteboard +query \
|
||||
--whiteboard-token "wbcnxxxxxxxx" \
|
||||
--output-type preview \
|
||||
--output_as image \
|
||||
--output ./preview.png
|
||||
```
|
||||
|
||||
|
||||
@@ -2,47 +2,6 @@
|
||||
|
||||
本文件用于补充说明 block XML 扩展能力。常用标签和通用规则见 [`lark-doc-xml.md`](lark-doc-xml.md);后续新增其他 block 说明时可继续追加到本文件。
|
||||
|
||||
## HTML5 block
|
||||
|
||||
1. 写入 HTML 内容块时,把完整单文件 HTML 存为本地 `.html` 文件,XML 写 `<html5-block path="@widget.html"></html5-block>`;已有 `data-ref` 时配合 `--reference-map @reference-map.json`。读取时 `<html5-block data-ref="html5_1"></html5-block>` 只是占位,必须从 `document.reference_map["html5-block"]["html5_1"].data` 读取 HTML;若 entry 是 `path`,读取对应 `@doc-fetch-resources/...html` 文件。
|
||||
2. 格式如下:
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="use-iframe" content="true">
|
||||
<meta name="html-box-height-mode" content="auto">
|
||||
<meta name="description" content="内容摘要,会导出为 html5-block 的 alt 属性,帮助模型理解该 HTML 块的用途">
|
||||
<title></title>
|
||||
</head>
|
||||
<body>
|
||||
...
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### 布局与高度
|
||||
|
||||
- `lark-cli` 会读取 `.html` 文件并原样写入 `reference_map`,不会解析或校验 `html-box-height-mode`;创建或更新文档前在 `<head>` 中显式声明 `auto` 或 `viewport`。
|
||||
- 生成时只使用 `auto` 或 `viewport`,不要臆造 `fixed`、`initial` 或像素值等其他 mode。
|
||||
- 文档常见可用宽度约 `820px`;根容器使用 `width: 100%`、`max-width: 100%`、`box-sizing: border-box`。
|
||||
|
||||
四种策略:
|
||||
|
||||
1. 内容自然撑开:`auto` + 普通文档流;根容器不设固定高度或 `overflow: hidden`。
|
||||
2. 仅按初始内容定高:`auto` + 首次渲染后不再追加或展开内容。
|
||||
3. 固定像素操作区:`auto` + 业务容器按场景设置固定的 CSS `height` 和 `overflow: auto`;高度数值不写进 meta。
|
||||
4. 单屏应用:`viewport` + `100vh` + 内部滚动、切页或缩放;适用于游戏、幻灯片、Dashboard、canvas 编辑器。
|
||||
|
||||
正文需要在飞书文档中完整展开时选 `auto`;内容应在 HTML Block 内滚动时选 `viewport`。`lark-cli` 不参与页面加载后的高度刷新,不要臆造相关 CLI flag。
|
||||
|
||||
### 内容限制
|
||||
|
||||
- HTML 总长度上限为 500KB。不要内联大图片、Base64、字体、长 JSON/CSV 或大量 mock 数据。
|
||||
|
||||
## OKR block
|
||||
|
||||
OKR block 可用 XML 格式完整表达。创建前先参考 [`lark-okr`](../../lark-okr/SKILL.md) 确认可用周期;创建时只写 root-only `<okr cycle-id="..."/>` 挂载已有 OKR,不构造 Objective/KR/Progress 子树。
|
||||
|
||||
@@ -23,7 +23,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|
||||
## 行内组件
|
||||
| 标签 | 说明 | 关键属性 |
|
||||
|-|-|-|
|
||||
| `<cite type="user">` | @人 | XML 导入时必须显式传入 `user-id`:`<cite type="user" user-id="userID"></cite>` |
|
||||
| `<cite type="user">` | @人 | `<cite type="user" user-id="userID"></cite>` |
|
||||
| `<cite type="doc">` | @文档 | `<cite type="doc" doc-id="docx_token"></cite>` |
|
||||
| `<latex>` | 行内公式 | `<latex>E = mc^2</latex>` |
|
||||
| `<img>` | 图片(可独立成块或内联) | `<img width="800" height="600" caption="说明" name="图.png" href="http 或 https"/>` |
|
||||
@@ -46,8 +46,8 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|
||||
- `<task>` — `<task task-id="GUID"></task>`,必传 task-id(任务 guid)
|
||||
- `<chat_card>` — `<chat_card chat-id="CHAT_ID"></chat_card>`,必传 chat-id
|
||||
- `<sub-page-list>` — `<sub-page-list></sub-page-list>` 子页面列表块;仅 wiki 文档可插入
|
||||
- `<html5-block>`、`<okr>` — 前者在飞书文档「HTML 块」iframe 中加载单文件 HTML,内容可用 HTML 渲染时直接使用;后者创建时仅支持 root-only `<okr cycle-id="..."/>` 挂载已有 OKR。完整语法与字段规则见 [`lark-doc-xml-extended-blocks.md`](lark-doc-xml-extended-blocks.md)。
|
||||
- bitable、base_ref、synced_reference、synced_source — 不可创建,仅支持移动
|
||||
- `<okr>` — 创建时仅支持 root-only `<okr cycle-id="..."/>` 挂载已有 OKR;完整结构与字段规则见 [`lark-doc-xml-extended-blocks.md`](lark-doc-xml-extended-blocks.md#okr-block)
|
||||
|
||||
# 四、块级复制与移动
|
||||
|
||||
@@ -85,7 +85,6 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|
||||
|
||||
## 用户名写入规则
|
||||
|
||||
- 任何包含 `<cite type="user">` 的 XML 在导入、新建或编辑回写时,都必须显式传入 `user-id`;其值为用户的 `open_id`,不得省略。
|
||||
- 当从 IM 消息、日历、审批、任务等来源获取到用户的 `open_id` 时,写入文档**必须**使用 `<cite type="user" user-id="open_id">` 标签,而非纯文本名字。这样文档中会渲染为可点击的 @人。
|
||||
- 典型场景:IM 消息的 `sender`、`mentions`、reactions 的 `operator`、卡片消息中引用的用户、系统消息中的用户名、合并转发中的用户名。
|
||||
- 当只有纯文本名字而没有 `open_id` 时(如系统消息、合并转发内容),先通过 `lark-cli contact +search-user --query "名字" --as user` 反查 `open_id`,再写入 cite 标签。
|
||||
|
||||
@@ -26,10 +26,7 @@ metadata:
|
||||
- 高风险写操作(删除、公开权限修改、owner 转移、版本删除/回滚、批量移动/覆盖/同步)必须同时满足三个条件才执行:目标已解析为该操作可直接使用的执行对象,执行细节已明确到可直接调用命令(例如删除的 file-token/type、公开权限修改的共享范围、owner 转移的目标 owner、版本删除/回滚的 version id、移动/覆盖/同步的目标位置和冲突策略),且用户在本轮明确确认执行这些具体目标和执行细节。用户只说“删除没用的文件”“开放/共享给大家”“改成开放”“覆盖/移动这些”只表示目标状态;先只读发现并列出候选、权限档位或执行方案,停止等待用户确认。
|
||||
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要”权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
|
||||
- 用户要为指定飞书文档**设置 / 修改密级标签(secure label)**,或查询当前用户可用的密级标签,直接读取 [`references/lark-drive-secure-label.md`](references/lark-drive-secure-label.md);这是 Drive 文件治理能力。
|
||||
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
|
||||
- 用户要**按特定主题、关键词或内容线索跨容器查找资料,并统一收集到 Drive 文件夹或 Wiki 节点**,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`topic_move_collector`](references/lark-drive-workflow-topic-move-collector.md) workflow。该 workflow 负责搜索召回、内容验证、相关性分类、移动计划、写前确认和结果验证;禁止直接从 `drive +search` 或 `drive +move` 开始。
|
||||
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](references/lark-drive-workflow-knowledge-organize.md) workflow。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
|
||||
- 按主题跨范围查找并集中归档,进入 `topic_move_collector`;对已知文件夹、文档库或知识库做目录盘点和结构重组,进入 `knowledge_organize`;只移动一个已明确资源时仍使用原子移动命令。
|
||||
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`,owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag,避免手写嵌套 JSON。
|
||||
- 用户要**获取文档评论列表**时,优先使用 `lark-cli drive +list-comments --url '<url>'`,不要优先手写 `drive file.comments list`;支持妙搭 apps 的 `/page/<token>` URL;具体使用方式先阅读 [`references/lark-drive-list-comments.md`](references/lark-drive-list-comments.md)。
|
||||
- 妙搭 apps 评论场景:除新增全文/局部评论不支持外,评论列表、批量查询、解决/恢复、回复创建/读取/更新/删除、reaction 添加/删除等评论管理能力已支持;使用原生命令时文档类型传 `apps`(`file_type=apps`),裸 token 调 shortcut 时传 `--type apps`。
|
||||
|
||||
@@ -190,9 +190,9 @@ lark-cli base +record-list --base-token '<base_token>' --table-id '<table_id>' -
|
||||
- 若要定位画板内部节点,切到 `lark-whiteboard` 读取 raw 节点结构:
|
||||
|
||||
```bash
|
||||
lark-cli whiteboard +export \
|
||||
lark-cli whiteboard +query \
|
||||
--whiteboard-token '<whiteboard_token>' \
|
||||
--output-type raw
|
||||
--output_as raw
|
||||
```
|
||||
|
||||
- 如果 raw 节点中存在唯一匹配 `quote` 的文本节点,可定位到该节点;如果有多个相同文本节点,仍然是弱匹配,需要结合位置、样式、用户描述或人工确认。
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
# 主题资料收集工作流:执行
|
||||
|
||||
由状态 `CONFIRM_EXECUTION`、`EXECUTE`、`VERIFY`、`RESTORE` 加载。
|
||||
|
||||
本文档负责最终写操作确认、目标创建、资源移动、验证、恢复行为、`RollbackSnapshotItem` 和执行日志。不得修改搜索、召回、分类规则或计划 schema。
|
||||
|
||||
本文档只服务 `topic_move_collector`。进入本文档时,`workflow_id` 必须是 `topic_move_collector`;不得把当前任务改路由到其他 workflow。
|
||||
|
||||
## 必读上下文
|
||||
|
||||
执行本文档规则前:
|
||||
|
||||
1. 按 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 处理写操作确认、高风险操作、身份、认证和权限。
|
||||
2. 按 [`lark-drive-create-folder.md`](lark-drive-create-folder.md) 创建 Drive 文件夹。
|
||||
3. 按 [`lark-drive-move.md`](lark-drive-move.md) 执行 Drive 移动。
|
||||
4. 按 [`../../lark-wiki/references/lark-wiki-node-create.md`](../../lark-wiki/references/lark-wiki-node-create.md) 创建 Wiki 节点。
|
||||
5. 按 [`../../lark-wiki/references/lark-wiki-move.md`](../../lark-wiki/references/lark-wiki-move.md) 执行 Wiki 移动和 Drive 文档移动到 Wiki。
|
||||
6. 按 [`../../lark-wiki/references/lark-wiki-move-to-drive.md`](../../lark-wiki/references/lark-wiki-move-to-drive.md) 将 Wiki 节点移出到 Drive 文件夹。
|
||||
7. 按 [`lark-drive-delete.md`](lark-drive-delete.md) 删除本次 workflow 新建的 Drive 文件夹。
|
||||
8. 按 [`../../lark-wiki/references/lark-wiki-node-delete.md`](../../lark-wiki/references/lark-wiki-node-delete.md) 删除本次 workflow 新建的 Wiki 节点。
|
||||
9. 需要轮询异步任务时,按 [`lark-drive-task-result.md`](lark-drive-task-result.md) 执行。
|
||||
10. `MovePlanItem` schema 由 [`lark-drive-workflow-topic-move-collector-review-plan.md`](lark-drive-workflow-topic-move-collector-review-plan.md) 定义,本文件只消费已确认计划。
|
||||
|
||||
## 状态:`CONFIRM_EXECUTION`
|
||||
|
||||
进入条件:移动计划已准备,且用户要求执行。
|
||||
|
||||
必须:
|
||||
|
||||
1. 执行前展示所有写操作类别。
|
||||
2. 将目标创建和资源移动分开展示。
|
||||
3. 展示默认纳入的高相关资源。
|
||||
4. 如有用户选择的中相关资源,也要展示。
|
||||
5. 展示跳过分组和原因。
|
||||
6. 明确展示跨容器移动。
|
||||
7. 展示无移动权限和移动权限未知的资源数量。
|
||||
8. 请求用户明确确认。
|
||||
9. 确认前校验每个 `move_resource` 项都包含完整 `command_family`、`command_args`、权限快照和 `rollback_input`;缺失时必须返回 `PLAN_MOVE` 重新生成计划,不得在执行阶段补猜。
|
||||
10. 只有 `move_permission_state=movable` 且 `target_write_state=confirmed` 的计划项可以列入“将移动”。
|
||||
11. 对每个 `rollback_supported=false` 的计划项逐项展示标题、当前位置、目标位置、不可恢复原因和影响,不得只展示数量。
|
||||
|
||||
### 确认 UI
|
||||
|
||||
```text
|
||||
请确认是否执行以下写操作:
|
||||
|
||||
本次搜索范围:<当前用户 owner / 负责的资源 | 所有当前身份可见资源>
|
||||
|
||||
将创建:
|
||||
- 目标名称|父级位置|目标类型
|
||||
|
||||
将移动:
|
||||
- 标题|类型|当前位置|目标位置|原因
|
||||
|
||||
不会移动:
|
||||
- 中相关未选择:N 项
|
||||
- 低相关:N 项
|
||||
- 无权限:N 项
|
||||
- 无移动权限:N 项
|
||||
- 移动权限未知:N 项
|
||||
- 无法验证:N 项
|
||||
- 不支持移动:N 项
|
||||
|
||||
风险提示:
|
||||
- 不可自动恢复:N 项
|
||||
- 标题|当前位置|目标位置|不可恢复原因|影响:移动成功后 workflow 无法自动搬回原位置,需要手动处理
|
||||
- 如果搜索范围是所有当前身份可见资源,移动权限未知项不会移动。
|
||||
|
||||
确认后才会创建目标和移动资源。
|
||||
|
||||
如果不存在不可自动恢复项,请回复“确认执行”开始写操作。
|
||||
如果存在不可自动恢复项,请回复“确认执行,包括不可自动恢复项”;普通“确认执行”不满足本次风险确认。
|
||||
也可以回复“调整计划”返回选择资源,或回复“取消”结束流程。
|
||||
```
|
||||
|
||||
如果用户修改选择或相关性分组,废弃当前 `move_plan_items` 并返回 `PLAN_MOVE` 重新生成计划;不得在 `CONFIRM_EXECUTION` 直接局部改写计划。
|
||||
|
||||
## 状态:`EXECUTE`
|
||||
|
||||
进入条件:用户明确确认写操作;存在 `rollback_supported=false` 的计划项时,用户已明确确认包括不可自动恢复项。
|
||||
|
||||
必须:
|
||||
|
||||
1. 只执行已确认 `MovePlanItem.command_family` 和 `command_args`;不得回查 `ResourceItem` 补齐或改写命令参数。
|
||||
2. 当存在 `action_type=create_target` 的 `MovePlanItem` 时,先创建目标。
|
||||
3. 目标创建后记录返回 token;只允许把 `created_by_plan:<create_target plan_id>` 引用解析为该 token,并把解析后的实际参数写入 `execution_journal`。不得重新搜索或猜测目标。
|
||||
4. 目标 token 引用解析成功后再移动依赖该目标的资源;解析失败时停止依赖该创建目标的移动并记录 blocker,不得替换为其他目标。
|
||||
5. 执行任何写操作前,基于每个已确认计划项的 `rollback_input` 生成 `rollback_snapshot`。`rollback_supported=false` 且已有明确 `rollback_blocker` 的快照视为完整风险快照,不阻塞其他项。
|
||||
6. 执行任何写操作前,初始化 `execution_journal`。
|
||||
7. 每次写操作尝试后记录 `execution_journal`。
|
||||
8. 单项失败后可继续执行相互独立的移动;目标创建失败时必须停止。
|
||||
9. 不得移动 `permission_denied`、`no_move_permission`、`move_permission_unknown`、`unverifiable`、`low` 或 `unsupported_move_target` 项。
|
||||
10. 不得移动 `move_permission_state!=movable` 或 `target_write_state!=confirmed` 的资源。
|
||||
11. 如果移动命令返回权限错误,记录失败原因,不自动申请权限,不自动重试同一移动。
|
||||
12. 如果 `rollback_supported=true` 但 `rollback_input` 缺少恢复所需字段,将该计划项标记为 `failed` / `plan_snapshot_incomplete` 并跳过;不得在未重新确认风险的情况下把它静默降级为不可恢复项,也不得阻塞其他独立项。
|
||||
|
||||
### 移动方式选择
|
||||
|
||||
| 来源 -> 目标 | 移动方式 |
|
||||
|------------------|-------------|
|
||||
| Drive resource -> Drive folder | `drive +move` |
|
||||
| Drive document-like resource -> Wiki target | `wiki +move` 的 docs-to-wiki 模式;默认不可自动恢复 |
|
||||
| Wiki node -> Wiki target | `wiki +move --node-token` |
|
||||
| Wiki node -> Drive folder | `wiki +move-to-drive` |
|
||||
|
||||
### 执行顺序
|
||||
|
||||
1. 如有 `create_target` 项,先执行。
|
||||
2. 按确认计划顺序执行 `move_resource` 项。
|
||||
3. 如果命令返回 task ID,执行异步任务轮询。
|
||||
4. 输出写操作执行摘要。
|
||||
|
||||
### 进度 UI
|
||||
|
||||
批量较大时,按计数汇报进度:
|
||||
|
||||
```text
|
||||
执行进度:已完成 <done_count>/<total_count>,成功 <success_count>,失败 <failed_count>。
|
||||
当前操作:<title>
|
||||
继续执行中,不需要你操作;如遇到需要确认的失败会单独提示。
|
||||
```
|
||||
|
||||
## 状态:`VERIFY`
|
||||
|
||||
进入条件:执行完成。
|
||||
|
||||
必须:
|
||||
|
||||
1. 如果创建了目标,验证目标存在。
|
||||
2. 能力支持时,验证已移动资源在目标位置可见。
|
||||
3. 对比实际位置和 `move_plan_items`。
|
||||
4. 为每一项标记验证状态。
|
||||
5. 只有当已有移动成功且存在严重不一致或失败时,才提供恢复选项。
|
||||
6. 输出验证结果时,必须说明用户下一步可以结束流程、查看失败项,或在可恢复时选择恢复。
|
||||
7. 如果出现 `async_pending`,先使用 `drive +task_result` 轮询确认;超过轮询限制后再报告 pending blocker。
|
||||
|
||||
### 验证结果
|
||||
|
||||
| 状态值 | 说明 |
|
||||
|--------|------|
|
||||
| `verified` | 资源已在目标位置可见。 |
|
||||
| `not_found` | 目标位置未找到资源。 |
|
||||
| `permission_unknown` | 当前身份无法确认结果。 |
|
||||
| `async_pending` | 异步任务尚未完成,需要继续轮询。 |
|
||||
| `failed` | 移动命令失败或结果不符合计划。 |
|
||||
|
||||
## 状态:`RESTORE`
|
||||
|
||||
进入条件:失败、不一致或用户明确要求恢复。
|
||||
|
||||
必须:
|
||||
|
||||
1. 只基于 `rollback_snapshot` 和 `execution_journal` 生成恢复计划。
|
||||
2. 展示可恢复项和不可恢复项。
|
||||
3. 执行恢复写操作前请求明确确认;确认内容必须包含反向移动和删除本次 workflow 新建目标。
|
||||
4. 只恢复本次 workflow 移动过的资源。
|
||||
5. 只恢复 `rollback_supported=true` 且 `rollback_eligible=true` 的移动项。
|
||||
6. Drive / Wiki 跨容器移动、原父级 token 缺失等 `rollback_supported=false` 的项不得反向移动,也不得删除迁入后的文档。
|
||||
7. 本次 workflow 成功创建的目标文件夹或 Wiki 节点必须纳入清理计划。
|
||||
8. 删除 workflow 新建的 Wiki 目标节点时,必须使用 `wiki +node-delete --include-children=false --yes`,让已迁入的直接子文档保留到该节点父级层级。
|
||||
9. 删除 workflow 新建的 Drive 文件夹前,必须先恢复或移出其中由本次 workflow 放入的资源;如果无法确认文件夹已安全可删,报告清理阻塞,不得用删除文件夹来删除用户资源。
|
||||
|
||||
### 恢复顺序
|
||||
|
||||
1. 先恢复 `rollback_supported=true` 且 `rollback_eligible=true` 的移动项。
|
||||
2. 对全部 `rollback_supported=false` 的项,只记录“保留在当前目标位置,不回迁、不删除”和对应 blocker。
|
||||
3. 再清理 `created_by_workflow=true` 的目标容器。
|
||||
4. Wiki 新建目标清理使用 `--include-children=false`;Drive 新建目标清理只在不会删除用户资源时执行。
|
||||
|
||||
### 恢复 UI
|
||||
|
||||
```text
|
||||
可以尝试恢复本次已移动的资源:
|
||||
|
||||
可恢复:
|
||||
- 标题|当前位置|原位置
|
||||
|
||||
不可自动恢复:
|
||||
- 标题|当前位置|原位置|原因|影响:需要手动恢复
|
||||
|
||||
将清理本次新建目标:
|
||||
- 名称|类型|清理方式
|
||||
|
||||
将保留在当前目标位置的跨容器迁入文档:
|
||||
- 标题|当前位置|保留结果
|
||||
|
||||
是否执行恢复?
|
||||
```
|
||||
|
||||
## RollbackSnapshotItem
|
||||
|
||||
```json
|
||||
{
|
||||
"snapshot_id": "稳定快照行 ID",
|
||||
"plan_id": "对应 MovePlanItem.plan_id",
|
||||
"resource_id": "对应 MovePlanItem.resource_id",
|
||||
"source_kind": "drive|wiki",
|
||||
"title": "资源标题",
|
||||
"resource_type": "Drive 恢复命令需要的资源类型",
|
||||
"original_token": "原始 Drive token",
|
||||
"original_node_token": "原始 Wiki node token",
|
||||
"original_parent_kind": "drive_folder|drive_root|wiki_node|wiki_space_root|unknown",
|
||||
"original_parent_token": "原始父级 token",
|
||||
"original_space_id": "原始 Wiki space_id",
|
||||
"original_path": "执行前路径",
|
||||
"planned_target_parent_token": "计划目标父级 token",
|
||||
"rollback_supported": "是否支持自动恢复",
|
||||
"rollback_blocker": "不可自动恢复原因"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|-------|------|
|
||||
| `snapshot_id` | 稳定快照行 ID。 |
|
||||
| `plan_id` | 对应 `MovePlanItem.plan_id`,用于连接计划、快照和执行日志。 |
|
||||
| `resource_id` | 对应稳定资源 ID,用于审计计划来源。 |
|
||||
| `resource_type` | `drive +move` 恢复时必须传入的 `--type`;非 Drive 恢复也保留原始资源类型。 |
|
||||
| `original_token` / `original_node_token` | 执行前源资源身份。 |
|
||||
| `original_parent_kind` / `original_parent_token` | 执行前父级位置。 |
|
||||
| `rollback_supported` | 是否支持自动恢复。 |
|
||||
| `rollback_blocker` | 不可自动恢复原因。 |
|
||||
|
||||
## 执行日志
|
||||
|
||||
每次写操作尝试都必须追加一条内部日志:
|
||||
|
||||
```json
|
||||
{
|
||||
"journal_id": "稳定日志行 ID",
|
||||
"plan_id": "对应 MovePlanItem 的 plan_id",
|
||||
"time": "ISO-8601",
|
||||
"action_type": "create_target|move_resource|restore_resource|cleanup_target",
|
||||
"operation": "create_folder|create_node|move_drive|move_wiki_node|move_wiki_to_drive|restore_drive|restore_wiki_node|delete_folder|delete_wiki_node",
|
||||
"command_family": "drive +move|wiki +move|wiki +move-to-drive|drive +create-folder|wiki +node-create|drive +delete|wiki +node-delete",
|
||||
"resolved_command_args": {"<arg>": "实际发送的参数"},
|
||||
"title": "资源或目标名称",
|
||||
"resource_type": "资源类型",
|
||||
"input_token": "命令输入 token",
|
||||
"input_node_token": "命令输入 Wiki node token",
|
||||
"input_parent_token": "已知源父级 token",
|
||||
"target_parent_token": "目标父级 token",
|
||||
"returned_token": "命令返回 token",
|
||||
"returned_node_token": "命令返回 Wiki node token",
|
||||
"returned_parent_token": "返回父级 token",
|
||||
"task_id": "异步任务 ID",
|
||||
"next_command": "异步继续命令",
|
||||
"created_by_workflow": "是否由本次 workflow 创建",
|
||||
"rollback_eligible": "是否可进入自动恢复计划",
|
||||
"status": "success|failed|pending",
|
||||
"error": "失败原因"
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `journal_id` | 稳定日志行 ID。 |
|
||||
| `plan_id` | 对应 `MovePlanItem`,用于把日志项匹配回原计划。 |
|
||||
| `operation` | 细分操作类型,用于区分创建、移动和恢复。 |
|
||||
| `resolved_command_args` | 从确认计划解析出的实际发送参数;用于审计 `created_by_plan:<plan_id>` 的唯一运行时替换。 |
|
||||
| `resource_type` | 实际移动 / 恢复使用的资源类型。 |
|
||||
| `input_token` / `input_node_token` | 命令实际输入的资源 token。 |
|
||||
| `input_parent_token` | 执行前已知源父级 token。 |
|
||||
| `target_parent_token` | 命令输入的目标父级 token。 |
|
||||
| `returned_token` / `returned_node_token` | 命令返回的资源 token,恢复时作为当前源。 |
|
||||
| `returned_parent_token` | 命令返回的当前父级 token。 |
|
||||
| `task_id` / `next_command` | 异步任务跟踪信息。 |
|
||||
| `created_by_workflow` | 是否由本次 workflow 创建,用于后续清理判断。 |
|
||||
| `rollback_eligible` | 是否可进入自动恢复计划。 |
|
||||
| `status` | 写操作状态,异步未完成时为 `pending`。 |
|
||||
|
||||
除非用户要求查看技术调试细节,否则不要展示完整原始命令输出。
|
||||
@@ -1,202 +0,0 @@
|
||||
# 主题资料收集工作流:召回
|
||||
|
||||
由状态 `SEARCH_RECALL`、`RECALL_ENHANCE` 加载。
|
||||
|
||||
本文档负责基础搜索召回、覆盖增强、query 证据、去重和 `CandidateItem`。不得解析目标移动 token、读取完整文档内容、判断相关性或执行写操作。
|
||||
|
||||
本文档只服务 `topic_move_collector`。进入本文档时,`workflow_id` 必须是 `topic_move_collector`;不得把当前任务改路由到其他 workflow。
|
||||
|
||||
## 必读上下文
|
||||
|
||||
执行本文档规则前:
|
||||
|
||||
1. 按 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 处理身份、认证和权限。
|
||||
2. 按 [`lark-drive-search.md`](lark-drive-search.md) 处理 `drive +search` 语法、过滤条件、单批最多 5 页和身份语义;本 workflow 的全量续批规则见下文。
|
||||
|
||||
## 搜索原则
|
||||
|
||||
1. 默认使用 `drive +search --mine` 召回当前用户 owner / 负责的 Workspace 资源。
|
||||
2. 除非用户本来就要求限定范围,否则不要要求用户指定文件夹或 Wiki 范围。
|
||||
3. `SEARCH_RECALL` 和 `RECALL_ENHANCE` 必须保持为独立状态。
|
||||
4. `SEARCH_RECALL` 使用用户原始关键词、`owner_scope` 和显式限制。
|
||||
5. `RECALL_ENHANCE` 可以基于基础召回证据增加扩展 query,且必须继承同一个 `owner_scope`。
|
||||
6. 每个候选项必须保留 query 证据,方便后续解释来源。
|
||||
7. 单页或单个最多 5 页的 query 批次不代表完整覆盖;`has_more=true` 时必须保存 `next_page_token` 并自动开始下一批,直到 `has_more=false` 或出现阻塞。
|
||||
8. 召回和增强召回可能耗时较长,执行超过 60 秒时必须输出进度提示,之后约每 60 秒提示一次。
|
||||
9. 只有用户在 `CONFIRM_CONTEXT` 明确确认 `owner_scope=all_visible` 时,才允许移除 `--mine`。
|
||||
|
||||
### 分页优先级与完成语义
|
||||
|
||||
1. 用户确认进入 `topic_move_collector` 即表示同意为本次收集任务执行完整召回;无需再要求用户额外说“全部 / 全量 / 继续翻”。本规则覆盖 `lark-drive-search.md` 的默认首屏交互规则。
|
||||
2. 仍遵守 `lark-drive-search.md` 的单轮最多 5 页限制。每读取最多 5 页形成一个批次;批次结束且 `has_more=true` 时,保存 checkpoint,并使用原 query、原过滤条件和返回的 `next_page_token` 自动开始下一批。
|
||||
3. 自动续批不改变 workflow 状态,也不触发用户确认。执行超过约 60 秒时只输出进度。
|
||||
4. 一个 query 只有在 `has_more=false` 时才是 `complete`。单批结束、达到 5 页或已有部分候选都不代表完成。
|
||||
5. 当前状态的全部 query 都为 `complete` 后,才能进入下一状态。认证、权限、无效分页 token、连续重试失败或工具预算不足属于 blocker;必须保留 checkpoint、报告部分召回并停在当前状态,不得把部分结果当成完整召回继续分类。
|
||||
|
||||
### QueryRecallState
|
||||
|
||||
每个基础 / 增强 query 必须维护:
|
||||
|
||||
```json
|
||||
{
|
||||
"query_id": "稳定 query ID",
|
||||
"query": "完整 query",
|
||||
"recall_stage": "search_recall|recall_enhance",
|
||||
"page_count": 0,
|
||||
"batch_count": 0,
|
||||
"next_page_token": "下一批起点",
|
||||
"has_more": true,
|
||||
"status": "pending|running|complete|blocked",
|
||||
"blocker": "阻塞原因"
|
||||
}
|
||||
```
|
||||
|
||||
## 状态:`SEARCH_RECALL`
|
||||
|
||||
进入条件:用户已确认 `CONFIRM_CONTEXT`。
|
||||
|
||||
必须:
|
||||
|
||||
1. 基于已确认的 `topic` 构造基础 query。
|
||||
2. 应用默认 `owner_scope=mine` 和 `constraints` 中的显式限制。
|
||||
3. 不隐式添加 `--folder-tokens` 或 `--space-ids`。
|
||||
4. 当 `owner_scope=mine` 时,所有基础 query 必须带 `--mine`。
|
||||
5. 当 `owner_scope=all_visible` 时,不带 `--mine`,并记录扩展召回风险。
|
||||
6. 除非命令限制要求更低值,否则使用 `--page-size 20`。
|
||||
7. 每个基础 query 按每批最多 5 页执行;批次结束仍有更多结果时自动续批,并合并所有页面。
|
||||
8. 记录基础统计:query、搜索范围、页数、批次数、收集数量、重复数量、阻塞项。
|
||||
9. 只有全部基础 query 的 `status=complete` 且 `has_more=false` 时,才进入 `RECALL_ENHANCE`;出现阻塞时保持在 `SEARCH_RECALL`。
|
||||
|
||||
### 召回进度 UI
|
||||
|
||||
当 `SEARCH_RECALL` 或 `RECALL_ENHANCE` 持续超过约 60 秒时,输出当前进度:
|
||||
|
||||
```text
|
||||
搜索进度:当前阶段 <SEARCH_RECALL|RECALL_ENHANCE>,已执行 <query_count> 个 query,已读取 <page_count> 页,收集候选 <raw_count> 项,去重后 <unique_count> 项。继续搜索,不会创建或移动资源。
|
||||
```
|
||||
|
||||
如果正在执行具体 query,可补充:
|
||||
|
||||
```text
|
||||
当前 query:<query>
|
||||
```
|
||||
|
||||
### 基础 Query 规则
|
||||
|
||||
| 用户输入 | 基础 Query |
|
||||
|------------|----------------|
|
||||
| 单个关键词 | 直接作为 `--query`。 |
|
||||
| 多个关键词组成一个短语 | 优先按用户输入的短语执行。 |
|
||||
| 明确精确短语 | 保留引号。 |
|
||||
| 明确排除词 | 保留负向词。 |
|
||||
| 没有真实关键词,只有过滤条件 | 使用 `--query ""` 搭配过滤条件。 |
|
||||
|
||||
在 `SEARCH_RECALL` 中不得添加同义词、仅标题搜索、仅评论搜索或 OR 扩展。
|
||||
|
||||
### 基础召回输出
|
||||
|
||||
```text
|
||||
基础召回完成:
|
||||
- 使用 query:
|
||||
- 搜索范围:
|
||||
- 应用限制:
|
||||
- 收集候选:
|
||||
- 去重后候选:
|
||||
- 阻塞项:
|
||||
|
||||
下一步:继续执行覆盖增强,不需要你操作;不会创建或移动资源。
|
||||
```
|
||||
|
||||
## 状态:`RECALL_ENHANCE`
|
||||
|
||||
进入条件:基础召回完成。
|
||||
|
||||
必须:
|
||||
|
||||
1. 基于已确认主题和基础召回证据生成增强 query。
|
||||
2. 确保增强 query 可解释且不引入明显污染。
|
||||
3. 每个增强 query 都必须继承 `owner_scope`;`owner_scope=mine` 时必须带 `--mine`。
|
||||
4. 每个 query 都必须按每批最多 5 页处理分页,并自动续批直到 `has_more=false`。
|
||||
5. 有稳定去重键时,按稳定去重键合并候选项。
|
||||
6. 为每个候选项保留 `source_queries` 和命中证据。
|
||||
7. 当 query 不再产生新候选,或出现工具预算 / API 阻塞时,停止增强。
|
||||
|
||||
### 召回阶段退出门禁
|
||||
|
||||
`RECALL_ENHANCE` 完成后,必须:
|
||||
|
||||
1. 确认全部基础和增强 query 的 `status=complete` 且 `has_more=false`,再固化完整 `candidate_items`,包含去重结果、`source_queries`、`match_channels`、`snippets` 和 `dedupe_status`。
|
||||
2. 将 `current_state` 设置为 `RESOURCE_RESOLVE`。
|
||||
3. 加载 [`lark-drive-workflow-topic-move-collector-resolve-verify.md`](lark-drive-workflow-topic-move-collector-resolve-verify.md)。
|
||||
4. 把完整 `candidate_items` 交给 `RESOURCE_RESOLVE`。
|
||||
5. 不得直接进入 `RELEVANCE_CLASSIFY`、`PLAN_MOVE` 或展示相关性结果。
|
||||
6. 不得用搜索标题、摘要或 query 命中直接生成高 / 中 / 低相关分组。
|
||||
|
||||
### 增强策略
|
||||
|
||||
| 策略 | 说明 |
|
||||
|----------|------|
|
||||
| 精确短语 | 对明确短语使用 `"..."` 提高精确命中。 |
|
||||
| `intitle:` | 对项目名、客户名、制度名、报表名等标题特征强的主题执行标题召回。 |
|
||||
| `--only-title` | 当标题命中更可信时使用。 |
|
||||
| `--only-comment` | 当主题可能只出现在评论讨论中时使用。 |
|
||||
| 类型拆分 | 对 `docx`、`sheet`、`bitable`、`slides`、`file` 等分类型搜索,减少服务端排序偏差。 |
|
||||
| 同义词 / 别名 | 使用业务上明确的同义词、简称、英文名、中文名。 |
|
||||
| OR 扩展 | 对同一实体的别名做 OR 扩展。 |
|
||||
| 负向词 | 对明显噪声使用 `-term`,但不能排除可能相关的主题词。 |
|
||||
|
||||
### Query 证据
|
||||
|
||||
每个候选项都要记录:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|-------|------|
|
||||
| `source_queries` | 命中过该资源的 query 列表。 |
|
||||
| `match_channels` | 命中位置,如 title、body、comment、metadata。 |
|
||||
| `snippets` | 搜索返回的摘要或片段。 |
|
||||
| `query_rank` | 资源在各 query 中的相对位置。 |
|
||||
| `recall_stage` | `search_recall` 或 `recall_enhance`。 |
|
||||
|
||||
## 去重规则
|
||||
|
||||
必须:
|
||||
|
||||
1. 搜索响应提供 canonical token 时,优先使用 canonical token。
|
||||
2. 对 Wiki 结果,不得只按 object token 去重;同一对象可能出现在多个 Wiki 节点中。
|
||||
3. token 缺失时,使用 URL 作为 fallback。
|
||||
4. 合并重复项时保留所有 query 证据。
|
||||
5. 如果无法确定去重是否稳定,保留该项并设置 `dedupe_status=uncertain`。
|
||||
|
||||
## CandidateItem
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "资源标题",
|
||||
"url": "资源链接",
|
||||
"raw_type": "搜索返回类型",
|
||||
"source_queries": ["query"],
|
||||
"match_channels": ["title|body|comment|metadata"],
|
||||
"snippets": ["命中片段"],
|
||||
"page_rank": 1,
|
||||
"dedupe_key": "候选去重键",
|
||||
"dedupe_status": "stable|fallback|uncertain",
|
||||
"recall_stage": "search_recall|recall_enhance"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|-------|------|
|
||||
| `title` | 搜索结果标题。 |
|
||||
| `url` | 资源访问链接。 |
|
||||
| `raw_type` | 搜索返回的原始类型。 |
|
||||
| `source_queries` | 命中过该资源的搜索 query。 |
|
||||
| `match_channels` | 命中位置。 |
|
||||
| `snippets` | 摘要或命中片段。 |
|
||||
| `page_rank` | 当前 query 下的排序位置。 |
|
||||
| `dedupe_key` | 候选去重键。 |
|
||||
| `dedupe_status` | 去重可信度。 |
|
||||
| `recall_stage` | 资源首次进入候选集的召回阶段。 |
|
||||
|
||||
## 阻塞项
|
||||
|
||||
缺少认证 / scope、`drive +search` 返回权限或策略阻塞、分页 token 无效、分页重试后仍无法继续,或工具预算不足以完成全部页面时,必须把对应 `QueryRecallState.status` 设置为 `blocked`,保留累计候选、页数和 `next_page_token`,停止并报告。阻塞解除后从 checkpoint 续跑;在全部 query 完成前不得进入资源解析或分类阶段。
|
||||
@@ -1,231 +0,0 @@
|
||||
# 主题资料收集工作流:资源解析与内容验证
|
||||
|
||||
由状态 `RESOURCE_RESOLVE`、`CONTENT_VERIFY` 加载。
|
||||
|
||||
本文档负责资源解析、结构化父级、移动资格、内容验证和 `ResourceItem`。不得判断相关性、生成移动计划、创建目标、移动资源或执行恢复操作。
|
||||
|
||||
本文档只服务 `topic_move_collector`。进入本文档时,`workflow_id` 必须是 `topic_move_collector`;不得把当前任务改路由到其他 workflow。
|
||||
|
||||
## 必读上下文
|
||||
|
||||
执行本文档规则前:
|
||||
|
||||
1. 按 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 处理身份、认证和权限。
|
||||
2. 按 [`lark-drive-inspect.md`](lark-drive-inspect.md) 处理 URL / token 解析。
|
||||
3. 使用 `drive metas batch_query` 补齐 Drive 资源 owner、标题和 URL。
|
||||
4. 必要时使用 `drive permission.members auth` 读取权限信号;该接口不提供 `full_access` / 移动权限的直接判定,不能把 `manage_public` 等同为可移动。
|
||||
5. 按 [`../../lark-wiki/references/lark-wiki-node-get.md`](../../lark-wiki/references/lark-wiki-node-get.md) 处理 Wiki 节点解析。
|
||||
6. 按 [`../../lark-doc/references/lark-doc-fetch.md`](../../lark-doc/references/lark-doc-fetch.md) 读取文档内容。
|
||||
7. 需要验证 Sheet 内容时,按 [`../../lark-sheets/SKILL.md`](../../lark-sheets/SKILL.md) 执行。
|
||||
|
||||
## 进入解析与验证阶段前校验
|
||||
|
||||
进入本文档后,如果 `resource_items` 还不存在,当前状态必须是 `RESOURCE_RESOLVE`。
|
||||
|
||||
禁止从 `candidate_items` 直接进入 `CONTENT_VERIFY` 或 `RELEVANCE_CLASSIFY`,也禁止从 `RESOURCE_RESOLVE` 直接进入 `RELEVANCE_CLASSIFY`。即使候选项已有标题、URL、摘要或 token,也必须依次执行 `RESOURCE_RESOLVE` 和 `CONTENT_VERIFY`;两个状态不得合并。
|
||||
|
||||
## 状态:`RESOURCE_RESOLVE`
|
||||
|
||||
进入条件:候选列表已准备。
|
||||
|
||||
必须:
|
||||
|
||||
1. 为每个 `CandidateItem` 生成稳定 `resource_id`,并转换为标准化 `ResourceItem`。
|
||||
2. 解析 canonical token、资源类型、URL、结构化当前父级、Wiki 节点身份和读取权限状态。
|
||||
3. 对 Wiki 资源同时保留 `wiki_node_token` 和 `wiki_obj_token`。
|
||||
4. 按 `move_method` 补齐 `owner_id`、`is_owner`、`source_move_state`、`source_parent_write_state`、`target_write_state`、`move_permission_state` 和 `move_permission_basis`。
|
||||
5. 基于 `target_location` 检测不支持的移动方向。
|
||||
6. 未解析成功的资源仍保留在审核分组中,不得静默丢弃。
|
||||
7. 即使搜索结果已经包含标题、URL 或 token,也必须经过本状态生成 `ResourceItem`;不得从召回结果直接进入相关性分级。
|
||||
8. 只有确认 `move_permission_state=movable` 且 `target_write_state=confirmed` 的资源,才能进入后续默认移动链路。
|
||||
9. 解析耗时超过约 60 秒时,必须输出进度提示,之后约每 60 秒提示一次。
|
||||
|
||||
### 解析规则
|
||||
|
||||
| 候选类型 | agent 必须执行 |
|
||||
|----------------|---------------|
|
||||
| Drive URL / token | token 或类型不确定时,使用 `drive +inspect`。 |
|
||||
| Wiki URL / token | 使用 `drive +inspect` 或 `wiki +node-get`;保留节点身份和对象身份。 |
|
||||
| 文件夹候选 | 标记为容器;不要当作普通文档做内容验证。 |
|
||||
| 快捷方式候选 | 能解析源资源时解析源资源;同时保留快捷方式身份。 |
|
||||
| 无读取权限 | 保留可见元数据,并设置 `permission_state=denied`。 |
|
||||
| 无移动权限或移动权限未知 | 保留可见元数据和召回证据,并设置对应 `move_permission_state`。 |
|
||||
| 无法解析当前父级 | 设置 `current_parent_kind=unknown`,保留已知路径,后续计划项设置 `rollback_supported=false` 和明确 blocker;不得编造父级 token。 |
|
||||
|
||||
### 资源解析进度 UI
|
||||
|
||||
当 `RESOURCE_RESOLVE` 持续超过约 60 秒时,输出当前进度:
|
||||
|
||||
```text
|
||||
资源解析进度:已解析 <resolved_count>/<total_count> 项,已确认可移动 <movable_count> 项,无移动权限 <denied_count> 项,移动权限未知 <unknown_count> 项,解析失败 <failed_count> 项。
|
||||
当前资源:<title>
|
||||
继续解析中,不会创建或移动资源。
|
||||
```
|
||||
|
||||
如果正在处理权限或 owner 元数据,可补充:
|
||||
|
||||
```text
|
||||
当前步骤:解析 owner / 当前父级 / 移动资格。
|
||||
```
|
||||
|
||||
`RESOURCE_RESOLVE` 完成后,输出摘要:
|
||||
|
||||
```text
|
||||
资源解析完成:
|
||||
- 候选总数:N 项
|
||||
- 可进入内容验证:N 项
|
||||
- 无移动权限:N 项
|
||||
- 移动权限未知:N 项
|
||||
- 解析失败或无读取权限:N 项
|
||||
|
||||
下一步会对可移动资源做内容验证;不会创建或移动资源。
|
||||
```
|
||||
|
||||
### 资源解析出口门禁
|
||||
|
||||
`RESOURCE_RESOLVE` 完成后必须:
|
||||
|
||||
1. 将 `content_verify_completed` 重置为 `false`。
|
||||
2. 将下一状态设置为 `CONTENT_VERIFY`,不得设置为 `RELEVANCE_CLASSIFY` 或 `PLAN_MOVE`。
|
||||
3. 不得在本状态生成 `relevance`、`relevance_groups` 或移动计划。
|
||||
4. 即使可读取正文的资源数量为 0,也必须进入 `CONTENT_VERIFY`,为每项记录跳过验证原因并输出验证摘要。
|
||||
|
||||
### 移动资格判定
|
||||
|
||||
`owner` 只能作为部分权限证据,不得单独把资源判为 `movable`。`RESOURCE_RESOLVE` 必须先按 `move_method` 记录以下独立状态:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `source_move_state` | 当前身份是否确认可以对源资源执行对应移动;Drive owner 只可作为 Drive 源资源可管理的证据,Wiki 底层资源 owner 不能证明 Wiki 节点可移动。 |
|
||||
| `source_parent_write_state` | 当前身份是否确认可编辑源位置;仅 `drive_move` 必须确认,其他移动方式为 `not_required`。 |
|
||||
| `target_write_state` | 当前身份是否确认可写目标位置;待创建目标以父级位置的创建 / 写入权限为准。 |
|
||||
|
||||
#### 按移动方式的权限矩阵
|
||||
|
||||
| `move_method` | `source_move_state=confirmed` 的证据 | `source_parent_write_state` | `target_write_state` |
|
||||
|---------------|--------------------------------------|-----------------------------|----------------------|
|
||||
| `drive_move` | 当前用户是可靠解析出的 Drive 资源 owner,或有明确资源可管理证据 | 必须为 `confirmed` | 必须为 `confirmed` |
|
||||
| `wiki_move_docs_to_wiki` | 有明确的 Drive 文档直接迁入权限;仅 owner 元数据不足以证明可直接迁入 | `not_required` | 必须确认目标 Wiki 节点 / 空间可写 |
|
||||
| `wiki_move_node` | 有明确的 Wiki 节点 / 源空间移动权限;不得从底层资源 owner 推导 | `not_required` | 必须确认目标 Wiki 节点 / 空间可写 |
|
||||
| `wiki_move_to_drive` | 有明确的 Wiki 节点移出权限;不得从底层资源 owner 推导 | `not_required` | 必须确认目标 Drive 文件夹可写 |
|
||||
|
||||
#### 聚合顺序
|
||||
|
||||
1. 目标方向或资源类型不支持时,设置 `move_permission_state=denied`、`move_permission_basis=["unsupported_direction"]`。
|
||||
2. 任一必需状态为 `denied` 时,设置 `move_permission_state=denied`,并在 `move_permission_basis` 记录 `source_denied`、`source_parent_denied` 或 `target_denied`。
|
||||
3. 任一必需状态为 `unknown` 时,设置 `move_permission_state=unknown`,并记录对应的 `source_unknown`、`source_parent_unknown` 或 `target_unknown`。
|
||||
4. 只有权限矩阵中的全部必需状态都为 `confirmed` 时,才能设置 `move_permission_state=movable`、`move_permission_basis=["permission_matrix_confirmed"]`。
|
||||
|
||||
注意:
|
||||
|
||||
1. `drive permission.members auth` 不提供 `full_access` 或 `move` action;不能用 `view`、`edit`、`share` 或 `manage_public` 结果推断源位置或目标位置可写。
|
||||
2. `target_write_state=unknown|denied` 的资源不得进入高 / 中相关可执行分组或移动计划。
|
||||
3. `move_permission_state=unknown` 的资源默认不进入内容验证、相关性高 / 中分组或移动计划。
|
||||
4. 当 `owner_scope=mine` 但解析出的 owner 不是当前用户时,将该资源视为异常候选,设置 `source_move_state=unknown` 和 `move_permission_state=unknown`,不得加入移动计划。
|
||||
|
||||
## 状态:`CONTENT_VERIFY`
|
||||
|
||||
进入条件:资源列表已准备。
|
||||
|
||||
必须:
|
||||
|
||||
1. 本状态不可跳过,也不得与 `RESOURCE_RESOLVE` 或 `RELEVANCE_CLASSIFY` 合并;没有可读取正文的资源时仍须执行。
|
||||
2. 只在资源解析后读取支持的内容。
|
||||
3. 按数量、大小和类型能力限制读取范围。
|
||||
4. 结合搜索证据和内容证据;除非标题精确且足够强,否则不要仅凭标题判为高相关。
|
||||
5. 将不可读取资源标记为 `unverifiable` 或 `permission_denied`。
|
||||
6. 不得自动申请权限。
|
||||
7. 为每个资源写入验证状态:已读取内容证据、仅可使用搜索证据、无权限、无移动权限、移动权限未知、无法验证或不支持内容验证。
|
||||
8. 对 `move_permission_state=denied|unknown` 的资源,不再读取正文内容,写入跳过验证原因并保留召回证据;写入跳过原因属于执行本状态,不等于跳过本状态。
|
||||
9. 所有资源都有验证状态或跳过原因后,将 `content_verify_completed` 设置为 `true` 并输出验证摘要。
|
||||
10. `content_verify_completed=true` 前不得进入 `RELEVANCE_CLASSIFY`。
|
||||
|
||||
### 验证方式
|
||||
|
||||
| 资源类型 | 验证方式 |
|
||||
|---------------|---------------------|
|
||||
| `docx` / `doc` | 允许时使用 `docs +fetch --api-version v2`。 |
|
||||
| `sheet` | 使用 `sheets +find` 查关键词证据,或用 `sheets +read` 读取有界范围。 |
|
||||
| `bitable` | 只有必要且已加载 Base 能力时验证。 |
|
||||
| `slides` | 除非具备幻灯片读取能力,否则使用元数据 / 预览 / 标题证据。 |
|
||||
| `file` | 仅在支持时使用标题、元数据、预览或导出文本。 |
|
||||
| `wiki` 节点 | 按 `obj_type` 验证底层对象;节点本身不是内容 token。 |
|
||||
| `folder` | 除非用户明确要移动容器,否则通常不作为主题证据移动。 |
|
||||
|
||||
### 内容验证完成 UI
|
||||
|
||||
完成 `CONTENT_VERIFY` 后必须输出:
|
||||
|
||||
```text
|
||||
内容验证完成:
|
||||
- 已读取内容证据:N 项
|
||||
- 仅复用搜索证据:N 项
|
||||
- 因无权限或移动资格跳过:N 项
|
||||
- 无法验证或不支持验证:N 项
|
||||
|
||||
下一步会基于以上证据进行相关性分组;不会创建或移动资源。
|
||||
```
|
||||
|
||||
如果没有任何资源可以读取正文,仍须输出该摘要,并明确说明所有资源采用的搜索证据或跳过原因。
|
||||
|
||||
### 内容验证出口门禁
|
||||
|
||||
`CONTENT_VERIFY` 完成后必须:
|
||||
|
||||
1. 确认 `content_verify_completed=true`,且每个 `ResourceItem` 都已有验证状态或跳过原因。
|
||||
2. 将下一状态设置为 `RELEVANCE_CLASSIFY`。
|
||||
3. 加载 [`lark-drive-workflow-topic-move-collector-review-plan.md`](lark-drive-workflow-topic-move-collector-review-plan.md)。
|
||||
4. 不得直接进入 `PLAN_MOVE`。
|
||||
|
||||
## ResourceItem
|
||||
|
||||
```json
|
||||
{
|
||||
"resource_id": "稳定资源 ID",
|
||||
"title": "资源标题",
|
||||
"resource_type": "doc|docx|sheet|bitable|file|folder|wiki|slides|shortcut",
|
||||
"url": "资源链接",
|
||||
"canonical_token": "标准资源 token",
|
||||
"wiki_node_token": "Wiki 节点 token",
|
||||
"wiki_obj_token": "Wiki 底层对象 token",
|
||||
"wiki_obj_type": "Wiki 底层对象类型",
|
||||
"space_id": "知识空间 ID",
|
||||
"current_parent_kind": "drive_folder|drive_root|wiki_node|wiki_space_root|unknown",
|
||||
"current_parent_token": "当前父级 token",
|
||||
"current_parent_space_id": "当前父级 Wiki space_id",
|
||||
"current_path": "用于展示的当前位置",
|
||||
"owner_id": "资源 owner open_id",
|
||||
"is_owner": "true|false|unknown",
|
||||
"permission_state": "readable|denied|unknown",
|
||||
"source_move_state": "confirmed|unknown|denied",
|
||||
"source_parent_write_state": "confirmed|unknown|denied|not_required",
|
||||
"move_permission_state": "movable|denied|unknown",
|
||||
"move_permission_basis": ["权限矩阵证据或阻塞原因"],
|
||||
"target_write_state": "confirmed|unknown|denied",
|
||||
"item_resolve_status": "resolved|partial|failed",
|
||||
"content_verify_state": "verified|search_evidence_only|skipped_by_move_permission|permission_denied|unverifiable|unsupported",
|
||||
"content_evidence": ["证据"],
|
||||
"relevance": "high|medium|low|permission_denied|no_move_permission|move_permission_unknown|unverifiable|unsupported_move_target"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|-------|------|
|
||||
| `canonical_token` | 内容读取、Drive 对象操作或底层对象操作使用的标准 token;Wiki 节点移动不得使用该字段。 |
|
||||
| `resource_id` | 资源解析时生成的稳定 ID,用于连接 `ResourceItem` 和 `MovePlanItem`。 |
|
||||
| `wiki_node_token` | Wiki 节点身份,用于 Wiki 节点移动。 |
|
||||
| `wiki_obj_token` | Wiki 节点背后的真实文档 token。 |
|
||||
| `current_parent_kind` / `current_parent_token` / `current_parent_space_id` | 结构化执行前父级,用于 `already_at_target` 判断和恢复;未知值不得猜测。 |
|
||||
| `current_path` | 仅用于用户展示的当前位置,不得代替父级 token。 |
|
||||
| `owner_id` | 资源 owner;Drive 资源优先来自 `drive metas batch_query`,Wiki 节点优先来自 `wiki +node-get`。 |
|
||||
| `is_owner` | 当前用户是否为资源 owner。 |
|
||||
| `permission_state` | 当前身份下的读取权限状态。 |
|
||||
| `source_move_state` | 当前身份是否确认能对源资源执行所选 `move_method`;必须按权限矩阵判断。 |
|
||||
| `source_parent_write_state` | Drive 内移动所需的源位置编辑状态;非 `drive_move` 为 `not_required`。 |
|
||||
| `move_permission_state` | 权限矩阵聚合结果;只有 `movable` 且目标写入状态为 `confirmed` 才可进入默认移动链路。 |
|
||||
| `move_permission_basis` | 移动资格判断依据,用于解释为什么纳入或排除。 |
|
||||
| `target_write_state` | 目标位置是否确认可写。 |
|
||||
| `item_resolve_status` | 资源项解析状态;不要和 `TargetLocation.target_resolve_status` 混用。 |
|
||||
| `content_verify_state` | 内容验证状态或跳过验证原因。 |
|
||||
| `content_evidence` | 支撑相关性判断的命中证据。 |
|
||||
| `relevance` | 相关性和可执行性分组。 |
|
||||
@@ -1,248 +0,0 @@
|
||||
# 主题资料收集工作流:审核与计划
|
||||
|
||||
由状态 `RELEVANCE_CLASSIFY`、`PLAN_MOVE` 加载。
|
||||
|
||||
本文档负责相关性分级、审核 UI、移动计划生成和 `MovePlanItem`。不得重新执行资源解析或内容验证,也不得创建目标、移动资源或执行恢复操作。
|
||||
|
||||
本文档只服务 `topic_move_collector`。进入本文档时,`workflow_id` 必须是 `topic_move_collector`;不得把当前任务改路由到其他 workflow。
|
||||
|
||||
## 输入契约
|
||||
|
||||
进入本文档前必须已有:
|
||||
|
||||
1. `resource_items`,且每个 `ResourceItem` 已包含稳定 `resource_id`、资源类型、移动所需 token、结构化当前父级、权限状态、内容验证状态和证据。
|
||||
2. `content_verify_completed=true`。
|
||||
3. 每个资源都有内容证据、搜索证据复用说明或明确跳过原因。
|
||||
|
||||
`ResourceItem` schema 和字段生成规则由 [`lark-drive-workflow-topic-move-collector-resolve-verify.md`](lark-drive-workflow-topic-move-collector-resolve-verify.md) 负责。只要上述输入契约完整,本状态不得为重复读取 schema 而重新加载或执行前一阶段文档。
|
||||
|
||||
如果输入字段缺失、资源需要重新解析或用户要求重新读取证据,废弃受影响的相关性和计划结果,返回 `RESOURCE_RESOLVE` 或 `CONTENT_VERIFY`,并加载资源解析与内容验证文档;不得在本状态补猜。
|
||||
|
||||
## 状态:`RELEVANCE_CLASSIFY`
|
||||
|
||||
进入条件:`CONTENT_VERIFY` 已完成,`content_verify_completed=true`,且每个 `ResourceItem` 都已有验证状态或跳过验证原因。
|
||||
|
||||
禁止条件:
|
||||
|
||||
1. 只有 `candidate_items`,没有 `resource_items`。
|
||||
2. 资源未经过 `RESOURCE_RESOLVE`。
|
||||
3. 资源没有 `RESOURCE_RESOLVE` 写入的移动资格状态。
|
||||
4. 资源没有 `CONTENT_VERIFY` 写入的验证状态或跳过验证原因。
|
||||
5. 上一完成状态是 `RESOURCE_RESOLVE`,或 `content_verify_completed` 不为 `true`。
|
||||
|
||||
必须将每个资源归入且只归入一个分组:
|
||||
|
||||
| 分组 | 说明 | 默认移动 |
|
||||
|-------|------|--------------|
|
||||
| `high` | 可移动资源,且主题或内容直接命中,有明确标题 / 正文 / 表格 / 评论证据。 | 是 |
|
||||
| `medium` | 可移动资源,可能相关,但证据不足或只命中弱相关片段。 | 否,需用户选择 |
|
||||
| `low` | 可移动资源,弱相关或噪声,保留展示但不建议移动。 | 否 |
|
||||
| `permission_denied` | 当前身份无权读取或解析,不能验证内容。 | 否 |
|
||||
| `no_move_permission` | 已确认当前身份不具备移动资格。 | 否 |
|
||||
| `move_permission_unknown` | 无法确认当前身份是否具备移动资格。 | 否 |
|
||||
| `unverifiable` | 类型或工具限制导致无法验证内容。 | 否 |
|
||||
| `unsupported_move_target` | 目标方向或资源类型不支持移动。 | 否 |
|
||||
|
||||
`high`、`medium` 和 `low` 只能包含 `move_permission_state=movable` 且 `target_write_state=confirmed` 的资源。
|
||||
|
||||
判为高相关至少需要一个强证据:
|
||||
|
||||
1. 标题或内容中出现精确主题短语。
|
||||
2. 多个主题词在相关上下文中同时出现。
|
||||
3. Sheet / 表格单元格明确匹配用户主题。
|
||||
4. 用户明确提供的文档名或项目别名命中。
|
||||
|
||||
中相关示例:
|
||||
|
||||
1. 标题包含一个主题词,但内容无法确认。
|
||||
2. 搜索摘要看起来相关,但无法完整读取。
|
||||
3. 别名命中合理但证据不够强。
|
||||
|
||||
## 审核 UI
|
||||
|
||||
必须展示每个分组中的资源名称。
|
||||
|
||||
默认展示规则:
|
||||
|
||||
1. 展开 `high` 和 `medium`。
|
||||
2. 折叠 `low`、`permission_denied`、`no_move_permission`、`move_permission_unknown`、`unverifiable` 和 `unsupported_move_target`,但展示数量并允许展开。
|
||||
3. 每个可见资源展示标题、类型、当前位置、证据和默认动作。
|
||||
4. 除非用户要求技术细节,否则不展示原始 token。
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
筛选结果:
|
||||
|
||||
搜索范围:<当前用户 owner / 负责的资源 | 所有当前身份可见资源>
|
||||
|
||||
高相关(默认移动):
|
||||
- 标题|类型|证据|当前位置
|
||||
|
||||
中相关(需你勾选后才移动):
|
||||
- 标题|类型|证据|当前位置
|
||||
|
||||
未默认移动:
|
||||
- 低相关:N 项
|
||||
- 无权限:N 项
|
||||
- 无移动权限:N 项
|
||||
- 移动权限未知:N 项
|
||||
- 无法验证:N 项
|
||||
- 不支持移动:N 项
|
||||
|
||||
你可以选择:
|
||||
1. 确认按默认规则生成移动计划。
|
||||
2. 勾选要加入计划的中相关资源。
|
||||
3. 要求把某些资源移到其他分组或从计划中移除。
|
||||
4. 展开低相关 / 无权限 / 无移动权限 / 移动权限未知 / 无法验证 / 不支持移动分组查看名称。
|
||||
```
|
||||
|
||||
### 用户调整规则
|
||||
|
||||
如果用户不同意相关性结果,必须基于用户要求更新 `relevance_groups`,再重新展示分组结果并重新生成后续移动计划。
|
||||
|
||||
典型调整包括:
|
||||
|
||||
1. 从 `high` 中移除某个资源。
|
||||
2. 将 `medium` 中某个资源提升为 `high`。
|
||||
3. 将某个资源标为 `low` 或不移动。
|
||||
4. 要求重新读取证据或重新判断一批资源。
|
||||
5. 要求重新确认某些资源的移动权限。
|
||||
|
||||
用户调整后:
|
||||
|
||||
1. 旧的 `move_plan_items` 立即失效。
|
||||
2. 必须先输出“调整后相关性结果”,展示被调整项、各分组数量和高 / 中相关资源名称。
|
||||
3. 不得只回复“已调整”,也不得直接跳到 `CONFIRM_EXECUTION`。
|
||||
4. 必须基于新的 `relevance_groups` 重新执行 `PLAN_MOVE`。
|
||||
5. 不得把 `no_move_permission` 或 `move_permission_unknown` 资源直接提升到 `high` / `medium`;必须先回到 `RESOURCE_RESOLVE`,加载 [`lark-drive-workflow-topic-move-collector-resolve-verify.md`](lark-drive-workflow-topic-move-collector-resolve-verify.md) 取得可移动证据。
|
||||
|
||||
### 调整后结果 UI
|
||||
|
||||
```text
|
||||
已按你的要求调整相关性结果:
|
||||
- <标题>:<原分组> -> <新分组>
|
||||
|
||||
调整后分组:
|
||||
|
||||
搜索范围:<当前用户 owner / 负责的资源 | 所有当前身份可见资源>
|
||||
|
||||
高相关(默认移动):N 项
|
||||
- 标题|类型|证据|当前位置
|
||||
|
||||
中相关(需你勾选后才移动):N 项
|
||||
- 标题|类型|证据|当前位置
|
||||
|
||||
未默认移动:
|
||||
- 低相关:N 项
|
||||
- 无权限:N 项
|
||||
- 无移动权限:N 项
|
||||
- 移动权限未知:N 项
|
||||
- 无法验证:N 项
|
||||
- 不支持移动:N 项
|
||||
|
||||
接下来会基于这个调整后的结果重新生成移动计划;你也可以继续调整。
|
||||
```
|
||||
|
||||
## 状态:`PLAN_MOVE`
|
||||
|
||||
进入条件:相关性分组已准备。
|
||||
|
||||
必须:
|
||||
|
||||
1. 当 `target_location.create_required=true` 时,纳入目标创建计划。
|
||||
2. 生成移动计划前,比较规范化的当前父级与目标父级;已在目标位置的资源生成 `skip_resource`,设置 `skip_reason=already_at_target`,不得生成移动命令。
|
||||
3. 默认纳入全部 `high`、`move_permission_state=movable` 且 `target_write_state=confirmed` 的资源。
|
||||
4. 只有用户明确选择时,才纳入 `medium`、`move_permission_state=movable` 且 `target_write_state=confirmed` 的资源。
|
||||
5. 默认排除 `low`、`permission_denied`、`no_move_permission`、`move_permission_unknown`、`unverifiable` 和 `unsupported_move_target`。
|
||||
6. 为每个跳过项生成 `skip_reason`。
|
||||
7. 为每个计划项生成稳定 `plan_id`,并使用 `resource_id` 连接对应资源;不得按标题或临时 token 猜测关联。
|
||||
8. 按 `command_family` 保存完整、不可变的 `command_args`;不得把 Wiki 底层对象 token 当作 Wiki 节点移动 token。
|
||||
9. 为每个 `move_resource` 项复制执行前恢复所需的完整 `rollback_input`,使确认计划不依赖运行时回查 `ResourceItem`。
|
||||
10. 当前父级无法结构化解析或属于 Drive / Wiki 跨容器移动时,设置 `rollback_supported=false` 和明确 `rollback_blocker`;该单项仍可进入确认,但必须逐项展示不可恢复风险,不得阻塞其他独立项。
|
||||
11. 停止并等待用户选择或执行意图。
|
||||
12. 不得为 `move_permission_state!=movable` 或 `target_write_state!=confirmed` 的资源生成 `move_resource` 计划项。
|
||||
|
||||
### 已在目标位置判定
|
||||
|
||||
1. `drive_move` 比较 `current_parent_kind` 和目标 Drive 父级,并比较规范化后的 `current_parent_token` / root 标识。
|
||||
2. `wiki_move_node` 比较 `current_parent_space_id`、`current_parent_kind` 和 `current_parent_token`;Wiki 空间根节点使用明确的 root 标识,不得用空字符串和未知状态混淆。
|
||||
3. 只有父级类型、space ID(适用时)和 token 都已解析且相等时,才能设置 `skip_reason=already_at_target`;父级未知时不得猜测为相等。
|
||||
|
||||
### 移动 token 选择
|
||||
|
||||
| `command_family` | `command_args` 必须包含 |
|
||||
|------------------|---------------------------|
|
||||
| `drive +move` | `file_token`、`type`、`folder_token`;移动到 Drive root 时显式记录 `folder_token` 为空且目标类型为 root。 |
|
||||
| `wiki +move`(node) | `node_token`,以及 `target_space_id` 或 `target_parent_token`;可选 `source_space_id`。不得使用 `wiki_obj_token` 代替 `node_token`。 |
|
||||
| `wiki +move`(docs-to-wiki) | `obj_type`、`obj_token`、`target_space_id`、可选 `target_parent_token`,并显式保存 `apply=false`。 |
|
||||
| `wiki +move-to-drive` | `node_token`、`folder_token`;移动到 Drive root 时显式记录 `folder_token` 为空。 |
|
||||
| `drive +create-folder` | `name`、父级 `folder_token`;创建在 Drive root 时显式记录父级为空。 |
|
||||
| `wiki +node-create` | `space_id`、`title`、`obj_type`、可选 `parent_node_token`。 |
|
||||
| `none` | 不执行命令,保留 `skip_reason`。 |
|
||||
|
||||
目标由本次 workflow 创建时,对应目标参数保存 `created_by_plan:<create_target plan_id>` 引用。`EXECUTE` 只允许把该引用替换为对应创建计划返回的 token;不得重新搜索或猜测目标。
|
||||
|
||||
### 计划 UI
|
||||
|
||||
```text
|
||||
移动计划已生成:
|
||||
- 默认将移动高相关:N 项
|
||||
- 你已选择中相关:N 项
|
||||
- 其中不可自动恢复:N 项
|
||||
- 已在目标位置:N 项
|
||||
- 不会移动:N 项
|
||||
- 无移动权限:N 项
|
||||
- 移动权限未知:N 项
|
||||
|
||||
你可以回复“确认执行”,也可以继续调整分组、增减中相关资源,或取消本次移动。
|
||||
```
|
||||
|
||||
## MovePlanItem
|
||||
|
||||
```json
|
||||
{
|
||||
"plan_id": "稳定计划项 ID",
|
||||
"resource_id": "对应 ResourceItem.resource_id;create_target 为空",
|
||||
"action_type": "create_target|move_resource|skip_resource|unsupported",
|
||||
"title": "资源或目标名称",
|
||||
"resource_type": "源资源类型",
|
||||
"move_method": "drive_move|wiki_move_node|wiki_move_docs_to_wiki|wiki_move_to_drive|none",
|
||||
"command_family": "具体 shortcut 命令或 none",
|
||||
"command_args": {
|
||||
"<arg>": "按 command_family 参数表保存的完整、类型明确的参数"
|
||||
},
|
||||
"source_path": "用户确认时展示的源位置",
|
||||
"target_path": "用户确认时展示的目标位置",
|
||||
"move_permission_state": "movable|denied|unknown|not_required",
|
||||
"target_write_state": "confirmed|unknown|denied",
|
||||
"reason": "纳入或跳过原因",
|
||||
"skip_reason": "already_at_target 或其他跳过原因",
|
||||
"rollback_input": {
|
||||
"source_kind": "drive|wiki",
|
||||
"original_token": "原始 Drive / obj token",
|
||||
"original_node_token": "原始 Wiki node token",
|
||||
"resource_type": "恢复命令需要的资源类型",
|
||||
"original_parent_kind": "drive_folder|drive_root|wiki_node|wiki_space_root|unknown",
|
||||
"original_parent_token": "原始父级 token",
|
||||
"original_space_id": "原始 Wiki space_id",
|
||||
"original_path": "执行前路径"
|
||||
},
|
||||
"rollback_supported": "是否支持自动恢复",
|
||||
"rollback_blocker": "不可自动恢复原因",
|
||||
"execution_status": "pending|success|failed|skipped"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|-------|------|
|
||||
| `plan_id` | 稳定计划项 ID,用于连接计划、快照和执行日志。 |
|
||||
| `resource_id` | 稳定资源 ID,用于连接确认计划和解析结果;`create_target` 为空。执行阶段不得依赖该关联回查可变参数。 |
|
||||
| `action_type` | 计划动作类型。 |
|
||||
| `move_method` | 实际使用的移动方式。 |
|
||||
| `command_family` / `command_args` | 用户确认的完整写命令及参数快照;确认后保持不可变。目标待创建时只允许使用 `created_by_plan:<plan_id>` 引用。 |
|
||||
| `move_permission_state` / `target_write_state` | 用户确认时的权限门禁快照;`move_resource` 必须分别为 `movable` / `confirmed`。`create_target` 的移动权限为 `not_required`,但父级写入权限仍必须为 `confirmed`。 |
|
||||
| `rollback_input` | 从 `ResourceItem` 复制出的完整恢复输入;仅 `move_resource` 必填,生成确认计划后不得再回查或猜测。 |
|
||||
| `rollback_supported` | 是否支持自动恢复。 |
|
||||
| `rollback_blocker` | 不可自动恢复原因;跨容器移动使用 `cross_container_permission_model_not_losslessly_restorable`,原父级 token 缺失使用 `original_parent_token_unavailable`。 |
|
||||
| `execution_status` | 执行状态。 |
|
||||
@@ -1,174 +0,0 @@
|
||||
# 主题资料收集工作流:输入与目标确认
|
||||
|
||||
由状态 `PARSE_INPUT`、`RESOLVE_TARGET`、`CONFIRM_CONTEXT` 加载。
|
||||
|
||||
本文档负责用户输入解析、目标位置解析、搜索前确认和 `TargetLocation`。不得执行搜索召回、资源分类、目标创建或资源移动。
|
||||
|
||||
本文档只服务 `topic_move_collector`。进入本文档后必须确认 `workflow_id=topic_move_collector`;不得把当前任务改路由到其他 workflow。
|
||||
|
||||
## 必读上下文
|
||||
|
||||
执行本文档规则前:
|
||||
|
||||
1. 按 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 处理身份、认证和权限。
|
||||
2. 解析 Drive 目标时,遵循 [`lark-drive-inspect.md`](lark-drive-inspect.md)、[`lark-drive-create-folder.md`](lark-drive-create-folder.md) 和 [`lark-drive-search.md`](lark-drive-search.md)。
|
||||
3. 解析 Wiki 目标时,遵循 [`../../lark-wiki/SKILL.md`](../../lark-wiki/SKILL.md)、[`../../lark-wiki/references/lark-wiki-node-get.md`](../../lark-wiki/references/lark-wiki-node-get.md) 和 [`../../lark-wiki/references/lark-wiki-node-create.md`](../../lark-wiki/references/lark-wiki-node-create.md)。
|
||||
|
||||
## 状态:`PARSE_INPUT`
|
||||
|
||||
进入条件:workflow 被触发。
|
||||
|
||||
必须:
|
||||
|
||||
1. 提取 `topic`、`target`、`identity`、`owner_scope` 和 `constraints`。
|
||||
2. 将 `topic` 和 `target` 视为必填字段。
|
||||
3. 除非用户明确要求 bot / app 视角,否则 `identity` 默认使用用户身份。
|
||||
4. 默认 `allow_cross_container_move=true`,但必须在 `CONFIRM_CONTEXT` 展示。
|
||||
5. 默认 `owner_scope=mine`,表示只搜索当前用户 owner / 负责的资源。
|
||||
6. 只有用户明确要求“不限 owner”“包括共享给我的”“所有我能看到的文档”或“全量搜索”时,才设置 `owner_scope=all_visible`。
|
||||
7. 除非用户明确提供限制,否则 `constraints` 保持为空。
|
||||
8. 如果缺少 `topic` 或 `target`,只提出最小澄清问题。
|
||||
|
||||
### 输入字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|-------|------|
|
||||
| `topic` | 用户要查找的主题、关键词、内容线索、同义词、缩写、排除词。 |
|
||||
| `target` | 归档目标,可以是已有 Drive 文件夹、已有 Wiki 节点、待创建 Drive 文件夹或待创建 Wiki 节点。 |
|
||||
| `identity` | 执行身份,默认 `--as user`。 |
|
||||
| `owner_scope` | 搜索 owner 范围,默认 `mine`;`all_visible` 仅在用户明确要求扩展到所有可见资源时使用。 |
|
||||
| `constraints` | 用户显式给出的类型、时间、创建人、评论、标题、范围等限制。 |
|
||||
| `allow_cross_container_move` | 是否允许跨 Drive / Wiki 容器移动;默认允许,但必须确认。 |
|
||||
|
||||
### 澄清模板
|
||||
|
||||
```text
|
||||
我还需要补齐两个信息后才能开始:
|
||||
|
||||
1. 要查找的主题 / 关键词 / 内容线索是什么?
|
||||
2. 找到后要移动到哪个 Drive 文件夹或 Wiki 节点?如果需要新建目标,也请说明父级位置和新名称。
|
||||
```
|
||||
|
||||
## 状态:`RESOLVE_TARGET`
|
||||
|
||||
进入条件:`topic` 和 `target` 已获得。
|
||||
|
||||
必须:
|
||||
|
||||
1. 将已有目标解析为具体 token。
|
||||
2. 如果目标需要创建,只解析父级位置和新目标名称。
|
||||
3. 在本状态中不得创建文件夹或 Wiki 节点。
|
||||
4. 分别保留 Drive 文件夹 token、Wiki 节点 token、Wiki 对象 token、space ID 和 parent token。
|
||||
5. 如果目标 URL / token 存在,但当前身份无法读取或解析目标位置,设置 `target_resolve_status=permission_denied`,保持在 `RESOLVE_TARGET` 并等待用户更换目标或结束;不得进入搜索。
|
||||
6. 如果已知移动方向不支持,尽早标记。
|
||||
|
||||
### 目标解析
|
||||
|
||||
| 条件 | agent 必须执行 | 设置 `target_type` |
|
||||
|-----------|---------------|-------------------|
|
||||
| 已有 Drive 文件夹 URL 或 token | 有 URL 时用 `drive +inspect` 解析;保留 `folder_token` | `drive_folder` |
|
||||
| 已有 Wiki 节点 URL 或 token | 用 `wiki +node-get` 或 `drive +inspect` 解析;保留 `wiki_node_token` 和 `space_id` | `wiki_node` |
|
||||
| 在已知父级下新建 Drive 文件夹 | 解析父文件夹;保存新文件夹名称;不创建 | `new_drive_folder` |
|
||||
| 在已知父级下新建 Wiki 节点 | 解析知识空间和可选父节点;保存新节点标题;不创建 | `new_wiki_node` |
|
||||
| 以 Wiki 空间根节点作为目标 | 解析 `space_id`;parent token 可以为空 | `wiki_space` |
|
||||
| 目标名称有歧义 | 仅在必要时搜索或列出候选;展示候选并等待用户选择 | `unknown` |
|
||||
|
||||
### 目标解析状态
|
||||
|
||||
| 条件 | `target_resolve_status` |
|
||||
|------|--------------------------|
|
||||
| 目标已解析,或待创建目标的父级位置已解析 | `resolved` |
|
||||
| 目标名称有歧义、候选不唯一,或 `target_type=unknown` 需要用户选择 | `ambiguous` |
|
||||
| 已知目标方向或目标类型不支持本 workflow | `unsupported` |
|
||||
| 目标 URL / token 存在,但当前身份无权读取、解析或确认目标位置 | `permission_denied` |
|
||||
|
||||
### 目标解析出口门禁
|
||||
|
||||
| `target_resolve_status` | 下一状态 | agent 必须执行 |
|
||||
|-------------------------|----------|----------------|
|
||||
| `resolved` | `CONFIRM_CONTEXT` | 展示已解析目标并进入搜索前确认。 |
|
||||
| `ambiguous` | 保持 `RESOLVE_TARGET` | 展示候选并等待用户选择;不得进入 `CONFIRM_CONTEXT`。 |
|
||||
| `unsupported` | 保持 `RESOLVE_TARGET` | 展示不支持原因,等待用户更换目标或结束;不得搜索。 |
|
||||
| `permission_denied` | 保持 `RESOLVE_TARGET` | 展示权限 blocker,等待用户更换目标或结束;不得搜索。 |
|
||||
|
||||
用户提供新目标后,重新执行 `RESOLVE_TARGET`。只有新的解析结果为 `resolved`,才能进入 `CONFIRM_CONTEXT`;用户选择结束时进入 `DONE`。
|
||||
|
||||
### 跨容器规则
|
||||
|
||||
| 来源 -> 目标 | 默认规则 |
|
||||
|------------------|---------|
|
||||
| Drive 资源 -> Drive 文件夹 | 支持,使用 `drive +move`。 |
|
||||
| Drive 文档类资源 -> Wiki 节点 / 空间 | 资源类型支持时,使用 `wiki +move`。 |
|
||||
| Wiki 节点 -> Wiki 节点 / 空间 | 支持,使用 `wiki +move --node-token`。 |
|
||||
| Wiki 节点 -> Drive 文件夹 | `wiki +move-to-drive`。 |
|
||||
|
||||
## 状态:`CONFIRM_CONTEXT`
|
||||
|
||||
进入条件:`target_resolve_status=resolved`。
|
||||
|
||||
必须:
|
||||
|
||||
1. 展示主题、目标、身份、搜索 owner 范围、限制和目标解析字段。
|
||||
2. 说明下一步只进行搜索 / 读取。
|
||||
3. 说明是否计划创建目标,但尚未执行。
|
||||
4. 展示是否允许跨容器移动。
|
||||
5. 在进入 `SEARCH_RECALL` 前停止并等待用户确认。
|
||||
6. 如果 `owner_scope=all_visible`,明确提示候选数量可能较多,且可能包含无法移动的资源。
|
||||
|
||||
### 确认 UI
|
||||
|
||||
```text
|
||||
我先确认本次收集任务。
|
||||
|
||||
查找主题:
|
||||
目标位置:
|
||||
目标解析:
|
||||
执行身份:
|
||||
搜索范围:
|
||||
可选限制:
|
||||
跨容器移动:
|
||||
下一步操作:只进行搜索和读取验证,不创建目标,不移动资源。
|
||||
|
||||
请确认是否按以上信息开始搜索?
|
||||
```
|
||||
|
||||
默认搜索范围文案:
|
||||
|
||||
```text
|
||||
搜索范围:当前用户 owner / 负责的资源
|
||||
```
|
||||
|
||||
扩展搜索范围文案:
|
||||
|
||||
```text
|
||||
搜索范围:所有当前身份可见资源
|
||||
风险提示:候选数量可能较多,且部分资源可能无法移动;后续仍会经过资源解析和内容验证。
|
||||
```
|
||||
|
||||
如果用户修改任一字段,更新 `topic`、`target_location`、`owner_scope` 或 `constraints`,然后只重新执行受影响的 setup 状态,再次展示确认信息。
|
||||
|
||||
## TargetLocation
|
||||
|
||||
```json
|
||||
{
|
||||
"target_type": "drive_folder|wiki_node|wiki_space|new_drive_folder|new_wiki_node|unknown",
|
||||
"target_token": "已有目标的 folder_token 或 wiki_node_token",
|
||||
"parent_token": "待创建目标的父级 folder_token 或 wiki_node_token",
|
||||
"space_id": "知识库空间 ID",
|
||||
"target_name": "待创建目标名称",
|
||||
"create_required": false,
|
||||
"allow_cross_container_move": true,
|
||||
"target_resolve_status": "resolved|ambiguous|unsupported|permission_denied"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|-------|------|
|
||||
| `target_type` | 目标位置类型,用于决定后续创建和移动命令。 |
|
||||
| `target_token` | 已有目标的可执行 token。 |
|
||||
| `parent_token` | 待创建目标的父级位置 token。 |
|
||||
| `space_id` | Wiki 目标所属知识空间 ID。 |
|
||||
| `target_name` | 待创建目标的名称。 |
|
||||
| `create_required` | 是否需要在 `EXECUTE` 阶段创建目标。 |
|
||||
| `allow_cross_container_move` | 是否允许 Drive / Wiki 之间移动。 |
|
||||
| `target_resolve_status` | 目标位置解析状态;不要和 `ResourceItem.item_resolve_status` 混用。 |
|
||||
@@ -1,202 +0,0 @@
|
||||
# 主题资料收集工作流
|
||||
|
||||
Workflow id: `topic_move_collector`
|
||||
|
||||
Risk / Structure: `R2-R3` / `S3`
|
||||
|
||||
本文档实现已注册的主题资料收集 workflow。执行前必须先阅读 [`lark-drive-workflow.md`](lark-drive-workflow.md) 和 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md),并遵循共享执行协议、Artifact Contract、Workflow Loading、认证和写入确认规则。
|
||||
|
||||
本文档负责定义本 workflow 的全局约束、状态机和渐进加载关系。具体阶段规则放在配套文档中,只有进入对应状态时才加载。
|
||||
|
||||
配套文档只是本 workflow 的引用文件,不是独立 skill。不要把用户请求直接路由到某个配套文档。
|
||||
|
||||
## 必读上下文
|
||||
|
||||
执行本 workflow 前,必须先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md),用于处理身份、认证、权限和写操作确认规则。
|
||||
|
||||
按阶段渐进加载其他 skill / 引用文档:
|
||||
|
||||
- 目标是 Wiki 或个人文档库:[`../../lark-wiki/SKILL.md`](../../lark-wiki/SKILL.md)
|
||||
- 需要读取文档内容:[`../../lark-doc/SKILL.md`](../../lark-doc/SKILL.md) 和 [`../../lark-doc/references/lark-doc-fetch.md`](../../lark-doc/references/lark-doc-fetch.md)
|
||||
- 需要验证 Sheet 内容:[`../../lark-sheets/SKILL.md`](../../lark-sheets/SKILL.md)
|
||||
- 需要 Drive 搜索:[`lark-drive-search.md`](lark-drive-search.md)
|
||||
- 需要资源解析:[`lark-drive-inspect.md`](lark-drive-inspect.md)
|
||||
|
||||
## 适用范围
|
||||
|
||||
本 workflow 用于根据用户给出的主题、关键词或内容线索,在云空间 / 云盘 / Wiki / 电子表格等 Workspace 资源中查找相关资料,并在用户确认后统一移动到指定 Drive 文件夹或 Wiki 节点下。
|
||||
|
||||
适用触发语包括:
|
||||
|
||||
- "帮我找到和某主题相关的文档并放到这个文件夹"
|
||||
- "把所有关于某项目的资料收集到知识库节点下"
|
||||
- "找出包含某内容的资料,确认后移动到新建目录"
|
||||
- "按这个关键词搜索我负责的资料,把相关资料归档"
|
||||
|
||||
默认搜索范围是当前用户 owner / 负责的 Workspace 资源,即 `owner_scope=mine`。只有用户明确要求“不限 owner”“包括共享给我的”“所有我能看到的文档”或“全量搜索”时,才使用 `owner_scope=all_visible` 进入扩展召回模式。
|
||||
|
||||
不要求用户先限定文件夹或知识库范围。只有用户明确指定范围时,才使用 `--folder-tokens`、`--space-ids` 或其他显式限制。
|
||||
|
||||
## 非目标
|
||||
|
||||
默认不生成:
|
||||
|
||||
- 长篇研究报告
|
||||
- 内容总结文档
|
||||
- Sheet 清单或统计看板
|
||||
- 自动权限治理报告
|
||||
|
||||
默认禁止执行:
|
||||
|
||||
- 未确认前创建文件夹或 Wiki 节点
|
||||
- 未确认前移动资源
|
||||
- 删除资源、重命名资源或修改公开权限
|
||||
- 自动批量申请权限
|
||||
- 把无权限或无法验证的资源加入移动计划
|
||||
- 把移动权限未知或不具备移动资格的资源加入移动计划
|
||||
|
||||
如果用户明确要求把结果写入 Sheet / Doc,切到对应专项能力;本 workflow 的默认产物是移动后的资源归档结果。
|
||||
|
||||
## Agent 执行约束
|
||||
|
||||
触发本 workflow 后,agent 必须:
|
||||
|
||||
1. 按“执行状态机”的顺序执行。
|
||||
2. 维护“运行时状态”中的字段。
|
||||
3. 执行某个状态前,先读取本文档 `## 渐进加载关系` 表格中该状态对应的文档。
|
||||
4. 用户可见说明、字段说明和 UI 文案使用中文。
|
||||
5. 状态名、字段名、枚举值、命令名保留英文稳定标识。
|
||||
6. 将 `CONFIRM_CONTEXT` 和 `CONFIRM_EXECUTION` 作为强用户确认门:前者确认主题、目标位置、身份、搜索范围、可选限制和目标解析结果后才能搜索;后者确认创建目标和移动资源后才能写入。
|
||||
7. 进入 `EXECUTE` 前,不得创建目标文件夹 / 节点,也不得移动资源。
|
||||
8. 必须展示每个相关性分组中的资源名称;低置信分组可以折叠,但必须可查看。
|
||||
9. 默认只移动 `high` 相关资源;`medium` 资源必须由用户显式选择。
|
||||
10. 即使用户可见列表分页展示,也必须维护完整内部状态。
|
||||
11. `RESOURCE_RESOLVE` 和 `CONTENT_VERIFY` 是两个独立的强制阶段,不得合并;不得用搜索结果、标题或摘要直接替代 `CONTENT_VERIFY`,也不得从 `RESOURCE_RESOLVE` 直接进入 `RELEVANCE_CLASSIFY`。
|
||||
12. 触发后锁定 `workflow_id=topic_move_collector`;执行期间不得自动切换到其他 workflow。
|
||||
13. 如果认为需要切换 workflow,必须停止并向用户说明原因,等待用户确认。
|
||||
14. `RESOURCE_RESOLVE` 是移动资格门禁;只有确认 `move_permission_state=movable` 且 `target_write_state=confirmed` 的资源才能进入默认移动链路。
|
||||
|
||||
## 用户展示 UI 规则
|
||||
|
||||
所有用户可见 UI 都必须包含:
|
||||
|
||||
1. 已经完成的关键结果。
|
||||
2. 下一步会做什么,以及是否会产生写操作。
|
||||
3. 如果 `wait_for_user=true`,明确告诉用户可以选择的动作。
|
||||
4. 如果无需用户操作,明确说明将继续执行,避免用户误以为流程停住。
|
||||
|
||||
典型动作包括:确认继续、修改主题 / 目标 / 限制、展开更多结果、调整相关性分组、选择中相关资源、确认执行、取消执行。
|
||||
|
||||
## 职责边界
|
||||
|
||||
| 文件 | 负责 | 不负责 |
|
||||
|------|------|--------------|
|
||||
| `lark-drive-workflow-topic-move-collector.md` | 触发规则、全局约束、状态机、渐进加载关系、命令族白名单 | 具体阶段规则、UI 模板、执行细节 |
|
||||
| `lark-drive-workflow-topic-move-collector-setup.md` | `PARSE_INPUT`、`RESOLVE_TARGET`、`CONFIRM_CONTEXT`、`TargetLocation` | 搜索执行、相关性分类、写操作 |
|
||||
| `lark-drive-workflow-topic-move-collector-recall.md` | `SEARCH_RECALL`、`RECALL_ENHANCE`、搜索 query 策略、去重、`CandidateItem` | 资源 token 解析、内容验证、写操作 |
|
||||
| `lark-drive-workflow-topic-move-collector-resolve-verify.md` | `RESOURCE_RESOLVE`、`CONTENT_VERIFY`、权限矩阵、`ResourceItem` | 相关性分类、移动计划、写操作 |
|
||||
| `lark-drive-workflow-topic-move-collector-review-plan.md` | `RELEVANCE_CLASSIFY`、`PLAN_MOVE`、`MovePlanItem`、展示分组 | 资源解析、内容验证、写操作执行、恢复 |
|
||||
| `lark-drive-workflow-topic-move-collector-execute.md` | `CONFIRM_EXECUTION`、`EXECUTE`、`VERIFY`、`RESTORE`、`RollbackSnapshotItem`、执行日志 | 搜索、分类和计划 schema |
|
||||
|
||||
## 运行时状态
|
||||
|
||||
本 workflow 扩展共享 Artifact Contract。agent 在一次 workflow 运行中必须维护以下专项内部字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|-------|------|
|
||||
| `current_state` | 当前状态机节点。 |
|
||||
| `topic` | 用户确认后的主题、关键词、同义词和排除词。 |
|
||||
| `target_location` | 目标位置解析结果,见 setup 文件的 `TargetLocation`。 |
|
||||
| `identity` | 执行身份;默认优先 `--as user`。 |
|
||||
| `owner_scope` | 搜索 owner 范围;默认 `mine`,仅搜索当前用户 owner / 负责的资源;用户明确要求扩展时才为 `all_visible`。 |
|
||||
| `constraints` | 用户显式确认的类型、时间、创建人、范围等限制。 |
|
||||
| `allow_cross_container_move` | 是否允许跨 Drive / Wiki 容器移动;默认允许,但必须展示给用户确认。 |
|
||||
| `recall_query_states` | 每个基础 / 增强 query 的分页状态、累计页数、`next_page_token`、`has_more`、完成或阻塞状态。 |
|
||||
| `candidate_items` | 搜索召回结果,包含 query 证据和去重信息。 |
|
||||
| `resource_items` | 解析后的标准资源列表。 |
|
||||
| `content_verify_completed` | 内容验证阶段完成标记;`resource_items` 新建或变化时重置为 `false`,只有全部资源都有验证状态或跳过原因后才设为 `true`。 |
|
||||
| `relevance_groups` | 高相关、中相关、低相关、无权限、无移动权限、移动权限未知、无法验证、不可移动分组。 |
|
||||
| `move_plan_items` | 经用户选择后生成的完整移动计划,包含稳定资源关联、不可变命令参数、权限快照和恢复输入。 |
|
||||
| `execution_journal` | 写操作日志,用于验证和恢复。 |
|
||||
| `rollback_snapshot` | 写操作前位置快照,仅用于失败恢复或用户要求恢复。 |
|
||||
| `display_page_state` | 用户可见列表的分页、筛选和展开状态。 |
|
||||
|
||||
## 执行状态机
|
||||
|
||||
| 状态 | Protocol Step | 进入条件 | agent 必须执行 | 用户可见输出 | `wait_for_user` | 下一状态 |
|
||||
|-------|---------------|-----------------|---------------|--------------------|---------------|------------|
|
||||
| `PARSE_INPUT` | `route` / `scope` | workflow 被触发 | 加载 setup 文档;解析主题、目标、身份和限制 | 澄清问题或解析摘要 | 必填字段缺失时为 `true` | `RESOLVE_TARGET` |
|
||||
| `RESOLVE_TARGET` | `scope` | 主题和目标已获得 | 解析已有目标,或解析待创建目标;按解析状态分流 | 目标解析结果或 blocker | 非 `resolved` 时为 `true` | `resolved` 时进入 `CONFIRM_CONTEXT`;否则保持本状态 |
|
||||
| `CONFIRM_CONTEXT` | `scope` | `target_resolve_status=resolved` | 展示主题、目标、身份、限制和跨容器设置 | 搜索前确认 UI | `true` | `SEARCH_RECALL` |
|
||||
| `SEARCH_RECALL` | `read` | 用户确认上下文 | 用原始关键词、默认 owner 范围和显式限制执行基础召回;按每批最多 5 页自动续批 | 搜索进度 / 基础统计 | 阻塞时为 `true` | 所有基础 query 完成后进入 `RECALL_ENHANCE` |
|
||||
| `RECALL_ENHANCE` | `read` | 所有基础 query 已完成 | 执行覆盖增强 query,按每批最多 5 页自动续批并合并结果 | 增强召回摘要 | 阻塞时为 `true` | 所有增强 query 完成后进入 `RESOURCE_RESOLVE` |
|
||||
| `RESOURCE_RESOLVE` | `read` | 候选列表已准备 | 解析 token、类型、父级位置、owner 和移动资格 | 解析进度 / 阻塞摘要 | 阻塞时为 `true` | `CONTENT_VERIFY` |
|
||||
| `CONTENT_VERIFY` | `read` | 资源列表已准备 | 对支持的资源做有界内容读取,并为其余资源写入跳过原因 | 验证进度 / 验证摘要 | 阻塞时为 `true` | `RELEVANCE_CLASSIFY` |
|
||||
| `RELEVANCE_CLASSIFY` | `assess` | 证据已准备 | 按相关性和可执行性分组 | 分组结果列表 | `false` | `PLAN_MOVE` |
|
||||
| `PLAN_MOVE` | `assess` / `plan` | 分组完成 | 基于默认规则和用户可选项生成移动计划 | 草案计划和选择项 | `true` | `CONFIRM_EXECUTION` |
|
||||
| `CONFIRM_EXECUTION` | `confirm` | 用户要求执行 | 展示创建、移动、跳过项和风险 | 写操作确认 UI | `true` | `EXECUTE` 或 `PLAN_MOVE` 或 `DONE` |
|
||||
| `EXECUTE` | `execute` | 用户明确确认写操作 | 需要时先创建目标,再移动确认资源 | 执行进度 | 阻塞时为 `true` | `VERIFY` 或 `RESTORE` |
|
||||
| `VERIFY` | `verify` | 执行完成 | 验证目标位置下的移动结果 | 验证结果 | 提供恢复选项时为 `true` | `DONE` 或 `RESTORE` |
|
||||
| `RESTORE` | `recovery confirm` / `recovery execute` | 用户要求恢复 | 仅基于快照和日志恢复 | 恢复确认 / 结果 | 写操作前为 `true` | `VERIFY` 或 `DONE` |
|
||||
| `DONE` | `done` | 无后续操作 | 停止 | 最终回复 | `false` | 结束 |
|
||||
|
||||
### 状态跳转硬约束
|
||||
|
||||
1. `RESOLVE_TARGET` 只有在 `target_resolve_status=resolved` 时才能进入 `CONFIRM_CONTEXT`;`ambiguous`、`unsupported` 或 `permission_denied` 必须保持在 `RESOLVE_TARGET` 并等待用户选择、更换目标或结束。
|
||||
2. `SEARCH_RECALL` 只有在全部基础 query 的 `has_more=false` 时才能进入 `RECALL_ENHANCE`;单批达到 5 页但仍有更多结果时必须自动续批,不得提前跳转。
|
||||
3. `RECALL_ENHANCE` 只有在全部增强 query 的 `has_more=false` 时才能进入 `RESOURCE_RESOLVE`;不得直接进入 `RELEVANCE_CLASSIFY` 或 `PLAN_MOVE`。
|
||||
4. `RESOURCE_RESOLVE` 必须为每个 `CandidateItem` 生成对应的 `ResourceItem`,或生成明确的解析失败 / 权限受限状态。
|
||||
5. `RESOURCE_RESOLVE` 必须为每个 `ResourceItem` 写入 `move_permission_state` 和 `move_permission_basis`;完成后将 `content_verify_completed=false`,下一状态只能是 `CONTENT_VERIFY`。
|
||||
6. 禁止从 `RESOURCE_RESOLVE` 直接进入 `RELEVANCE_CLASSIFY`。即使没有任何资源可以读取正文,也必须进入 `CONTENT_VERIFY`,为每项写入验证状态或跳过原因并输出验证摘要。
|
||||
7. `CONTENT_VERIFY` 必须为每个 `ResourceItem` 写入内容证据、搜索证据复用说明,或不可验证原因;移动权限未知或无移动权限的资源可以只写入跳过验证原因。
|
||||
8. 只有当 `resource_items` 已准备、每项都有验证状态或跳过原因,且 `content_verify_completed=true` 时,才能进入 `RELEVANCE_CLASSIFY`。
|
||||
9. 用户调整相关性分组后,必须回到 `RELEVANCE_CLASSIFY` 输出调整后的分组结果,再进入 `PLAN_MOVE` 重新生成计划。
|
||||
|
||||
### Workflow 切换门禁
|
||||
|
||||
只有以下情况允许考虑切换 workflow:
|
||||
|
||||
1. 用户明确说不再做主题资料收集,改为整理整个目录结构或生成盘点方案。
|
||||
2. 当前 workflow 明确无法覆盖用户的新目标。
|
||||
3. 用户要求的是目录结构治理,而不是查找主题相关资料并移动。
|
||||
|
||||
即使满足以上条件,也不得自动切换;必须先向用户说明原因并等待确认。
|
||||
|
||||
## 渐进加载关系
|
||||
|
||||
| 状态 | 必读文档 |
|
||||
|-------|---------------|
|
||||
| `PARSE_INPUT` / `RESOLVE_TARGET` / `CONFIRM_CONTEXT` | [`lark-drive-workflow-topic-move-collector-setup.md`](lark-drive-workflow-topic-move-collector-setup.md) |
|
||||
| `SEARCH_RECALL` / `RECALL_ENHANCE` | [`lark-drive-workflow-topic-move-collector-recall.md`](lark-drive-workflow-topic-move-collector-recall.md) |
|
||||
| `RESOURCE_RESOLVE` / `CONTENT_VERIFY` | [`lark-drive-workflow-topic-move-collector-resolve-verify.md`](lark-drive-workflow-topic-move-collector-resolve-verify.md) |
|
||||
| `RELEVANCE_CLASSIFY` / `PLAN_MOVE` | [`lark-drive-workflow-topic-move-collector-review-plan.md`](lark-drive-workflow-topic-move-collector-review-plan.md) |
|
||||
| `CONFIRM_EXECUTION` / `EXECUTE` / `VERIFY` / `RESTORE` | [`lark-drive-workflow-topic-move-collector-execute.md`](lark-drive-workflow-topic-move-collector-execute.md) |
|
||||
|
||||
## 命令映射
|
||||
|
||||
| 状态 | 允许的命令族 | 用途 |
|
||||
|-------|--------------------------|---------|
|
||||
| `RESOLVE_TARGET` | `drive +inspect`、`wiki +node-get`、`wiki +space-list`、仅用于查找文件夹候选的 `drive +search` | 解析目标位置 |
|
||||
| `SEARCH_RECALL` / `RECALL_ENHANCE` | `drive +search` | 搜索召回和覆盖增强 |
|
||||
| `RESOURCE_RESOLVE` | `drive +inspect`、`wiki +node-get`、`drive metas batch_query`、必要时 `drive permission.members auth` | 解析标准 token、owner、权限信号和移动资格 |
|
||||
| `CONTENT_VERIFY` | `docs +fetch`、`sheets +read`、`sheets +find`、必要时 `drive +preview` | 验证内容证据 |
|
||||
| `EXECUTE` | `drive +create-folder`、`wiki +node-create`、`drive +move`、`wiki +move`、`wiki +move-to-drive`、`drive +task_result` | 执行已确认写操作 |
|
||||
| `VERIFY` | `drive files list`、`wiki +node-list`、`wiki +node-get`、`drive +inspect`、`drive +task_result` | 验证执行结果 |
|
||||
| `RESTORE` | `drive +move`、`wiki +move`、`drive +delete`、`wiki +node-delete`、`drive +task_result` | 恢复已确认资源并清理本次新建目标 |
|
||||
|
||||
## 引用文档
|
||||
|
||||
- [输入与目标确认](lark-drive-workflow-topic-move-collector-setup.md)
|
||||
- [召回](lark-drive-workflow-topic-move-collector-recall.md)
|
||||
- [资源解析与内容验证](lark-drive-workflow-topic-move-collector-resolve-verify.md)
|
||||
- [审核与计划](lark-drive-workflow-topic-move-collector-review-plan.md)
|
||||
- [执行](lark-drive-workflow-topic-move-collector-execute.md)
|
||||
- [lark-drive-search](lark-drive-search.md)
|
||||
- [lark-drive-inspect](lark-drive-inspect.md)
|
||||
- [lark-drive-move](lark-drive-move.md)
|
||||
- [lark-drive-create-folder](lark-drive-create-folder.md)
|
||||
- [lark-drive-delete](lark-drive-delete.md)
|
||||
- [lark-wiki-move](../../lark-wiki/references/lark-wiki-move.md)
|
||||
- [lark-wiki-move-to-drive](../../lark-wiki/references/lark-wiki-move-to-drive.md)
|
||||
- [lark-wiki-node-create](../../lark-wiki/references/lark-wiki-node-create.md)
|
||||
- [lark-wiki-node-delete](../../lark-wiki/references/lark-wiki-node-delete.md)
|
||||
@@ -97,7 +97,7 @@ Structure Level:
|
||||
2. Entry file 超过约 300 行时,优先拆 `commands`、`outputs` 或 `artifacts` reference。
|
||||
3. 只有执行、验证、恢复或 rollback 状态链复杂到影响可读性时,才升级到 `S3` phase files。
|
||||
4. 垂直业务包优先作为已有 workflow 的 recipe / policy / template,不默认新增独立 workflow。
|
||||
5. 已有样板:`permission_governance` 是 `R2/S2`;`knowledge_organize` 和 `topic_move_collector` 是 `R2-R3/S3`。
|
||||
5. 已有样板:`permission_governance` 是 `R2/S2`;`knowledge_organize` 是 `R2-R3/S3`。
|
||||
|
||||
## 加载与拆分边界
|
||||
|
||||
@@ -108,11 +108,10 @@ Structure Level:
|
||||
|
||||
## Workflow Registry
|
||||
|
||||
| Workflow | Status | Risk | Structure | Entry File | Trigger |
|
||||
|----------|--------|------|-----------|------------|-----------------------------------------------------------------|
|
||||
| Workflow | Status | Risk | Structure | Entry File | Trigger |
|
||||
|----------|--------|------|-----------|------------|---------|
|
||||
| `permission_governance` | Registered | `R2` | `S2` | [`lark-drive-workflow-permission-governance.md`](lark-drive-workflow-permission-governance.md) | 权限审计、公开链接/外部访问、复制/下载/评论/分享设置、权限申请、owner 转移 / 批量 owner 转移、密级标签调整 |
|
||||
| `knowledge_organize` | Registered | `R2-R3` | `S3` | [`lark-drive-workflow-knowledge-organize.md`](lark-drive-workflow-knowledge-organize.md) | 整理云盘 / 文件夹 / 文档库 / 知识库、盘点目录结构、归类资源、生成整理方案,并在用户确认后创建目录或移动资源 |
|
||||
| `topic_move_collector` | Registered | `R2-R3` | `S3` | [`lark-drive-workflow-topic-move-collector.md`](lark-drive-workflow-topic-move-collector.md) | 按主题、关键词或内容线索跨容器搜索资料,验证相关性和移动资格,并在用户确认后归档到 Drive 文件夹或 Wiki 节点 |
|
||||
| `knowledge_organize` | Registered | `R2-R3` | `S3` | [`lark-drive-workflow-knowledge-organize.md`](lark-drive-workflow-knowledge-organize.md) | 整理云盘 / 文件夹 / 文档库 / 知识库、盘点目录结构、归类资源、生成整理方案,并在用户确认后创建目录或移动资源 |
|
||||
|
||||
## Workflow Loading
|
||||
|
||||
|
||||
@@ -14,85 +14,30 @@ metadata:
|
||||
|
||||
**身份**:OKR 操作默认使用 `--as user`(查看当前用户/上下级的 OKR 时)。也支持 `--as bot` 查看他人 OKR(需相应权限)。
|
||||
|
||||
## 快速决策
|
||||
|
||||
| 用户需求 | 操作路径 | 参考文档 |
|
||||
|----------------|----------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| 查看自己/他人的 OKR | 获取用户 ID -> `+cycle-list` -> `+cycle-detail` -> 按需查指标/进展记录 | [`cycle-list`](references/lark-okr-cycle-list.md), [`cycle-detail`](references/lark-okr-cycle-detail.md), [`indicators`](references/lark-okr-indicators.md), [`progress-list`](references/lark-okr-progress-list.md) |
|
||||
| 为自己写一组 OKR | 优先用 `+batch-create` 创建 Objective/KR 骨架 | [`batch-create`](references/lark-okr-batch-create.md), [`contentblock`](references/lark-okr-contentblock.md) |
|
||||
| 只新增一条 O 或单条 KR | 用 `+create` | [`create`](references/lark-okr-create.md) |
|
||||
| 编辑内容/备注/截止时间 | 用 `+patch` | [`patch`](references/lark-okr-patch.md) |
|
||||
| 修改 OKR 分数 | 只有用户明确说“分数”“评分”“打分”“score”时才用 `+patch --score`;分数不是进度/完成度 | [`patch`](references/lark-okr-patch.md) |
|
||||
| 调整顺序或权重 | 用 `+reorder` / `+weight` | [`reorder`](references/lark-okr-reorder.md), [`weight`](references/lark-okr-weight.md) |
|
||||
| 更新数字进度/完成度 | 百分比或不带单位数字用 `+indicator-update`;需要改单位/目标值时查指标后用 `indicators patch` | [`indicator-update`](references/lark-okr-indicator-update.md), [`indicators`](references/lark-okr-indicators.md) |
|
||||
| 写文字进展 | 用 `+progress-create`;如果文本和数字都有,百分比或默认单位可使用 `--progress-percent` 统一改,非百分比单位更新量化指标 | [`progress-create`](references/lark-okr-progress-create.md), [`progress-list`](references/lark-okr-progress-list.md), [`progress-update`](references/lark-okr-progress-update.md) |
|
||||
| 对齐目标 | 直接按对齐关系工作流处理 | [`alignments`](references/lark-okr-alignments.md) |
|
||||
|
||||
分类只在用户明确要求分类,或创建 Objective 返回 `invalid parameters` 且怀疑租户强制开启分类时处理:用 `lark-cli okr categories list --params '{"owner_type":"user","page_size":100}' --as user` 查可用分类,选择语义合适且 `enabled=true` 的分类 ID;分类可后续调整,不必停下等待用户确认。
|
||||
|
||||
获取当前用户用 `contact +get-user`;按姓名/邮箱查他人用 `contact +search-user`,拿到 `open_id` 后再查 OKR。
|
||||
|
||||
```bash
|
||||
lark-cli contact +search-user --query "张三" --has-chatted --as user
|
||||
```
|
||||
|
||||
最常用 OKR 命令示例:
|
||||
|
||||
```bash
|
||||
# 查用户周期,再用周期 ID 查详情
|
||||
lark-cli okr +cycle-list --user-id "ou_xxx" --as user
|
||||
lark-cli okr +cycle-detail --cycle-id 7000000000000000001 --as user
|
||||
|
||||
# 批量创建 Objective/KR
|
||||
lark-cli okr +batch-create \
|
||||
--cycle-id 7000000000000000001 \
|
||||
--input '[{"text":"提升产品用户体验","notes":"关注核心流程和用户反馈","krs":[{"text":"核心流程满意度达到 4.8 分"}]}]' \
|
||||
--as user
|
||||
|
||||
# 更新数字进度/完成度
|
||||
lark-cli okr +indicator-update \
|
||||
--level key-result \
|
||||
--id 7000000000000000003 \
|
||||
--value 75 \
|
||||
--as user
|
||||
```
|
||||
|
||||
分数和进度不要混用:用户说“进度”“完成度”“当前做到 75%”时,通常是在改量化指标或写进展记录,不是在改 `score`。只有明确要求修改 OKR 分数/评分/打分时,才使用 [`+patch --score`](references/lark-okr-patch.md);`score` 取值是 0-1,最多一位小数。
|
||||
|
||||
进度判断规则:用户说“进度”“完成度”时,先判断是否是量化数字。数字进度通常对应量化指标;不可量化文本对应进展记录。需要修改指标单位时看 [`lark-okr-indicators.md`](references/lark-okr-indicators.md)
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli okr +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------------------------------------------------------------|-----------------------------------------------------------------------------------|
|
||||
| [`+cycle-list`](references/lark-okr-cycle-list.md) | 分页获取特定用户的 OKR 周期列表,可以用 `--time-range` 对当前页后置筛选 |
|
||||
| [`+cycle-detail`](references/lark-okr-cycle-detail.md) | 获取特定 OKR 中所有目标和关键结果的内容 |
|
||||
| [`+create`](references/lark-okr-create.md) | 创建单个 Objective(可带备注),或向已有 Objective 新增 KR |
|
||||
| [`+progress-list`](references/lark-okr-progress-list.md) | 分页获取目标或关键结果的进展记录列表 |
|
||||
| [`+progress-get`](references/lark-okr-progress-get.md) | 根据 ID 获取单条 OKR 进展记录 |
|
||||
| [`+progress-create`](references/lark-okr-progress-create.md) | 为目标或关键结果创建进展记录 |
|
||||
| [`+progress-update`](references/lark-okr-progress-update.md) | 更新指定 ID 的进展记录内容 |
|
||||
| [`+progress-delete`](references/lark-okr-progress-delete.md) | 删除指定 ID 的进展记录(不可恢复) |
|
||||
| [`+upload-image`](references/lark-okr-image-upload.md) | 上传图片用于 OKR 进展记录的富文本内容 |
|
||||
| [`+batch-create`](references/lark-okr-batch-create.md) | 批量创建 Objective(可带备注)和 KR |
|
||||
| [`+reorder`](references/lark-okr-reorder.md) | 调整 Objective 或 KR 的顺位 |
|
||||
| [`+weight`](references/lark-okr-weight.md) | 调整 Objective 或 KR 的权重 |
|
||||
| [`+indicator-update`](references/lark-okr-indicator-update.md) | 更新 Objective 或 KR 的当前进度指标。更复杂的量化指标操作见 [量化指标管理](references/lark-okr-indicators.md) |
|
||||
| [`+patch`](references/lark-okr-patch.md) | 部分更新 Objective 或 KR(content、notes、score、deadline) |
|
||||
|
||||
### 创建场景选择
|
||||
|
||||
- **单条创建优先用 [`+create`](references/lark-okr-create.md)**:适合创建一个 Objective,或给已有 Objective 增加一个 KR。
|
||||
- **批量创建用 [`+batch-create`](references/lark-okr-batch-create.md)**:适合一次创建多个 Objective,并可同时附带多个 KR。
|
||||
- 如果你只需要修改已有 Objective / KR 的内容、备注、分数或截止时间,使用 [`+patch`](references/lark-okr-patch.md)。
|
||||
| Shortcut | 说明 |
|
||||
|----------------------------------------------------------------|--------------------------|
|
||||
| [`+cycle-list`](references/lark-okr-cycle-list.md) | 获取特定用户的 OKR 周期列表,可以按时间筛选 |
|
||||
| [`+cycle-detail`](references/lark-okr-cycle-detail.md) | 获取特定 OKR 中所有目标和关键结果的内容 |
|
||||
| [`+progress-list`](references/lark-okr-progress-list.md) | 获取目标或关键结果的所有进展记录列表 |
|
||||
| [`+progress-get`](references/lark-okr-progress-get.md) | 根据 ID 获取单条 OKR 进展记录 |
|
||||
| [`+progress-create`](references/lark-okr-progress-create.md) | 为目标或关键结果创建进展记录 |
|
||||
| [`+progress-update`](references/lark-okr-progress-update.md) | 更新指定 ID 的进展记录内容 |
|
||||
| [`+progress-delete`](references/lark-okr-progress-delete.md) | 删除指定 ID 的进展记录(不可恢复) |
|
||||
| [`+upload-image`](references/lark-okr-image-upload.md) | 上传图片用于 OKR 进展记录的富文本内容 |
|
||||
| [`+batch-create`](references/lark-okr-batch-create.md) | 批量创建 Objective 和 KR |
|
||||
| [`+reorder`](references/lark-okr-reorder.md) | 调整 Objective 或 KR 的顺位 |
|
||||
| [`+weight`](references/lark-okr-weight.md) | 调整 Objective 或 KR 的权重 |
|
||||
| [`+indicator-update`](references/lark-okr-indicator-update.md) | 更新 Objective 或 KR 的指标当前值(简单场景推荐)。更复杂的指标操作见 [量化指标管理](references/lark-okr-indicators.md) |
|
||||
| [`+patch`](references/lark-okr-patch.md) | 部分更新 Objective 或 KR(content、notes、score、deadline) |
|
||||
|
||||
## 格式说明
|
||||
|
||||
- [`OKR 业务实体`](references/lark-okr-entities.md) 获取 OKR 实体结构,定义和关系,帮助你更好的使用 OKR 功能
|
||||
- [`ContentBlock 富文本格式`](references/lark-okr-contentblock.md) — Objective/KeyResult/Progress 中 Content/Note
|
||||
字段使用的富文本格式说明,以及简化的半纯文本(SemiPlainContent)格式的进一步说明。
|
||||
- [`ContentBlock 富文本格式`](references/lark-okr-contentblock.md) — Objective/KeyResult/Progress 中 Content/Note 字段使用的富文本格式说明,以及简化的半纯文本(SemiPlainContent)格式的进一步说明。
|
||||
- **强烈建议** 在操作 OKR 前,阅读[`OKR 业务实体`](references/lark-okr-entities.md)以了解基础概念
|
||||
|
||||
## API Resources
|
||||
@@ -111,9 +56,18 @@ Shortcut 是对常用操作的高级封装(`lark-cli okr +<verb> [flags]`)
|
||||
### cycles
|
||||
|
||||
- `list` — 批量获取用户周期
|
||||
- `objectives_position` — 更新用户周期下全部目标的位置
|
||||
- 请求中必须携带对应周期下全部目标的 ID,否则会参数校验失败。以传入的目标ID顺序重新排列目标。
|
||||
- `objectives_weight` — 更新用户周期下全部目标的权重
|
||||
- 请求中必须同时修改对应周期下全部目标的权重,且所有权重值的和必须等于 1 ,否则会参数校验失败。例如周期下有 2 个目标时:
|
||||
- 正确指令示例如下:
|
||||
``` bash
|
||||
lark-cli okr cycles objectives_weight --params '{"cycle_id": "7000000000000000001"}' --data '{"objective_weights": [{"objective_id": "7000000000000000002", "weight": 0.7}, {"objective_id": "7000000000000000003", "weight": 0.3}]}' --as user
|
||||
```
|
||||
|
||||
### cycle.objectives
|
||||
|
||||
- `create` — 创建目标
|
||||
- `list` — 批量获取用户周期下的目标
|
||||
|
||||
### indicators
|
||||
@@ -156,6 +110,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli okr +<verb> [flags]`)
|
||||
|
||||
### objective.key_results
|
||||
|
||||
- `create` — 创建关键结果
|
||||
- `list` — 批量获取目标下的关键结果
|
||||
|
||||
## 不在本 skill 范围
|
||||
|
||||
@@ -10,7 +10,23 @@
|
||||
# 批量创建 2 个 Objective,各带 2 个 KR。
|
||||
lark-cli okr +batch-create \
|
||||
--cycle-id 7000000000000000001 \
|
||||
--input '[{"text":"提升产品用户体验","mention":["ou_xxxxxxxx"],"notes":"重点关注核心路径体验","krs":[{"text":"页面加载速度提升 50%","mention":["ou_yyyyyyyy"]},{"text":"用户满意度达到 4.8 分"}]},{"text":"拓展新市场份额","krs":[{"text":"新增 10 个城市覆盖"},{"text":"市场份额提升至 25%"}]}]' \
|
||||
--input '[
|
||||
{
|
||||
"text": "提升产品用户体验",
|
||||
"mention": ["ou_xxxxxxxx"],
|
||||
"krs": [
|
||||
{"text": "页面加载速度提升 50%", "mention": ["ou_yyyyyyyy"]},
|
||||
{"text": "用户满意度达到 4.8 分"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": "拓展新市场份额",
|
||||
"krs": [
|
||||
{"text": "新增 10 个城市覆盖"},
|
||||
{"text": "市场份额提升至 25%"}
|
||||
]
|
||||
}
|
||||
]' \
|
||||
--as user
|
||||
|
||||
# 从文件读取输入
|
||||
@@ -28,22 +44,17 @@ lark-cli okr +batch-create \
|
||||
```
|
||||
- mention 是可选参数,不需要使用“@”提及其他用户时不传入。
|
||||
- 传入的 mention 参数会以 @对应用户的形式,添加在文本后。
|
||||
- Objective 的 notes / notes_mention 是可选参数,用于创建目标备注;KR 不支持备注。
|
||||
- Objective 的 category_id 是可选参数;也可以通过 `--category-id` 给所有未显式设置分类的 Objective 指定默认分类。
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 默认值 | 说明 |
|
||||
|------------------|----|-----------|------------------------------------------------------------|
|
||||
| `--cycle-id` | 是 | — | OKR 周期 ID(int64 类型) |
|
||||
| `--input` | 是 | — | JSON 数组格式的 Objective 列表。支持 `@文件路径` 从文件读取或 `-` 从 stdin 读取。 |
|
||||
| `--category-id` | 否 | — | 默认 Objective 分类 ID。仅用于 input 中未设置 `category_id` 的 Objective。通常不需要传入,见下方“分类提示”。 |
|
||||
| `--input` | 是 | — | JSON 数组格式的 Objective 列表。支持 `@文件路径` 从文件读取或 `@-` 从 stdin 读取。 |
|
||||
| `--user-id-type` | 否 | `open_id` | mention 中使用的用户 ID 类型:`open_id` \| `union_id` \| `user_id` |
|
||||
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行 |
|
||||
| `--format` | 否 | `json` | 输出格式 |
|
||||
|
||||
> **分类提示**:当用户明确要求设置 Objective 分类,或创建 Objective 返回 `invalid parameters` 且怀疑租户强制开启分类时,可以配置 category-id 字段进行创建。先运行 `lark-cli okr categories list --as user` 查看可用分类,然后选择一个语义合适且 `enabled=true` 的分类 ID 作为 `category-id`。分类创建后可以再调整;不必因为分类选择停下等待用户确认。
|
||||
|
||||
## 输入格式
|
||||
|
||||
```json
|
||||
@@ -51,9 +62,6 @@ lark-cli okr +batch-create \
|
||||
{
|
||||
"text": "Objective 内容",
|
||||
"mention": ["ou_xxxxxxxx", "ou_yyyyyyyy"],
|
||||
"notes": "Objective 备注",
|
||||
"notes_mention": ["ou_xxxxxxxx"],
|
||||
"category_id": "7249339036661170180",
|
||||
"krs": [
|
||||
{
|
||||
"text": "KR 内容",
|
||||
@@ -64,15 +72,6 @@ lark-cli okr +batch-create \
|
||||
]
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
- `text`:Objective 或 KR 内容,必填。
|
||||
- `mention`:追加到内容后的用户 mention,可选。
|
||||
- `notes`:Objective 备注文本,可选,仅 Objective 支持。
|
||||
- `notes_mention`:追加到 Objective 备注后的用户 mention,可选,仅在 `notes` 存在时有意义。
|
||||
- `category_id`:Objective 分类 ID,可选;会覆盖命令级 `--category-id`。
|
||||
- `krs`:当前 Objective 下要创建的 KR 列表,可选。
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. 使用 `+cycle-list` 获取可用的 OKR 周期 ID
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
# okr +create
|
||||
|
||||
> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
创建单个 OKR 目标(Objective)或关键结果(Key Result)。这是单条写入场景的首选 shortcut;如果需要一次创建多个 Objective 及其 KR,可使用 [`+batch-create`](lark-okr-batch-create.md)。
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
# 在指定周期下创建一个 Objective(默认 simple 风格)
|
||||
lark-cli okr +create \
|
||||
--level objective \
|
||||
--cycle-id 7000000000000000001 \
|
||||
--content '{"text":"提升北极星指标","mention":["ou_xxxxxxxx"]}' \
|
||||
--notes '{"text":"重点关注活跃用户和转化漏斗"}' \
|
||||
--as user
|
||||
|
||||
# 在已有 Objective 下创建一个 KR
|
||||
lark-cli okr +create \
|
||||
--level key-result \
|
||||
--objective-id 7000000000000000002 \
|
||||
--content '{"text":"季度留存率提升到 45%"}' \
|
||||
--as user
|
||||
|
||||
# 使用 richtext 风格创建 Objective(完整 ContentBlock JSON)
|
||||
lark-cli okr +create \
|
||||
--level objective \
|
||||
--cycle-id 7000000000000000001 \
|
||||
--style richtext \
|
||||
--content '{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"建立跨部门协作机制"}}]}}]}' \
|
||||
--as user
|
||||
|
||||
# 预览 API 调用而不实际执行
|
||||
lark-cli okr +create \
|
||||
--level key-result \
|
||||
--objective-id 7000000000000000002 \
|
||||
--content '{"text":"完成 3 次核心流程优化"}' \
|
||||
--dry-run \
|
||||
--as user
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 默认值 | 说明 |
|
||||
|------------------|----|-----------|--------------------------------------------------------------------------------------------------------------------|
|
||||
| `--level` | 是 | — | 创建层级:`objective`(创建目标)\| `key-result`(在已有目标下创建 KR) |
|
||||
| `--cycle-id` | 条件 | — | OKR 周期 ID(int64 类型)。当 `--level=objective` 时**必填**。 |
|
||||
| `--objective-id` | 条件 | — | Objective ID(int64 类型)。当 `--level=key-result` 时**必填**。 |
|
||||
| `--style` | 否 | `simple` | 内容输入风格:`simple`(半纯文本 JSON,推荐) \| `richtext`(完整 ContentBlock JSON)。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。 |
|
||||
| `--content` | 是 | — | 内容。根据 `--style` 指定格式。支持 `@文件路径` 从文件读取或 `-` 从 stdin 读取。 |
|
||||
| `--notes` | 否 | — | Objective 备注,仅 `--level=objective` 支持。根据 `--style` 指定格式,支持 `@文件路径` 或 `-` 从 stdin 读取。 |
|
||||
| `--category-id` | 否 | — | Objective 分类 ID,仅 `--level=objective` 支持。通常不需要传入,见下方“分类提示”。 |
|
||||
| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id`。影响 mention 中用户 ID 的解释方式。 |
|
||||
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 |
|
||||
| `--format` | 否 | `json` | 输出格式。 |
|
||||
|
||||
> **分类提示**:当用户明确要求设置 Objective 分类,或创建 Objective 返回 `invalid parameters` 且怀疑租户强制开启分类时,可以配置 --category-id 参数进行创建。先运行 `lark-cli okr categories list --as user` 查看可用分类,然后选择一个语义合适且 `enabled=true` 的分类 ID 作为 `--category-id`。分类创建后可以再调整;不必因为分类选择停下等待用户确认。
|
||||
|
||||
## 输入格式
|
||||
|
||||
### `--style simple`(默认)
|
||||
|
||||
推荐大多数创建场景使用 `simple` 风格。`--content` 和 `--notes` 都使用 `SemiPlainContent` JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"text": "提升北极星指标",
|
||||
"mention": ["ou_xxxxxxxx"]
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `text` 必填,且不能为空白字符串
|
||||
- `mention` 可选;如果传入,数组中的每个用户 ID 都不能为空字符串
|
||||
- `--notes` 仅适用于 Objective;创建 KR 时传 `--notes` 会报错
|
||||
- 同一条命令只有一个 flag 可以使用 `-` 读取 stdin;如果 `--content -`,`--notes` 请使用内联 JSON 或 `@文件路径`
|
||||
|
||||
### `--style richtext`
|
||||
|
||||
当你需要精确控制段落结构、插入文档链接,或使用完整富文本块结构时,使用 `richtext` 风格:
|
||||
|
||||
```json
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"block_element_type": "paragraph",
|
||||
"paragraph": {
|
||||
"elements": [
|
||||
{
|
||||
"paragraph_element_type": "textRun",
|
||||
"text_run": {
|
||||
"text": "建立跨部门协作机制"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `blocks` 至少需要有一个非空段落或图片块
|
||||
- 不能传空 `blocks`,也不能传只有空段落元素的内容
|
||||
- 更多结构说明见 [ContentBlock 富文本格式](lark-okr-contentblock.md)
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. 如果要创建 Objective,先使用 `+cycle-list` 获取目标周期的 `cycle_id`。
|
||||
2. 如果要给已有 Objective 新增 KR,先通过 `+cycle-detail` 或其他 OKR 查询命令拿到 `objective_id`。
|
||||
3. 选择输入风格:
|
||||
- **推荐**:`simple`,适合普通文本和 mention。
|
||||
- 需要复杂富文本时:`richtext`。
|
||||
4. 执行 `lark-cli okr +create ...`。
|
||||
5. 报告结果:
|
||||
- 创建 Objective 时返回新的 `objective_id`
|
||||
- 创建 KR 时返回新的 `key_result_id`,并附带父 `objective_id`
|
||||
|
||||
## Dry-run 对应接口
|
||||
|
||||
- `--level=objective`:
|
||||
- `POST /open-apis/okr/v2/cycles/:cycle_id/objectives`
|
||||
- `--level=key-result`:
|
||||
- `POST /open-apis/okr/v2/objectives/:objective_id/key_results`
|
||||
|
||||
## 输出
|
||||
|
||||
### 创建 Objective 成功
|
||||
|
||||
```json
|
||||
{
|
||||
"level": "objective",
|
||||
"objective_id": "7000000000000000002"
|
||||
}
|
||||
```
|
||||
|
||||
### 创建 KR 成功
|
||||
|
||||
```json
|
||||
{
|
||||
"level": "key-result",
|
||||
"objective_id": "7000000000000000002",
|
||||
"key_result_id": "7000000000000000003"
|
||||
}
|
||||
```
|
||||
|
||||
## 常见错误与处理
|
||||
|
||||
- `--level=objective` 但未传 `--cycle-id`
|
||||
- 补充有效的周期 ID
|
||||
- `--level=key-result` 但未传 `--objective-id`
|
||||
- 补充已有 Objective 的 ID
|
||||
- `--content` 为空、不是合法 JSON,或内容结构为空
|
||||
- 按 `--style` 对应格式修正输入
|
||||
- 在 `simple` 风格中传了 `docs` 或 `images`
|
||||
- 改用 `--style richtext`,或移除这些字段
|
||||
|
||||
## 何时用 +create,何时用 +batch-create
|
||||
|
||||
| 命令 | 适用场景 |
|
||||
|------|----------|
|
||||
| `+create` | 创建单个 Objective,或向已有 Objective 新增单个 KR |
|
||||
| `+batch-create` | 一次创建多个 Objective,并可同时为每个 Objective 创建多个 KR |
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-okr](../SKILL.md) -- 所有 OKR 命令
|
||||
- [OKR 业务实体](lark-okr-entities.md) -- Objective、KR、周期等基础概念
|
||||
- [ContentBlock 格式](lark-okr-contentblock.md) -- content/notes 字段的另一种输入风格,支持完整富文本格式
|
||||
- [okr +batch-create](lark-okr-batch-create.md) -- 批量创建多个 Objective / KR
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
@@ -2,21 +2,18 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
列出指定用户的一页 OKR 周期,支持外部控制翻页和可选的时间范围后置过滤。
|
||||
列出指定用户的 OKR 周期,支持可选的时间范围过滤。
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
# 获取用户周期第一页 (默认页大小为 100 按时间倒序排列,一般不用翻页)
|
||||
# 列出用户的所有周期
|
||||
lark-cli okr +cycle-list --user-id "ou_xxx"
|
||||
|
||||
# 获取下一页
|
||||
lark-cli okr +cycle-list --user-id "ou_xxx" --page-size 100 --page-token "7000000000000000002"
|
||||
|
||||
# 使用特定的用户 ID 类型列出周期
|
||||
lark-cli okr +cycle-list --user-id "xxx" --user-id-type user_id
|
||||
|
||||
# 列出当前返回页中与时间范围重叠的周期(例如 2025-01 到 2025-06)
|
||||
# 列出时间范围内的周期(例如 2025-01 到 2025-06)
|
||||
lark-cli okr +cycle-list --user-id "ou_xxx" --time-range "2025-01--2025-06"
|
||||
|
||||
# 预览 API 调用而不实际执行
|
||||
@@ -29,9 +26,7 @@ lark-cli okr +cycle-list --user-id "ou_xxx" --dry-run
|
||||
|------------------|----|-----------|------------------------------------------------------------------|
|
||||
| `--user-id` | 是 | — | OKR 所有者的用户 ID |
|
||||
| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` |
|
||||
| `--time-range` | 否 | — | 后置筛选条件:先按 `--page-size`/`--page-token` 请求一页,再在本地保留与该时间范围重叠的周期。格式:`YYYY-MM--YYYY-MM`(例如 `2025-01--2025-06`)。 |
|
||||
| `--page-size` | 否 | `100` | 每页数量,范围 `1-100`。 |
|
||||
| `--page-token` | 否 | `""` | 上一次响应中的 `page_token`,留空表示第一页。 |
|
||||
| `--time-range` | 否 | — | 按时间范围过滤周期。格式:`YYYY-MM--YYYY-MM`(例如 `2025-01--2025-06`)。留空获取所有周期。 |
|
||||
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 |
|
||||
| `--format` | 否 | `json` | 输出格式。 |
|
||||
|
||||
@@ -39,11 +34,8 @@ lark-cli okr +cycle-list --user-id "ou_xxx" --dry-run
|
||||
|
||||
1. 获取目标用户的 `open_id`(或其他 ID 类型)。如果用户说"我的 OKR 周期",先通过 `lark-cli contact +get-user` 获取当前用户的
|
||||
ID。
|
||||
2. 执行 `lark-cli okr +cycle-list --user-id "ou_xxx" --page-size 100`,可选择使用 `--time-range`。
|
||||
3. 如果响应中 `has_more=true`,继续用返回的 `page_token` 调用下一页。
|
||||
4. 报告结果:每个周期的 ID、开始/结束时间和状态。
|
||||
|
||||
`--time-range` 是后置筛选条件,不会改变服务端分页窗口。也就是说,命令会先获取指定页,再过滤该页中的周期;如果需要完整时间范围结果,需要按 `has_more`/`page_token` 逐页拉取并合并。
|
||||
2. 执行 `lark-cli okr +cycle-list --user-id "ou_xxx"`,可选择使用 `--time-range`。
|
||||
3. 报告结果:找到的周期数量、每个周期的 ID、开始/结束时间和状态。
|
||||
|
||||
## 输出
|
||||
|
||||
@@ -59,8 +51,7 @@ lark-cli okr +cycle-list --user-id "ou_xxx" --dry-run
|
||||
"cycle_status": "normal"
|
||||
}
|
||||
],
|
||||
"has_more": true,
|
||||
"page_token": "7000000000000000002",
|
||||
"total": 1,
|
||||
"current_active_cycles": [
|
||||
{
|
||||
"id": "1234567890123456789",
|
||||
@@ -75,7 +66,6 @@ lark-cli okr +cycle-list --user-id "ou_xxx" --dry-run
|
||||
在这个周期信息中,这些字段值得关注:
|
||||
|
||||
- `id` 是这个周期的 ID,你通常需要用它在之后使用 `okr +cycle-detail` 获取 OKR 内容详情
|
||||
- `has_more` 和 `page_token` 用于外部控制翻页;`has_more=true` 时,用 `--page-token` 原样传入本次返回的 `page_token` 获取下一页。
|
||||
- `start_time` `end_time` 是周期的起止时间,总是从某个月1日开始,直到此月或之后某月的最后一日结束。
|
||||
- 在 OKR 系统中,我们只关注这个时间的年月部分,如 "2025-01-01开始,2025-06-30结束" 的周期被称作 "2025 年 1-6 月" 周期,而
|
||||
"2025-01-01开始,2025-01-31结束" 的周期被称作 "2025 年 1 月"周期。
|
||||
|
||||
@@ -50,7 +50,6 @@ Category (分类): Objective 的分组标签
|
||||
|
||||
- **当前周期**: 指周期的 start_time/end_time
|
||||
指周期的 start_time / end_time 所在的时间段与当前时间重叠的周期(即: start_time <= 当前时间 且 end_time >= 当前时间)。 注意:时间重叠是判断当前周期的首要且必须的硬性条件,绝对不能仅仅根据 cycle_status == 1 去判断。 如果有多个符合时间重叠标准的周期,再在这些包含当前时间的周期中过滤,保留周期状态为 default (0) 或 normal (1) 的周期。如果仍然有多个,则选择其中较新的一个。当用户提及“上一个周期”,“下一个周期”一类的表述时,通常是以当前周期为准计算。
|
||||
- 如果用户没有提及,那么当前周期一般不考虑年度周期(起止时间从 01-01 至 12-31 的周期)
|
||||
- **所有者**: 绝大多数所有者都是用户,少部分租户启用了“团队OKR”功能,所有者可能是部门。用户身份下,只能编辑所有者为当前用户的
|
||||
OKR。
|
||||
|
||||
|
||||
@@ -40,9 +40,7 @@ lark-cli okr +indicator-update \
|
||||
|
||||
1. 使用 `+cycle-list` 和 `+cycle-detail` 获取目标 ID 或 KR ID。
|
||||
2. 如需查看当前指标值,使用 `objective.indicators list` 或 `key_result.indicators list` 查询。
|
||||
若当前量化指标没有 start_value/current_value/target_value/unit 这些字段,代表当前量化指标为未设置的默认初始进度。
|
||||
3. 执行 `+indicator-update` 指定层级、ID 和新值。
|
||||
使用 +indicator-update 为默认初始进度设置当前值会将该量化指标配置为默认的百分比模式。若用户不希望将指标设置为百分比,请使用原生 API 详细设置,参考 [lark-okr-indicators.md](lark-okr-indicators.md)
|
||||
3. 执行 `+indicator-update` 指定层级、ID 和新值。
|
||||
4. 命令自动查询指标 ID 并更新当前值。
|
||||
|
||||
## 输出
|
||||
|
||||
@@ -40,11 +40,11 @@ lark-cli okr objective.indicators list --objective-id "<目标ID>" [flags]
|
||||
```bash
|
||||
# 获取目标的量化指标
|
||||
lark-cli okr objective.indicators list \
|
||||
--objective-id 7000000000000000001
|
||||
--objective-id 7652569715131075772
|
||||
|
||||
# 指定用户 ID 类型
|
||||
lark-cli okr objective.indicators list \
|
||||
--objective-id 7000000000000000001 \
|
||||
--objective-id 7652569715131075772 \
|
||||
--user-id-type "user_id"
|
||||
```
|
||||
|
||||
@@ -60,63 +60,6 @@ lark-cli okr objective.indicators list \
|
||||
|
||||
返回 `indicator` 字段,包含该目标的量化指标详情。
|
||||
|
||||
示例返回值:
|
||||
有进度时:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"indicator": {
|
||||
"create_time": "1782835200000", // 创建时间
|
||||
"current_value": 60, // 当前值
|
||||
"current_value_calculate_type": 0, // 当前值计算方式 0(手动更新)|2(按KR计算)|3(按拆解计算)。 仅当此处为 0 时,允许使用 patch API 更新当前值
|
||||
"entity_id": "7000000000000000001",// 指标挂载的 Objective/KR id
|
||||
"entity_type": 2, // 指标挂载在 Objective还是KR 上 2(Objective)|3(KR)
|
||||
"id": "7000000000000000002", // 指标本身的 ID
|
||||
"indicator_status": 0, // 指标状态 -1(未定义)|0(正常)|1(有风险)|2(延期)
|
||||
"owner": { // 指标归属的用户
|
||||
"owner_type": "user",
|
||||
"user_id": "ou_xxx"
|
||||
},
|
||||
"start_value": 0, // 起始值, 默认0
|
||||
"status_calculate_type": 0, // 状态计算方式
|
||||
"target_value": 100, // 目标值, 默认 100
|
||||
"unit": { // 指标单位,默认是公共的百分比
|
||||
"unit_type": 0, // 单位类型 0(公共)|1(自定义)
|
||||
"unit_value": "PERCENT" // 单位名
|
||||
},
|
||||
"update_time": "1782835200000" // 更新时间
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
默认初始进度:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"indicator": {
|
||||
"create_time": "1782835200000",
|
||||
"entity_id": "7000000000000000001",
|
||||
"entity_type": 2,
|
||||
"id": "7000000000000000002",
|
||||
"indicator_status": -1,
|
||||
"owner": {
|
||||
"owner_type": "user",
|
||||
"user_id": "ou_xxx"
|
||||
},
|
||||
"status_calculate_type": 0,
|
||||
"update_time": "1782835200000"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
默认初始进度不携带 start_value/current_value/target_value/unit 等信息,若直接设置当前值,则使用百分比作为默认单位。
|
||||
由于默认单位为百分比,当一定要计算数值时,可以视作 0%,但是向用户汇报默认初始进度时,应当明确对应的 O/KR 未设置进度这一点,以和真正的 0% 区别开。
|
||||
|
||||
---
|
||||
|
||||
## 二、查询关键结果的量化指标
|
||||
@@ -244,7 +187,12 @@ lark-cli okr indicators patch \
|
||||
```bash
|
||||
lark-cli okr indicators patch \
|
||||
--indicator-id "ind-123" \
|
||||
--data '{"current_value":65.0,"current_value_calculate_type":0,"indicator_status":1,"status_calculate_type":0}'
|
||||
--data '{
|
||||
"current_value": 65.0,
|
||||
"current_value_calculate_type": 0,
|
||||
"indicator_status": 1,
|
||||
"status_calculate_type": 0
|
||||
}'
|
||||
```
|
||||
|
||||
4. **验证更新结果**
|
||||
@@ -262,7 +210,10 @@ lark-cli okr key_result.indicators list --key-result-id 7652569715131075780
|
||||
# 2. 更新目标值和单位
|
||||
lark-cli okr indicators patch \
|
||||
--indicator-id 7652569715131075781 \
|
||||
--data '{"target_value":500,"unit":{"unit_type":0,"unit_value":"YUAN"}}'
|
||||
--data '{
|
||||
"target_value": 500,
|
||||
"unit": {"unit_type": 0, "unit_value": "YUAN"}
|
||||
}'
|
||||
```
|
||||
|
||||
## 参考
|
||||
|
||||
@@ -2,24 +2,17 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
获取目标(Objective)或关键结果(Key Result)的一页进展记录列表,支持外部控制翻页。
|
||||
获取目标(Objective)或关键结果(Key Result)的所有进展记录列表。
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
# 获取目标进展记录第一页 (默认页大小为 100,一般不用翻页)
|
||||
# 获取目标的所有进展记录
|
||||
lark-cli okr +progress-list \
|
||||
--target-id 1234567890123456789 \
|
||||
--target-type objective
|
||||
|
||||
# 获取下一页进展记录
|
||||
lark-cli okr +progress-list \
|
||||
--target-id 1234567890123456789 \
|
||||
--target-type objective \
|
||||
--page-size 100 \
|
||||
--page-token "7000000000000000002"
|
||||
|
||||
# 获取关键结果进展记录第一页
|
||||
# 获取关键结果的所有进展记录
|
||||
lark-cli okr +progress-list \
|
||||
--target-id 9876543210987654321 \
|
||||
--target-type key_result
|
||||
@@ -33,17 +26,14 @@ lark-cli okr +progress-list \
|
||||
| `--target-type` | 是 | — | 目标类型:`objective` \| `key_result` |
|
||||
| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` |
|
||||
| `--department-id-type` | 否 | `open_department_id` | 部门 ID 类型:`department_id` \| `open_department_id` |
|
||||
| `--page-size` | 否 | `100` | 每页数量,范围 `1-100`。 |
|
||||
| `--page-token` | 否 | `""` | 上一次响应中的 `page_token`,留空表示第一页。 |
|
||||
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 |
|
||||
| `--format` | 否 | `json` | 输出格式。 |
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. 使用 `+cycle-list` 和 `+cycle-detail` 获取目标或关键结果的 ID。
|
||||
2. 执行 `lark-cli okr +progress-list --target-id "..." --target-type objective --page-size 100`。
|
||||
3. 如果响应中 `has_more=true`,继续用返回的 `page_token` 调用下一页。
|
||||
4. 获取该目标或关键结果下的进展记录列表。
|
||||
2. 执行 `lark-cli okr +progress-list --target-id "..." --target-type objective`。
|
||||
3. 获取该目标或关键结果下的所有进展记录列表。
|
||||
|
||||
## 输出
|
||||
|
||||
@@ -51,7 +41,7 @@ lark-cli okr +progress-list \
|
||||
|
||||
```json
|
||||
{
|
||||
"progress_list": [
|
||||
"progress": [
|
||||
{
|
||||
"progress_id": "1234567890123456789",
|
||||
"modify_time": "2025-01-15 10:30:00",
|
||||
@@ -62,15 +52,13 @@ lark-cli okr +progress-list \
|
||||
}
|
||||
}
|
||||
],
|
||||
"has_more": true,
|
||||
"page_token": "7000000000000000002"
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `progress_list` — 进展记录数组
|
||||
- `has_more` 和 `page_token` 用于外部控制翻页;`has_more=true` 时,用 `--page-token` 原样传入本次返回的 `page_token` 获取下一页。
|
||||
- `progress` — 进展记录数组
|
||||
- `content` 字段是 JSON 字符串,为 OKR ContentBlock 富文本格式。请参考 [lark-okr-contentblock.md](lark-okr-contentblock.md) 了解详细信息。
|
||||
- `progress_rate.status` 返回可读字符串:`normal`(正常)、`overdue`(逾期)、`done`(已完成)。
|
||||
|
||||
@@ -78,7 +66,7 @@ lark-cli okr +progress-list \
|
||||
|
||||
| 命令 | 用途 | API 版本 |
|
||||
|------------------|------------------------------------|----------|
|
||||
| `+progress-list` | 分页获取某个目标/关键结果的进展记录 | v2 |
|
||||
| `+progress-list` | 获取某个目标/关键结果的所有进展记录 | v2 |
|
||||
| `+progress-get` | 根据进展记录 ID 获取单条记录 | v1 |
|
||||
|
||||
`+progress-list` 返回的 `progress_list` 数组中每条记录的结构与 `+progress-get` 返回的 `progress` 结构相同。
|
||||
|
||||
@@ -16,17 +16,16 @@ metadata:
|
||||
|
||||
**权威经验是全局硬约束和高频易错点,必须牢记并严格遵守。**
|
||||
|
||||
- 你有充足的时间完成这个 PPT,质量永远比速度重要。
|
||||
- 你有充足的时间完成这个 PPT,质量永远比速度重要,交付前必须跑静态检查([`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py))并解决所有文本元素重叠问题。
|
||||
- PPT 的尺寸是 960x540,必须严格确保主体内容在页面边界内。
|
||||
- !!!禁止交付无图产物!!! 必须使用大量图片增强视觉效果!!! 禁止重复使用同一张图!!!
|
||||
- 封面页的主视觉必须是 `<img>`(来自生图工具或搜图工具),不要使用 `<shape>` 或 `<icon>` 拼出封面视觉。
|
||||
- 禁止重复使用同一张图片。
|
||||
- 必须在完成 PPT 素材收集之后,包括阅读附件(如果用户上传了附件则附件内的信息重要性最高)、联网搜索、图片搜索(真实实体对应的图片必须使用搜图工具)、图片生成(使用生图工具),再进入 PPT 生成流程。
|
||||
- 禁止用 `<shape>` 和 `<line>` 拟形具体物项,必须使用生图工具生成的 `<img>`。
|
||||
- 禁止在 `headline` 或 `title` 下方放置用于分隔或装饰的 `rect` 或 `<line>`。
|
||||
- 禁止在任何页面内部使用无意义的装饰线条或色块条带,页面任何一边都不要使用贴边窄条。
|
||||
- 生图工具的指令参数必须以“不要出现任何文字和颜色色号”结尾,避免生成的图片上出现干扰文字。
|
||||
- 必须在生图工具的指令参数中写明“不要出现任何文字和颜色色号”,生成图片后检查是否出现文字,如果出现文字必须重做、不可接受。
|
||||
- 禁止使用 emoji 图标,任何位置都不能出现。
|
||||
- 字号必须显式设置 `<content>` 的 `fontSize` 属性,不要依赖 `textType` 的默认字号兜底,这些兜底值明显偏大。
|
||||
- 大数字、字号大或字数多的 `<content>` 必须设置 `wrap="true" autoFit="normal-auto-fit"` 属性自动换行和缩排,避免文字溢出。
|
||||
- 关键指标、核心指标、高密度文字所在的文本框的 `<content>` 必须设置 `wrap="true" autoFit="normal-auto-fit"` 属性自动换行和缩排,避免文字溢出。
|
||||
- 文字颜色必须用 `<content>` 的 `color` 属性而不是 `fontColor` 属性。
|
||||
- 文字行间距必须设置 `<content>` 的 `lineSpacing="multiple:xx"` 或 `lineSpacing="fixed:xx"` 而不是 `lineSpacing="xx"`。
|
||||
- 图片必须用 `<img>` 而不是 `<image>`。
|
||||
@@ -34,55 +33,34 @@ metadata:
|
||||
- 绘制图表时原生图表(柱状、条形、折线、面积、饼(环)、雷达、组合图)用 `<chart>`,其他(漏斗图、金字塔图、象限图、矩阵图等)用 `<shape>` + `<line>` 模拟。
|
||||
- 隐藏 `<chart>` 的图例只能通过不写或删除 `<chartLegend>` 实现,`<chartLegend>` 不支持 `position="none"`。
|
||||
- 表格优先用 `rect` 和 `text` 模拟,其他用 `<table>`,没有 `<shape type="table">`。
|
||||
- 必须设置 `<table>` 的 `width` 和 `height` 固定表格大小,同时设置需要保留列宽或行高的 `<col>` 的 `width` 和 `<tr>` 的 `height`,其余自动分配。
|
||||
- `<table>` 必须设置 `width` 和 `height` 固定整体表格大小,行高列宽建议默认分配,只设置少数必要的 `<col>` 的 `width` 和 `<tr>` 的 `height`。
|
||||
- `<td>` 直接子元素只有 `<fill>`(背景)、`<content>`(文字)和边框配置(一般不用),不能嵌套 `<shape>`、`<img>`、`<icon>`。
|
||||
- `<shape type="rect">` 只是形状不是容器,`<icon>`、`<img>`、`<shape type="text">` 和其他 `<shape>` 必须与它平级靠坐标叠放。
|
||||
- 填充渐变颜色必须用 `<fill><fillColor color="linear-gradient(135deg, rgba(R,G,B,A) 0%, rgba(R,G,B,A) 100%)"/></fill>`。
|
||||
- 必须使用 NotifyHuman 工具交付 PPT 链接;编辑已有 PPT 也必须交付 PPT 链接,无论之前是否交付过相同链接。
|
||||
- 编辑页面前必须阅读 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。
|
||||
- 绘制图表前必须阅读 [`slides_chart_demo.xml`](references/slides_chart_demo.xml)。
|
||||
- 当用户要求无损复述历史上下文时,必须无损复述以上权威经验、必读的技能文档(需要重新阅读)和易错元素语法(尤其是 `<table>` 和 `<chart>`)。
|
||||
- 当用户要求无损复述历史上下文时,必须无损复述以上权威经验、选定的设计系统(场景文档或兜底 [doubao-design.md](references/doubao-design.md))、必读的技能文档(需要重新阅读)和易错元素语法(尤其是 `<table>` 和 `<chart>`)。
|
||||
|
||||
## 豆包设计原则
|
||||
## 设计系统
|
||||
|
||||
适用范围:
|
||||
**演示文稿的设计系统由场景决定,是规划的第一步。** 读 [references/slides_categories.md](references/slides_categories.md) 判定场景(只选一个主场景),再按它为该场景指向的文档作为本 deck 的设计系统。
|
||||
|
||||
- 普通内容页的设计必须以豆包设计原则为最高准则,除非用户要求使用模板或直接提供设计方案。
|
||||
- 不适用于 `title-cover`、`section-divider`、`conclusion`、`quote-highlight` 和 `big-number`。
|
||||
|
||||
核心要求:
|
||||
|
||||
- 必须采用信息密度极高的图文卡片布局,追求充实饱满、图文丰富、可逐行细读的版面,宁可密而满,不要空而疏。
|
||||
- **!!!信息密度极高!!! 图多!!! 卡多!!! 字多!!!**
|
||||
|
||||
排版布局:
|
||||
|
||||
- 卡片布局:卡片按多行网格铺满页面,版面对称、均衡、不留白。网格数、图文比例按内容变化,避免每页雷同。使用更多卡片做细分承载,避免在单张卡片里堆砌大量文字(例如 8 张 50 字卡片优于 2 张 200 字卡片),多个要点必须拆分为多张子卡片。
|
||||
- 卡片样式:方角卡片 + 半透明填充 + 无边框 + 卡片贴边窄条(可选);所有卡片必须使用相同的配色方案(少量需强调的卡片除外),禁止同页出现彩虹卡片(卡片颜色超过 3 种)。
|
||||
- 卡片结构:视觉锚点(关键词、编号或 IconPark 图标)+ 标题 + 内容(包括文字、图片、图表、子卡片)。
|
||||
- 文字卡片:多数页面必须满足 6-8 张文字卡片、200-400 文字数量,字数不足时必须扩写成长句或段落,文字卡片不要留白,必须充实饱满。文字卡片不是短标签,而是“标题 + 完整说明”,像浓缩的分析文稿。文字内容不得不用列表、分栏、关键词或短句时,必须保证层次清晰,更建议拆分为多张子卡片。
|
||||
- 图片卡片:多数页面必须满足 1-3 张图片卡片,缺少图片时必须用生图工具补充配图,图片卡片与文字卡片组成网格,确保图文丰富。
|
||||
- 图表卡片:数据信息不要在文字卡片中罗列,必须在图表卡片中可视化(包括表格、图表、时间线、流程图等),图表卡片与其他卡片组成网格,展现数据驱动。
|
||||
- 间距要求:所有边距都要左右对称,页面和内部内容的边距至少 40px(内容不要贴边),卡片和内部文字的边距至少 5px(文字不要贴边),卡片之间保持 20-40px 的间距。
|
||||
- 文字对齐:正文默认左对齐,只在封面、结尾或大号数字场景中使用居中;表格里的文字左对齐、数字右对齐、仅关键词或短句时居中对齐。
|
||||
|
||||
视觉风格:
|
||||
|
||||
- 美学:干净、明亮、清爽但信息饱满;靠卡片和对齐网格在高密度下维持秩序感;同排卡片文字数量应相近以保持观感整齐。
|
||||
- 字体:全篇以无衬线体(思源黑体)为主,封面或关键强调可少量使用衬线体。
|
||||
- 字号:标题 28-36pt、正文 12-14pt、注释 10-12pt,常规关键指标 16-32pt、核心指标用 36-52pt 数字,下面配 10-14pt 标签与简短解读,需要容纳更多文字时允许使用更小的字号。
|
||||
- 图标:内嵌 IconPark 图标(可用关键词或编号替代)作为视觉锚点,让高密度文字也有图形节奏,而不是成片纯文字块。
|
||||
- 配色:克制颜色数量,确保所有页面都只使用同样的 1 个背景色(偏好浅米白)、1 个主色、1 个强调色和 1 个辅助色;偏好莫兰迪配色,禁止彩虹配色(比如蓝配橙)。
|
||||
- **不匹配任何场景**时,回退到兜底设计系统 [references/doubao-design.md](references/doubao-design.md)(信息密度极高的图文卡片布局)。
|
||||
- 选定的设计系统(场景文档或兜底)与其它通用建议冲突时,**以选定的设计系统为准**(例如卡片用法、字体、图片密度)。
|
||||
- 用户直接提供模板、品牌规范、配色、字体或参考风格时,以用户为准。
|
||||
- `title-cover` 和 `section-divider` 不受兜底设计系统约束。
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|
||||
|----------|----------|-----------------|
|
||||
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`slides +create` |
|
||||
| 用户要求使用模板 | 将模板导入为 Slides 再编辑 | `lark-slides-pptx-template-workflows.md` |
|
||||
| 新建 PPT | 先判定场景选定设计系统,再规划 `slide_plan.json`,按复杂度选择一步或两步创建 | `slides_categories.md`(选场景设计系统)、`planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`slides +create` |
|
||||
| 用户直接提供模板 | 将模板导入为 Slides 再编辑 | `lark-slides-pptx-template-workflows.md` |
|
||||
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide`、`lark-slides-replace-slide.md` |
|
||||
| 读取或分析已有 PPT | 解析 slides/wiki token,用 shortcut 回读全文 XML 或读取单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `slides +xml-get`、`xml_presentation.slide.get`、`lark-slides-xml-presentations-get.md` |
|
||||
| 查看或回滚历史版本 | 先用 `+history-list` 找 `history_version_id`,再 `+history-revert`,必要时 `+history-revert-status` 轮询 | [`lark-slides-history.md`](references/lark-slides-history.md) |
|
||||
| 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面,一次不超过 10 页 | `slides +screenshot`、`lark-slides-screenshot.md` |
|
||||
| 获取幻灯片页面截图 | 用 `slide_id` 指定页面;页号仅用于人工定位 fallback,一次不超过 10 页 | `slides +screenshot`、`lark-slides-screenshot.md` |
|
||||
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`、`lark-slides-media-upload.md`,或 `+create --slides` 的 `@./path` 占位符 |
|
||||
| 绘制图表 | 原生图表(柱状、条形、折线、面积、饼(环)、雷达、组合图)用 `<chart>`,其他(漏斗图、金字塔图、象限图、矩阵图等)用 `<shape>` + `<line>` 模拟 | `xml-schema-quick-ref.md`、`slides_chart_demo.xml` |
|
||||
| 绘制表格 | 优先用 `rect` 和 `text` 模拟,其他用 `<table>` | `xml-schema-quick-ref.md` |
|
||||
@@ -97,19 +75,21 @@ metadata:
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。先创建对应目录,规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**
|
||||
|
||||
**CRITICAL — 选定设计系统是规划的第一步:生成 `slide_plan.json` 前 MUST 先读取 [references/slides_categories.md](references/slides_categories.md) 判定演示文稿所属场景(共 7 类:分析决策 / 商业提案 / 管理汇报 / 学术研究 / 教育培训 / 技术工程 / 品牌创意,只选一个主场景)。匹配到场景 → 按场景表列出的英文文档名读取对应的 `references/*.md`(如 `academic-research.md`)作为本 deck 的设计系统,按其表达重点与方法组织每页,并将选定场景写入 `slide_plan.json`;不匹配任何场景 → 回退到兜底设计系统 [references/doubao-design.md](references/doubao-design.md)。选定的设计系统与兜底/通用建议冲突时以选定的设计系统为准;用户已指定模板/品牌/配色/字体/参考风格时以用户为准。后续 `visual-planning`、`asset-planning` 都服务于选定的设计系统。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,生成 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 — 新建演示文稿或大幅改写页面时,规划 `asset_need` MUST 遵循 [asset-planning.md](references/asset-planning.md):元数据规划,必须有 `fallback_if_missing`。**
|
||||
|
||||
**CRITICAL — 将完整 `<slide>` XML 提交给 `slides +create --slides`、`xml_presentation.slide create` 或 `slides +replace-pages` 之前,MUST 先把待提交 XML 保存到本地文件并运行 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py);`summary.error_count` 必须为 0 才能调用接口。**
|
||||
|
||||
**CRITICAL — 创建或大幅改写后,MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险;XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)。**
|
||||
**CRITICAL — 创建或大幅改写后,MUST 按 [validation-checklist.md](references/validation-checklist.md) 完成回读、静态检查和逐页截图视觉验收:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险;XML 语法和文本重叠静态检查使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py);再以当前 `slide_id` 清单逐页截图并记录 review 结果。**
|
||||
|
||||
**CRITICAL — 创建前自检或失败排障时,MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。**
|
||||
|
||||
**编辑已有幻灯片页面**:单个标题、文本块、图片或局部元素优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序);已有 Slides 的多页大改优先用 [`+replace-pages`](references/lark-slides-replace-pages.md) 在原 presentation 内批量重建页面,避免 `slides +create` 生成新链接。选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。
|
||||
|
||||
**用户要求使用模板**:按 [lark-slides-pptx-template-workflows.md](references/lark-slides-pptx-template-workflows.md) 处理。
|
||||
**用户直接提供模板**:按 [lark-slides-pptx-template-workflows.md](references/lark-slides-pptx-template-workflows.md) 处理。
|
||||
|
||||
## 身份选择
|
||||
|
||||
@@ -205,10 +185,13 @@ lark-cli auth login --domain slides
|
||||
|
||||
### 生成流程
|
||||
|
||||
必须在完成 PPT 素材收集之后,包括阅读附件(如果用户上传了附件则附件内的信息重要性最高)、联网搜索、图片搜索(真实实体对应的图片必须使用搜图工具)、图片生成(使用生图工具),再进入 PPT 生成流程。
|
||||
|
||||
```text
|
||||
Step 1: 需求分析 & 读取知识
|
||||
Step 1: 需求分析 & 选定设计系统 & 读取知识
|
||||
- 分析主题、受众、页数、风格;
|
||||
- 若用户要求使用模板,按 lark-slides-pptx-template-workflows.md 处理
|
||||
- 若用户直接提供模板,按 lark-slides-pptx-template-workflows.md 处理
|
||||
- **选定设计系统(规划第一步)**:读 references/slides_categories.md 判定所属场景,按其场景表列出的英文文件名读对应场景文档(如 references/academic-research.md);不匹配任何场景时回退到 references/doubao-design.md;用户已指定风格时以用户为准
|
||||
- 读取 xml-schema-quick-ref.md;新建 / 大幅改写时还要读取 planning-layer.md、visual-planning.md、asset-planning.md
|
||||
- 涉及图表读取 slides_chart_demo.xml
|
||||
|
||||
@@ -224,7 +207,9 @@ Step 3: 按 slide_plan.json 生成 XML → 创建
|
||||
|
||||
Step 4: 审查 & 交付
|
||||
- 创建完成后,必须用 `slides +xml-get` 读取全文 XML,并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
|
||||
- 失败或部分成功按 troubleshooting.md 处理;局部问题优先用 `+replace-slide` 修正
|
||||
- 静态检查通过后,使用当前回读得到的 `slide_ids` 调用 `slides +screenshot`;首次新建且页集合未变时可复用创建响应。每批最多 10 页,保存到 `.lark-slides/review/<deck-or-task-id>/screenshots/`,然后实际查看生成的图片。**截图使用 `--presentation`、重复的 `--slide-id` 和可选 `--output-dir`;不要迁移 `--output`、`--params`、`--slides`、`--pages` 或 `--presentation-id`。**
|
||||
- 先为当前 `slide_ids` 建立逐页 review 记录,初始均为 `not_reviewed`;实际打开每张截图后,按「可读性、布局、视觉层级、内容完整性、图表精确可读性(有图表时)」更新为 pass / fix。**只截图或查看关键页属于抽查,不是视觉 review;只要存在 `not_reviewed` / `fix`,就不得写“已完成视觉 review”。**
|
||||
- 失败或部分成功按 troubleshooting.md 处理;局部问题优先用 `+replace-slide` 修正,修正后必须重新截图并复验该页
|
||||
- 没问题 → 交付:使用 NotifyHuman 工具交付 PPT 链接
|
||||
```
|
||||
|
||||
@@ -337,6 +322,6 @@ lark-cli slides <resource> <method> [flags] # 调用 API
|
||||
5. **保存关键 ID**:后续操作需要 `xml_presentation_id`、`slide_id`、`revision_id`
|
||||
6. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片
|
||||
7. **编辑已有页面优先原链接更新**:修改单个 shape/img 用 `+replace-slide`(`block_replace` / `block_insert`),不要整页重建;已有 Slides 的多页整页重建用 `+replace-pages`,不要用 `slides +create` 新建整份 PPT;只有没有 shortcut 覆盖的特殊单页整页操作才手动 `slide.create` + `slide.delete`
|
||||
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 不支持分片上传)。
|
||||
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides` 的 `@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `wget`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**(slides upload API 不支持分片上传)。
|
||||
|
||||
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。
|
||||
|
||||
135
skills/lark-slides/references/academic-research.md
Normal file
135
skills/lark-slides/references/academic-research.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Academic Research
|
||||
|
||||
**Benchmark references**: presentation templates from universities such as MIT, ETH Zürich, Tsinghua University, and Peking University; LaTeX Beamer themes such as Metropolis, Focus, Blei, and Auriga; research figure plates from journals such as Nature, Science, Cell, and NEJM; and presentations at top academic conferences.
|
||||
|
||||
## Goals
|
||||
|
||||
### Narrative: capture the value in the first minute; keep progress visible at all times
|
||||
Follow the narrative logic of research background, evidence gap, research question, experimental hypotheses, core evidence, conclusion, and contribution — letting the committee/advisor grasp the value within the first minute.
|
||||
|
||||
Through proper section planning, adding transition pages at key positions, building navigation bars into the body skeleton, and similar methods, keep the committee/advisor aware of the presentation's progress at all times.
|
||||
|
||||
### Visuals: simple but not cheap
|
||||
Presentations in academic scenarios are often plain, but simplicity does not mean simplistic, much less cheap. Use appropriate, refined colors that are expected in spirit yet unexpected in choice, paired with meticulous layouts to build a premium feel.
|
||||
1. **Keep a minimal style**: decorative elements and decorative images may only be used in areas such as the cover, section transition pages, closing pages, and page background images; strictly forbid using decorative images to fill body areas. Icons are not a cure-all for filling whitespace either — use them only when adding an icon brings a clear benefit.
|
||||
2. **A premium feel is achieved through meticulous detail design**: a unified page skeleton, footnote markers for data sources and references, captions for figures and tables, proper page numbering, exquisitely designed font-size hierarchy and typefaces...
|
||||
3. **Colors expected in spirit, unexpected in choice**:
|
||||
- When the user precisely mentions a school/institution/organization/conference name/journal name, you may download the official logo, use the logo's primary color as the primary color, and insert the logo at reasonable positions. If you cannot obtain the logo, do not use the logo's primary color, to avoid deviation.
|
||||
- Reject the most common palettes of this scenario:
|
||||
* Medical research does not use hospital blue/medical green; turn instead to the hematoxylin purple #684765 and eosin pink #C9828B of pathology slides.
|
||||
* Climate and earth sciences do not use environmental green and ocean blue; adopt rock-strata gray #334047, mineral ochre #B66A3C, and sulfur yellow #C2A33A instead.
|
||||
- Unless the user explicitly requests it, do not use crude, simplistic black, white, or light-blue backgrounds; align toward more refined backgrounds, such as titanium gray #E7E8E5, archival paper #F1E9DA, or ivory paper #F7F3E8.
|
||||
4. **High density at large font sizes**
|
||||
- Academic presentations should maintain high density at appropriate font sizes while retaining some breathing room. Unlike the small font sizes and single line spacing of industry research and finance, academic-research body text should use larger font sizes (above 10) and suitable line spacing (around 1.2), unless the deliverable is a reading-type artifact such as an academic poster. At the same time, it should have the "fullness" of industry research and finance: leave some breathing room between regions, but keep the inside of each region full.
|
||||
- Strictly forbid a region planned for 100px that ends up holding only 70px of content.
|
||||
|
||||
### Start from the research field; consider the visual focus
|
||||
Academic presentations in different research fields have different emphases: think about what truly matters in that field, and decide what each page should focus on.
|
||||
- Basic research and theoretical derivation: formulas, definitions, propositions, and proof steps are the focus; the rigorous, complete reasoning process occupies large areas.
|
||||
- Computer science and engineering: system diagrams, code, run traces, ablation experiments, and performance curves form the page skeleton.
|
||||
- Experimental research: research charts, images, and tables are the focus. Formulas, charts, tables, and images belonging to the same research conclusion should be as compact as possible.
|
||||
- Empirical analysis: essentially also experimental research, but social-science work should be more narrative-driven than science-and-engineering work.
|
||||
- Humanities/architecture/arts: archives, maps, manuscripts, image details, chronological threads, and textual evidence... Beyond academic rigor, there should also be brand-level refinement — go read `brand-creative.md` again.
|
||||
- Use shapes, lines, and arrows as annotations to emphasize or explain chart and image content.
|
||||
|
||||
## Prohibitions
|
||||
|
||||
1. **No cards by default**: unless the user explicitly requests it, strictly forbid using rounded rectangles or rectangular cards to build hierarchy or alignment: line segments, whitespace, and font/size differences are better solutions.
|
||||
2. **No evenly divided compositions**: unless no other layout is available, do not default to one-third splits, four-way splits, or 2×2 matrices — including three-part formulas such as "three columns + title + conclusion."
|
||||
3. **No mediocre, common, or AI-typical color schemes**: strictly forbid any blue-and-white pairing, blue-purple gradients, cyan-purple neon, rainbow flares, glassmorphism cards, or glowing borders — unless the user explicitly requests them.
|
||||
4. **No overly small font sizes**: unless the user requests it or the deliverable is a reading-type artifact (posters, etc.), body text must not use small font sizes.
|
||||
5. **No whitespace inside regions**: strictly forbid any case where the content cannot fill its planned region. It is not enough for the text box to be full — the actual text content, as seen in a screenshot, must fill the region. Failure to fill is the most serious kind of crudeness; it feels like no care went into the layout at all.
|
||||
|
||||
## Visual References
|
||||
|
||||
### ETH Swiss Lab Posters
|
||||
|
||||
**Reference objects**: ETH Zürich official PowerPoint and LaTeX templates, Metropolis.
|
||||
|
||||
**Core visual**: like a set of Swiss research posters pinned to a lab wall. A strict grid, giant conclusions, large solid-color swatches, and full-bleed research images form strong proportions; the pages are quiet but not conservative.
|
||||
|
||||
**Type and grid**:
|
||||
- Use a neutral sans-serif typeface; cover titles 64–88 pt, page titles 36–48 pt, body text 16–19 pt, sources 9–11 pt.
|
||||
- Use clearly asymmetric proportions, commonly 3:9, 4:8, or a full-page main image.
|
||||
|
||||
**Research figures**:
|
||||
- The main figure occupies 60%–75% of the page, with axes, error bars, legend, and source fully preserved.
|
||||
- Photography, micrographs, and remote-sensing images may be laid out full-bleed; titles sit inside opaque color blocks rather than directly over complex imagery.
|
||||
- Highlight only one result per page.
|
||||
|
||||
**Page grammar**:
|
||||
1. Cover: a full-bleed image of the research setting or subject, paired with solid swatches of the primary color to build an asymmetric character.
|
||||
2. Content pages: the flow advances horizontally along a twelve-column grid; **cards are strictly forbidden**.
|
||||
3. Results pages: giant conclusion on the left, complete main figure on the right; on the next page, swap left and right.
|
||||
4. Limitations page: a thick colored line cuts apart "proven" and "not yet certain."
|
||||
5. Closing page: return to the cover's color-swatch proportions and answer the research questions one by one.
|
||||
|
||||
**Recurring motif**: asymmetric solid-color swatches, section numbers, page numbers, notes, reference footnote markers, and thin tick lines along the page edges.
|
||||
|
||||
**Prohibitions**: rounded cards, centered titles, soft shadows, blue-and-white business style, multi-colored charts, and enlarging the institution's emblem into a giant watermark.
|
||||
|
||||
### Tsinghua / MIT-Style Elite-University Defense
|
||||
|
||||
**Reference objects**: the official visual identity systems of Tsinghua University, MIT, and similar institutions; department defense templates; course handouts and research reports.
|
||||
|
||||
**Core visual**: the whole deck carries the orderliness of formal departmental defense materials. Identity is established through the school color, school name, or department emblem; everything else recedes behind the content. Pages are stable, precise, restrained; formulas, figures, and arguments always hold the visual center.
|
||||
|
||||
**Palette**:
|
||||
- When the user explicitly mentions a school or institution, extract one primary color from the official emblem — never hard-code a color value from memory. On ordinary body pages, keep the school color within 3%–8% of the area; section pages may use the school color across the entire page.
|
||||
- Keep scientific color schemes on figures when they carry clear semantics; when they do not, use the school color, light and dark grays, and one necessary contrast color — do not add colors just to enrich the picture.
|
||||
|
||||
**Type and grid**:
|
||||
- Chinese uses a neutral hei (sans-serif) typeface; English and formulas may pair with an academic serif or math typeface. Cover titles 48–64 pt, page titles 32–40 pt, body text 17–21 pt, captions and sources 9–11 pt.
|
||||
- Titles, body text, figure captions, and footnotes each keep their own fixed baselines.
|
||||
- Body pages may set an extremely thin header or footer rule, with a short title, section name, and page number fixed in place — or a section navigation.
|
||||
|
||||
**Research content**:
|
||||
- Formulas keep their numbering, conditions, and symbol explanations; key derivations may appear step by step, but each page must form a complete local argument.
|
||||
- Tables preferably adopt the three-line table structure from papers, highlighting only the rows and columns directly relevant to the conclusion.
|
||||
|
||||
**Page grammar**:
|
||||
1. Cover: small school or department emblem, title, author, advisor, department, and date; establish identity with a thin line in the school color — no giant watermarks.
|
||||
2. Section pages: a full page of school color or a refined background color, holding only the section number, the title, and one research question. Pair with a decorative subtle-pattern background image to enrich the visual effect.
|
||||
3. Summary page: answer item by item by research question, marking the corresponding evidence or section number after each item.
|
||||
4. References page: use a font size slightly smaller than body text but larger than footnotes, and place the references in the official citation format, in columns or a single column.
|
||||
5. Closing page: keep the school color, department emblem, and contact information, presenting only "Questions" or one final conclusion.
|
||||
|
||||
**Recurring motif**: the short school-color line, fixed title baseline, section name, page numbers, figure captions, and reference footnote markers.
|
||||
|
||||
**Prohibitions**: faking the school color from memory, enlarging the emblem to fill whitespace, semi-transparent emblem watermarks, blue gradient headers, repeating large color bands on every page, rounded info cards, figures without captions, and squeezing body text and figures for the sake of tradition.
|
||||
|
||||
### Humanities & Social Sciences Archival Special Issue
|
||||
|
||||
**Reference objects**: humanities and social-science academic monographs from university presses, twentieth-century academic journals, archival catalogues, and the vintage-print paper, layout, and image treatments in `brand-creative.md`. Absorb only the parts that serve the presentation of evidence.
|
||||
|
||||
**Core visual**: the whole deck reads like an archival research special issue typeset with contemporary layout. Warm paper tones, serif type, marginal annotations, document numbers, and thin separator lines form a stable skeleton; archives, maps, manuscripts, interviews, and statistical materials appear as evidence, not as decoration filling the picture.
|
||||
|
||||
**Palette**:
|
||||
- Background: archival paper #F1E9DA; text: ink black #27231F; auxiliary information: old-paper gray #9B9489.
|
||||
- Choose one accent color by theme: annotation vermilion #8C3B36 or binding ink green #355C52. Pick only one for the whole presentation, and keep it within 3%–8% of the area on ordinary pages.
|
||||
- Original archives and artworks may keep their original colors; figures, annotations, and navigation still obey the unified palette.
|
||||
|
||||
**Type and grid**:
|
||||
- Chinese titles and body text preferably use a modern Song (serif) typeface or another clear serif; numbers, numbering, sources, and archival reference numbers may use sans-serif or monospaced fonts.
|
||||
- Cover titles 48–68 pt, page titles 34–44 pt, body text 17–20 pt, long quotations 21–28 pt, figure notes and footnotes 9–11 pt; body line spacing 1.3–1.45.
|
||||
- Commonly use unequal-width layouts of 4:8 or 5:7, reserving a narrow margin for dates, keywords, document numbers, or annotations. Rely on whitespace and thin lines to organize content — no vintage lace borders and no cards.
|
||||
|
||||
**Archives, quotations, and data**:
|
||||
- Archive scans should preserve paper edges, page numbers, seals, and collection or accession numbers; when emphasis is needed, point out the evidence with thin boxes, leader lines, and magnified details.
|
||||
- Label every archival image with author, title, date, holding institution, and reference number; label every interview excerpt with respondent number, time, and place.
|
||||
- Statistical charts stay modern and clearly drawn, with no aged textures overlaid. Use the accent color only to highlight data series directly relevant to the argument.
|
||||
- Long quotations excerpt only the passages that support the argument, with explanation and provenance beside them; avoid piling full-page stacks of original text.
|
||||
|
||||
**Page grammar**:
|
||||
1. Cover: the title styled like the title page of an academic monograph, paired with an archive, map, or artwork detail occupying 35%–50% of the picture; also place author, institution, date, and volume/issue-style metadata.
|
||||
2. Research-question page: use a three-to-five-line abstract-style lead-in, with locations, dates, subjects, and material scope listed in the margin.
|
||||
3. Literature-review page: organize the literature along time or divergences of opinion, connecting scholars, arguments, and response or engagement relationships with thin lines — do not build walls of author cards.
|
||||
4. Archival-evidence page: a large original on the left; transcription, translation, interpretation, and provenance in order on the right; use the same numbers to connect original and analysis.
|
||||
5. Empirical-analysis page: the main figure or table occupies two-thirds; the other side holds conclusions, variable descriptions, and limitations; quantitative material also follows the special-issue skeleton.
|
||||
6. Map-and-time page: use one main map or one main timeline; the accent color marks only the places and events relevant to the argument.
|
||||
7. Argument page: a clear judgment sentence at the top, with textual evidence, visual evidence, and the researcher's interpretation laid out in order below.
|
||||
8. Conclusion page: answer the research questions with consecutively numbered short paragraphs, each attaching the corresponding archive number, figure number, or section number.
|
||||
|
||||
**Recurring motif**: book-page-style page numbers, thin footnote rules, running heads, archival reference numbers, section tabs, and a unified figure-caption format.
|
||||
|
||||
**Prohibitions**: casually tilted clip art, low-contrast body text, treating archival material as background texture, and historical photos without sources.
|
||||
36
skills/lark-slides/references/analysis-decision.md
Normal file
36
skills/lark-slides/references/analysis-decision.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Analysis & Decision
|
||||
|
||||
## Solid, compact content with extremely high-density layout
|
||||
|
||||
1. Unless the presentation is explicitly intended for on-stage presenting, treat it as a reading-type deck by default. Design the entire work around this goal, and demonstrate the standard of a top-tier research report or consulting firm.
|
||||
2. Every page has enough depth: the core conclusion can be grasped quickly, while reading the full page takes 5 minutes.
|
||||
3. Support cascades from conclusion to evidence: once the title states a judgment, the page should carry several self-standing supporting judgments that connect the conclusion to the evidence, each with one verifiable sentence of support.
|
||||
4. Body-page titles are preferably declarative sentences ("Europe's electric heavy-truck batteries reach 48 GWh by 2030 — 16x growth in six years under a strong policy push"), but decide whether to use a declarative sentence based on the actual content.
|
||||
|
||||
## Make data and structured information the body of the argument
|
||||
|
||||
1. Data is the core support of the argument: search thoroughly around the topic to obtain real data, and present it fully through all kinds of charts and tables; the source of every piece of data must be stated — never fabricate data.
|
||||
2. Charts come complete with axes, units, legends, sources, and measurement basis, so that a chart stands on its own apart from the body text.
|
||||
3. Mark data inflection points and key values on charts and explain their meaning; accompany each chart with text stating the judgment it proves, the findings visible on it, the implications for the decision, and the source and boundaries.
|
||||
4. Encourage using SmartArt shape combinations to present information; when structural relationships exist between pieces of content — sequence, comparison, cycle, causality, and so on — prefer making them visible with a structural diagram rather than describing them in text.
|
||||
5. Encourage drawing and combining shapes to build complex charts and SmartArt, supported by text explanations — plain text alone is too flat.
|
||||
|
||||
## Key Requirements
|
||||
1. Create a top-tier visual experience: every detail of typesetting, fonts, colors, and alignment must be treated with care; accept no defaults — details determine success or failure.
|
||||
2. Unless explicitly requested, strictly forbid using rounded rectangles or rectangular cards to build hierarchy or alignment: line segments, whitespace, and font/size differences are better solutions.
|
||||
3. A single basic chart must not occupy half or more of the width; encourage laying out multiple charts or other content on one page.
|
||||
4. Unless no other layout is available, forbid evenly divided compositions; do not default to one-third splits, four-way splits, or 2×2 matrices — including three-part formulas such as "three columns + title + conclusion."
|
||||
|
||||
## Visual References
|
||||
|
||||
1. White background + single primary-color skeleton: pure white pages, with only one primary color throughout the deck (e.g., navy, ink green, or another color common in financial-consulting style) carrying the structural skeleton — title emphasis, table headers, navigation highlights, main chart series, and numbered markers — supported by a 2–3-step light-tint ladder of the same hue and neutral grays.
|
||||
2. The title is the conclusion: each page's main title is a complete judgment sentence; the heading format (kicker → main title → subtitle, or title + conclusion-sentence subtitle) is fixed throughout; in typography, bold serif titles and sans-serif body text form two clearly divided roles, with a strictly consistent hierarchy.
|
||||
3. If headers and footers exist, they must stay stable: section navigation always visible (current section highlighted; sections distinguished by navigation position, not by switching colors), a fixed "Source:" line + page number in the footer; every element keeps the same coordinates from page to page, so nothing jumps when flipping.
|
||||
4. Density rhythm: body pages are packed at high density, with charts/tables/structural diagrams as the main body and text serving the evidence; covers, section pages, and conclusion pages use whitespace as breathing points.
|
||||
5. Position discipline: conclusion → top title; evidence → charts/tables in the middle; interpretation → notes beside the chart or a sidebar; sources and measurement basis → the source line at the bottom; action recommendations → the closing page only. Every type of information sits in its own place, consistent throughout.
|
||||
6. De-default charts: reassign all series colors to the primary-color ladder + grays; remove vertical gridlines (at most keep extremely light horizontal lines); data labels mark only key points and endpoints; actual vs. forecast is distinguished by solid vs. dashed/hatched; directly below each chart, place its source + measurement-basis note.
|
||||
7. Mark key values directly on the chart: key totals with big-number callouts, inflection points/gaps/growth rates with arrows or leader lines + one-sentence annotations, ratings with Harvey balls or badges — placed right on the chart area, not piled into text paragraphs.
|
||||
8. Table conventions: dark header with white text, extremely thin horizontal separators (no vertical lines or heavy borders), numbers right-aligned and text left-aligned, subject names in the first column bolded; large tables get an interpretation panel beside them.
|
||||
9. Line and alignment discipline: all elements align to a unified left edge and invisible column lines; separation uses only extremely thin lines, emphasis uses only primary-color short bars or thick vertical bars, never thick frames; panels are square-cornered with no shadows; rounded corners are allowed only on small labels.
|
||||
10. Clean surfaces: no shadows, no gradients, no textures, no glow effects, no default colors; the texture is achieved entirely through whitespace, alignment, a single-hue color scale, and line discipline.
|
||||
|
||||
202
skills/lark-slides/references/brand-creative.md
Normal file
202
skills/lark-slides/references/brand-creative.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# Brand / Creative Showcase
|
||||
|
||||
**Benchmark references**: top magazines such as Monocle, Wallpaper, and Vogue; international design agencies such as Pentagram; and outstanding brand annual reports, brand books, and creative proposals.
|
||||
|
||||
## Goals
|
||||
|
||||
### Identify the subject; build a deck whose subject is recognizable at a glance
|
||||
If the presentation's subject is a specific brand, product, company, person, city, building, event, or IP, first extract the subject's identifying features:
|
||||
- Extract visual identifiers such as icons and the brand concept from the user's uploaded reference materials.
|
||||
- Search and visit official websites, officially published annual reports, creative manuals, and icons. Do not just read the text — rely more on referencing and studying the pages' visual effects to help understand the style.
|
||||
Even if all text content were removed, the association with the subject should still be found through other visual features.
|
||||
|
||||
### Take the essence, discard the dross
|
||||
Not every subject has an aesthetic aligned with top magazines like Monocle, Wallpaper, or Vogue. When extracting the subject's style, accurately identify which items may be referenced and which are prohibited. Designs that fail to meet the benchmark's standard must be explicitly forbidden. If the brand itself lacks design quality, you may keep only the brand's primary color as the theme or accent color, or keep icons as decoration.
|
||||
|
||||
### Build a top-tier visual experience
|
||||
Use strong font-size contrast and extreme asymmetric layouts to build an exaggerated style and a top-tier visual experience. Reject every safe, mediocre, conservative option: better ugly than mediocre.
|
||||
1. **Make extreme choices**: oversized type, giant images, intense whitespace, high-density multi-columns, a single vivid color, or heavy lines. Build an ultimate style: old-newspaper front page, Memphis, Constructivism, Swiss style...
|
||||
2. **Vary the pages**: cover, opinion, data, and image pages each have different compositions. There is no need to strictly follow one identical "page skeleton," but the deck's consistency should still be recognizable through palette, typeface, font sizes, same-style decorative elements, and the same grid alignment.
|
||||
3. **Have a visual rationale**: the visual language must come from the deck's subject, brand, industry, era, or material, and must help the audience understand the content.
|
||||
|
||||
## Prohibitions
|
||||
1. **No cards by default**: unless the user explicitly requests it, strictly forbid using rounded rectangles or rectangular cards to build hierarchy or alignment: line segments, whitespace, and font/size differences are better solutions.
|
||||
2. **No evenly divided compositions**: unless no other layout is available, do not default to one-third splits, four-way splits, or 2×2 matrices — including three-part formulas such as "three columns + title + conclusion."
|
||||
3. **No mediocre, common, or AI-typical color schemes**: strictly forbid any blue-and-white pairing, blue-purple gradients, cyan-purple neon, rainbow flares, glassmorphism cards, or glowing borders — unless the user explicitly requests them.
|
||||
4. **No elements that clash with the overall style**: no styles from outside the chosen style may appear, such as using rounded icons or rounded rectangles within a sharp style.
|
||||
|
||||
## Visual References
|
||||
|
||||
Visual references are only "references": they show you something good so you know what good looks like — they are not directly usable. You should design visuals according to the user's actual needs, not directly apply templates.
|
||||
|
||||
### Klein Blue Swiss Posters
|
||||
|
||||
**Core visual**: the whole deck reads like a set of modernist exhibition posters. Strict left alignment, a twelve-column grid, extreme font-size differences, giant numerals, and large areas of whitespace. The picture uses only paper white, near-black, and Klein blue.
|
||||
|
||||
**Palette**:
|
||||
- Background: warm paper white #F7F5EF, roughly 70%–85%.
|
||||
- Text: near-black #101010.
|
||||
- Accent: Klein blue #0038B8, occupying 5%–12% on ordinary pages; section pages may use a blue block occupying 30%–40% of the picture.
|
||||
- Auxiliary gray: #D8D7D2, only for gridlines and secondary information.
|
||||
- No gradients and no second accent color.
|
||||
|
||||
**Type**:
|
||||
- Cover title: ultra-heavy sans-serif, 112–136 pt, line height 0.85–0.95, and may be slightly cropped by the canvas edge.
|
||||
- Content-page titles: 48–64 pt.
|
||||
- Body text: 17–20 pt.
|
||||
- Page numbers, sources, and labels: 9–11 pt, with widened letter spacing.
|
||||
- Everything left-aligned, using a single sans-serif family only, building hierarchy through weight changes.
|
||||
|
||||
**Grid and geometry**:
|
||||
- Left and right margins equal 5% of the canvas width; use a twelve-column grid.
|
||||
- Main compositions use 2:8, 3:7, or full-page whitespace.
|
||||
- Use only right-angle rectangles, true circles, 0.5 pt thin lines, and 4 pt thick lines.
|
||||
- No rounded corners, shadows, outlined icons, or floating cards.
|
||||
|
||||
**Image treatment**:
|
||||
- Product screenshots all go into a fixed 16:10 slot.
|
||||
- Photography is uniformly high-contrast black and white; a blue color surface may only be overlaid locally.
|
||||
- At most one main image per page; no photo collages.
|
||||
|
||||
**Page grammar**:
|
||||
1. Cover: the title occupies the left 65%; a blue color surface running the full height sits on the right; at the top, only one line of 10 pt meta-information.
|
||||
2. Section pages: the whole page holds only the section numeral and one title line; the numeral is 120 pt blue.
|
||||
3. Opinion page: one 64–88 pt conclusion, with a tiny annotation in the bottom-right corner.
|
||||
4. Data page: a 110 pt numeral occupies the left third; a simplified chart sits on the right.
|
||||
5. Architecture page: all nodes align to the twelve-column grid; connecting lines are uniformly 1 pt; only the core nodes are blue.
|
||||
6. Closing page: return to the cover's blue color surface and giant title, closing the loop.
|
||||
|
||||
**Recurring motif**: a blue vertical line and a two-digit section number fixed at the top-left corner of every page.
|
||||
|
||||
**Prohibitions**: rounded cards, three evenly divided columns, colorful small icons, multi-colored charts, gradients, soft shadows, centered titles.
|
||||
|
||||
### Stencil-Printed Indie Magazine
|
||||
|
||||
**Core visual**: the whole deck feels like a hand-trimmed, scanned, and bound indie magazine. Heavy poster type, two-color overprinting, halftone images, cut-and-paste blocks, and slight tilts. Preserve paper and printing imperfections rather than chasing the slickness of a digital interface.
|
||||
|
||||
**Palette**:
|
||||
- Paper: warm beige #F1E2C2.
|
||||
- Primary ink: cobalt blue #1646B8.
|
||||
- Second spot color: fluorescent pink #FF4F87.
|
||||
- Text and rules: ink black #171512.
|
||||
- Orange-yellow #F1A51B may appear on only one or two section pages, within 5% of the area.
|
||||
- No transparent gradients; colors appear only as flats, overprints, and halftones.
|
||||
|
||||
**Type**:
|
||||
- Cover title: ultra-heavy condensed poster type, 88–120 pt, allowed to rotate -3 degrees and cross the canvas edge.
|
||||
- Page titles: 42–58 pt, may be reversed white on a black ground.
|
||||
- Body text: 14–17 pt, in a clear sans-serif or serif face.
|
||||
- Annotations: 10–12 pt monospaced, mimicking typewriter labels.
|
||||
|
||||
**Grid and geometry**:
|
||||
- Use unequal-width two and three columns and cut-and-paste modules.
|
||||
- Images and text blocks may rotate from -4 to +4 degrees.
|
||||
- Main rules 2–3 pt; crop marks 0.75 pt.
|
||||
- Color blocks stay right-angled; local overruns and mutual overlaps are allowed.
|
||||
|
||||
**Image treatment**:
|
||||
- All photos are first converted to high-contrast black and white, then given either a blue or a pink halftone treatment.
|
||||
- Halftone dot size stays consistent; color misregistration of 2–4 pixels is allowed.
|
||||
- People and buildings may be cropped brutally, but never past the point of losing the recognizable subject.
|
||||
- Never drop full-color photos in directly.
|
||||
|
||||
**Page grammar**:
|
||||
1. Cover: the title occupies more than half the picture; at the bottom, a blue-and-pink duotone photo of a printing press or bookshop; a tilted small label is pasted at the top-right corner.
|
||||
2. Contents page: five colored paper strips, each holding only a section number and a short title.
|
||||
3. History page: the timeline looks like tickets clipped onto paper; years use 40–56 pt bold type.
|
||||
4. Craft page: five steps spread horizontally, each containing a halftone image, a number, and one line of explanation.
|
||||
5. City-case page: one large image occupies 60%; text presses against the image's side like a newspaper sidebar.
|
||||
6. Map page: the map stays as black line art; key cities are marked with solid pink circles.
|
||||
7. Closing page: four large numbered circles present the action steps; a black manifesto line sits at the bottom.
|
||||
|
||||
**Recurring motif**: misregistered blue-and-pink overprinting, the crop line at the top-left corner, and a fixed volume/issue number.
|
||||
|
||||
**Prohibitions**: glassmorphism, soft shadows, slick gradients, refined rounded corners, full-color photography, business clip icons, and perfectly symmetrical grids.
|
||||
|
||||
### Brutalist Newspaper
|
||||
|
||||
**Core visual**: the whole deck reads like a special-edition newspaper about an industry turning point. Giant mastheads, narrow margins, unequal-width multi-columns, thick-and-thin rules, high-density body text, and black-and-white halftone photos. Red is used only for the most critical data and warnings.
|
||||
|
||||
**Palette**:
|
||||
- Newsprint white: #F5F1E8.
|
||||
- Ink black: #111111.
|
||||
- News red: #C8102E, kept within 3%–8% of a page's area.
|
||||
- Sidebar paper yellow: #E7DDCA.
|
||||
- Secondary text: #55514A.
|
||||
- No blue-purple gradients and no colored status blocks.
|
||||
|
||||
**Type**:
|
||||
- Masthead: ultra-heavy sans-serif, 72–100 pt, all caps where supported or otherwise set as ultra-bold short text.
|
||||
- Headlines: 44–72 pt, tight leading.
|
||||
- Body text: 11–14 pt serif, line spacing 1.25–1.4.
|
||||
- Data and labels: monospaced, 10–14 pt.
|
||||
- Pull quotes: 28–40 pt serif italic or bold.
|
||||
|
||||
**Grid and geometry**:
|
||||
- Margins are 3.5%–4% of the canvas width.
|
||||
- Content pages use three to five unequal-width columns.
|
||||
- Lines come in four grades: 0.5 pt, 1 pt, 3 pt, and 5 pt.
|
||||
- All modules are right-angled, organized by rules; no floating cards.
|
||||
|
||||
**Image treatment**:
|
||||
- Photography is uniformly converted to high-contrast black-and-white halftone, keeping visible grain.
|
||||
- Every image carries a 9–10 pt caption and source.
|
||||
- You may mark an image with a red circle, red underline, or red stamp — choose only one of these mark types per page.
|
||||
|
||||
**Page grammar**:
|
||||
1. Cover: a 5 pt red line at the top, a masthead spanning the full page below it; the main headline uses 80–96 pt; three short news briefs at the bottom.
|
||||
2. Overview page: four large numbers in a row, each 64–88 pt, with three columns of observations below.
|
||||
3. Ranking page: a black-and-white halftone photo in the left third; horizontal bar charts in the right two-thirds.
|
||||
4. Cost page: a full-width server-room banner across the middle, with a line chart and a narrow annotation column below.
|
||||
5. Policy page: a black-and-white landscape image at the top; below, a dense three-column table compares different regions.
|
||||
6. Timeline page: one thick black main line with six red nodes; event descriptions distributed above and below.
|
||||
7. Closing page: only one 64 pt conclusion and a red full stop.
|
||||
|
||||
**Recurring motif**: the top red line, the fixed masthead, black column rules, and edition information at the bottom-right corner.
|
||||
|
||||
**Prohibitions**: rounded corners, shadows, illustrated icons, colored cards, loose business layouts, default table styles, and unsourced data.
|
||||
|
||||
### Memphis Pop Posters
|
||||
|
||||
**Core visual**: the whole deck uses thick black outlines, giant poster type, checkerboards, lightning bolts, waves, dots, and geometric shapes that break the frame. The picture is lively while text areas stay clean — like a blend of eighties pop posters and contemporary youth-event visuals.
|
||||
|
||||
**Palette**:
|
||||
- Background: cream white #FFF3D8.
|
||||
- Ink: deep black-purple #151427.
|
||||
- Fluorescent pink #FF3EA5.
|
||||
- Bright yellow #FFD63D.
|
||||
- Lake blue #05C7E8.
|
||||
- Mint green #23D6A2.
|
||||
- At most three high-saturation colors per page; deep black-purple must be used for outlines and body text to steady the picture.
|
||||
|
||||
**Type**:
|
||||
- Cover title: ultra-heavy rounded or comic-style display type, 92–128 pt, with a 3–5 pt dark outline and a hard-offset color block.
|
||||
- Section words: 80–110 pt, all caps where supported or a single giant character.
|
||||
- Page titles: 36–48 pt.
|
||||
- Body text: 15–18 pt neutral sans-serif; no script faces.
|
||||
- Numbers: 72–100 pt, in heavy black type.
|
||||
|
||||
**Grid and geometry**:
|
||||
- Compositions are clearly asymmetric; main objects may rotate from -6 to +6 degrees.
|
||||
- Geometric shapes use 3–5 pt dark outlines.
|
||||
- Each page carries one geometric shape breaking the frame, occupying 20%–40% of the picture.
|
||||
- Decoration concentrates in corners and edges, never covering body text.
|
||||
|
||||
**Image treatment**:
|
||||
- People are uniformly rendered as flat illustrations with thick black outlines or a high-saturation screen-print effect.
|
||||
- Multi-person pages use a four-panel series, with busts of consistent size and viewing angle.
|
||||
- Venue maps use hand-drawn icons, but route and schedule information stays clear.
|
||||
|
||||
**Page grammar**:
|
||||
1. Cover: the title fills the upper half; crowd silhouettes and the stage sit at the bottom; checkerboards, lightning bolts, and dots occupy the four corners, while the center stays clear.
|
||||
2. Section pages: one giant word or character, against a background of one frame-breaking circle and one wave.
|
||||
3. Numbers page: four numbers in a row, each on a different background color, all with the same thick outline.
|
||||
4. Lineup page: a four-person panel series in the upper half; names, styles, and set times in the lower half.
|
||||
5. Venue map: a radial map in the center, with colored labels around it marking stages and facilities.
|
||||
6. Schedule page: the timeline stays horizontal and regular, colors distinguish the stages, and decoration retreats to the edges.
|
||||
7. Ticket-pricing page: three tiers as hard-edged poster blocks, prices 64–80 pt, no floating shadows.
|
||||
8. Closing page: a full-bleed sunset illustration with an 88 pt handwritten-feel line on top, repeating the cover's checkerboard corners.
|
||||
|
||||
**Recurring motif**: checkerboard at the top-right corner, pink lightning bolts, and thick black outlines — the checkerboard must appear on at least five pages.
|
||||
|
||||
**Prohibitions**: soft shadows, low-contrast text, all colors appearing at once, script body text, decoration covering data, and ordinary business icons.
|
||||
49
skills/lark-slides/references/business-plan.md
Normal file
49
skills/lark-slides/references/business-plan.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Business Proposal
|
||||
|
||||
## A clear storyline with strong persuasion
|
||||
|
||||
- The storyline of the whole deck is clear and builds step by step.
|
||||
- If a page has a core claim, that most important sentence enjoys the visual privilege of being "marked."
|
||||
- **Emotion has a curve**: the deck's emotion is not flat — from unease (the pain point) to hope (the solution) to belief (the evidence) to urge (the action).
|
||||
- Make good use of data support: assist the proposal with data, tables, and charts at the right positions. Data must be truthful and reliable, with sources stated.
|
||||
|
||||
## Tell a more complete story through SmartArt structural diagrams
|
||||
|
||||
**SmartArt is the visualization of narrative: boldly use complex structural diagrams to present multi-layered relationships.**
|
||||
|
||||
- Decompose structures from information relationships — sequence, comparison, cycle, causality, and so on — and prefer making them visible with structural diagrams rather than describing them in text.
|
||||
|
||||
## Page turns have breathing room
|
||||
|
||||
- Encourage interspersing refined section pages and accent pages among the content pages to markedly elevate the reader's experience. Section pages may use images as backgrounds, covered by a gradient overlay.
|
||||
- When the topic suits it, search for images generously and place well-chosen images at the right spots — one picture is worth a thousand words.
|
||||
|
||||
## Key Requirements
|
||||
1. Create a top-tier visual experience: treat every detail of typesetting, fonts, and colors with care, crafting it like a work of art — details determine success or failure.
|
||||
2. Unless the user explicitly requests it, **strictly forbid using rounded rectangles or rectangular cards** to build hierarchy or alignment: line segments, whitespace, and font/size differences are better solutions.
|
||||
3. Unless no other layout is available, forbid evenly divided compositions; do not default to one-third splits, four-way splits, or 2×2 matrices — including three-part formulas such as "three columns + title + conclusion."
|
||||
|
||||
## Visual References
|
||||
|
||||
1. Type is attitude: display type (titles, manifesto lines, big numbers) and body type (body text, notes, sources) have a clear division of labor by family or weight; display type needs poster-level tension, daring extreme contrast in size, weight, and letter spacing — the title itself is a visual work.
|
||||
2. Fonts and the color scheme must be matched and harmonious; reference each other when designing.
|
||||
3. Rhythm and accent pages: rotate the page-type sequence, never two consecutive pages with the same skeleton; manifesto pages, section divider pages, and big-number hero pages handle emotional gear-shifts and visual emphasis.
|
||||
4. Carefully designed layouts: page layouts are ingeniously designed, with beauty and soul — not just simple arrangements of text.
|
||||
5. Corner and edge visuals: encourage building shapes into images that aid explanation, or crafting refined, purely decorative symbols out of shapes in empty areas.
|
||||
6. De-default charts and tables: all series colors come from the brand palette; data labels mark only key points and sit directly beside the graphic; tables are separated only by thin horizontal lines — no heavy frames, no zebra striping.
|
||||
7. Details are quality: pixel-level alignment and letter spacing that hold up under scrutiny at any magnification; the texture is achieved entirely through typesetting, alignment, color blocks, and line discipline — no defaults anywhere; detail is the dividing line between master and mediocrity.
|
||||
8. Motifs and text/image separation: establish 2–5 recurring visual motifs throughout the deck (decorations, icons, and illustrations share one language) to form recognizability and an artistic signature.
|
||||
|
||||
### Color Palette Reference
|
||||
|
||||
**Colors expected in spirit, unexpected in choice**:
|
||||
- When the user precisely mentions a company/product/brand name, prefer taking the brand's VI primary color as the structural primary color, and design a matching palette around it.
|
||||
- Reject the most common, most mediocre formulaic palettes of each scenario (tech blue-purple gradients, hospital blue-and-white, festive bright-red-and-gold, real-estate sales-office gold-brown, green-leaf environmental style, etc.); reference palettes for some scenarios:
|
||||
* Tech/AI/SaaS/entertainment roadshows: the launch-event color clash of ink black #141414 base + fluorescent yellow-green #C6F24E; or mist white #F2F4F7 base + indigo #2B3A8E + lime #A8E05F; or a dark-base clash of deep purple-black #1A1228 base + neon magenta #E91E8C + electric blue #2D9CDB.
|
||||
* Consumer goods/F&B/food franchise recruitment: creamy apricot #F3D9A4 base + baking brown #6B4226 + brick red #A63A2E; or off-white #F6EFE2 base + ink green #1E3B33 + terracotta #C15F3C; or soil brown #4E3428 base + wheat gold #C9A227 + moss green #5D6B4F.
|
||||
* Luxury/fashion/beauty: the editorial palette of ink black #0D0D0D base + ivory #F5F0E6 + burgundy #6D1F2C; or ivory #F5F0E6 base + ink black #0D0D0D + burgundy #6D1F2C; or the minimalist clash of cool white #F8F8F6 base + charcoal black #1A1A1A + cobalt blue #1F3A93.
|
||||
* Medical/health products: warm white #F5F0E1 base + deep pine green #1F4A3D + apricot yellow #E8B54A; or warm white #F7F5F0 base + deep indigo #1B2A52 + mint #7FC7B5; or charcoal gray #333A42 base + coral pink #E8A09A + light beige #EFE9E0.
|
||||
* Finance/legal/professional services: off-white #F7F3E8 base + ink green #123B2F + brass #B08D3E; or warm gray #C9C4BC base + ink black #1A1A2E + burgundy #7B2D43; or ink black #1C1C1C base + antique bronze #9C6B30 + parchment white #F4EDE1.
|
||||
* Industry/construction/energy/logistics (heavy B2B industries): charcoal black #232323 base + safety orange #E8590C + steel gray #868E96; or sandy white #F3EDE0 base + deep petroleum teal #0F4C5C + warm copper #B5651D; or light gray #D9DDE3 base + midnight blue #101D42 + signal yellow #F5B700.
|
||||
* Education/knowledge-payment products: warm white #FAF6EE base + ink purple #3D2C4F + cream yellow #F2C14E; or off-white #F5F1EA base + deep indigo #22315C + coral orange #F08A5D; or paper white #F7F3E8 base + pine green #2F5233 + apricot yellow #E8B54A.
|
||||
- Align background colors toward something more textured, e.g.: ink black #141414, deep ink green #0E2A22, warm paper white #F7F3E8, mist gray #E9E7E2, deep wine red #3B1219.
|
||||
37
skills/lark-slides/references/doubao-design.md
Normal file
37
skills/lark-slides/references/doubao-design.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# 豆包设计原则(兜底设计系统)
|
||||
|
||||
**本文件是兜底设计系统。** 仅当演示文稿**不匹配** [slides_categories.md](slides_categories.md) 里的任何场景时,以本文件为准。匹配到场景时以对应场景文档为准(与本文件冲突处以场景文档为准);用户直接提供模板、品牌规范、配色、字体或参考风格时以用户为准。
|
||||
|
||||
- 例外声明:不适用于 `title-cover` 和 `section-divider`。
|
||||
|
||||
## 核心要求
|
||||
|
||||
- 必须采用信息密度极高的图文卡片布局,追求充实饱满、图文丰富、数据驱动、可逐行细读的版面,宁可密而满,不要空而疏。
|
||||
- 信息密度不够时可以通过文字扩写增加文字数量,但扩写必须保证有事实依据、有引用来源,不能凭空捏造,不能与用户上传的附件(如有)中的信息冲突。
|
||||
- **!!!信息密度极高!!!图多!!!卡多!!!字多!!!**
|
||||
|
||||
## 图片与主视觉
|
||||
|
||||
- 任何体裁的 PPT 都必须保证图文并茂、图片丰富。
|
||||
- 封面页的主视觉必须是 `<img>`(来自生图工具或搜图工具),不要使用 `<shape>` 或 `<icon>` 拼出封面视觉。
|
||||
|
||||
## 排版布局
|
||||
|
||||
- 卡片布局:卡片按多行网格铺满页面,版面对称、均衡、不留白。网格数、图文比例按内容变化,避免每页雷同。使用更多卡片做细分承载,避免在单张卡片里堆砌大量文字(例如 8 张 50 字卡片优于 2 张 200 字卡片),多个要点必须拆分为多张子卡片。
|
||||
- 卡片样式:方角卡片,纯平无渐变背景填充(全局一致),默认无边框无装饰,仅在需要强调时可用细边框或色条(不用于纯装饰)。
|
||||
- 卡片结构:视觉锚点(关键词、编号、符号或 IconPark 图标)+ 标题(无背景填充) + 内容(包括文字、图片、图表、表格、子卡片)。
|
||||
- 文字卡片:多数页面必须满足 6-8 张文字卡片、200-400 文字数量,文字内容像浓缩的分析文稿,必须使用长句。多个要点或列表多项必须做成多张子卡片,关键词和短句必须做成方角标签卡片。高密度文字所在的文本框的 `<content>` 必须设置 `wrap="true" autoFit="normal-auto-fit"` 属性自动换行和缩排,避免文字溢出。
|
||||
- 图片卡片:多数页面必须满足 1-3 张图片卡片,素材收集阶段必须进行图片搜索和图片生成,数量不足时使用生图工具补充。
|
||||
- 图表卡片:数据信息不要用文字卡片,必须用图表卡片可视化,包括表格、原生图表、时间线、流程图等。原生图表有内置标题不需要卡片标题。
|
||||
- 间距要求:页面内容不要贴边、不要溢出,卡片内容不要贴边、不要溢出,卡片之间不要相连、不要重叠。
|
||||
- 对齐方式:正文默认左对齐;表格里的文字默认居中,长句、段落和列表设置左对齐;图表、图片、文本框设置在卡片内居中。
|
||||
- 密集填充:内容区用卡片网格密集铺满、避免大片空白;只有封面或刻意用整张图铺底的页面,才让画面满幅铺到页边(full-bleed),常规内容页保持四周留白。
|
||||
- 禁止在 `headline` 或 `title` 下方放置用于分隔或装饰的 `rect` 或 `<line>`。
|
||||
|
||||
## 视觉风格
|
||||
|
||||
- 美学:纯平无渐变,干净、明亮、清爽但信息饱满;靠卡片和对齐网格在高密度下维持秩序感;同排卡片文字数量应相近以保持观感整齐。高密度文字所在的文本框必须突出重点(关键信息加粗)和信息分组(分区段后换行,注意换行会占用高度空间,或拆分为多个文本框),让高密度文字也有主次和节奏,提高可读性,而不是成片纯文字块。
|
||||
- 字体:必须使用思源宋体(衬线字体),不要用默认的思源黑体。
|
||||
- 字号:标题 28-36pt、正文 12-14pt、注释 10-12pt,关键指标 16-32pt(核心指标数字可用 36-52pt),下面配 10-14pt 标签与简短解读,需要容纳更多文字时允许使用更小的字号。
|
||||
- 图标:内嵌 IconPark 图标(可用关键词、编号、符号替代)作为视觉锚点。
|
||||
- 配色:全局统一,克制颜色数量,限制仅用 1 个背景色(纯白或冷淡色)、1 个主色、1 个强调色和 1 个辅助色,使用莫兰迪/深莫兰迪配色,禁止高饱和配色,背景与主体色彩搭配协调自然。
|
||||
255
skills/lark-slides/references/education-training.md
Normal file
255
skills/lark-slides/references/education-training.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# Education & Training
|
||||
|
||||
Scope: courseware, job-skill training, operation guides, teacher materials, study handbooks, public education, onboarding training, capability building, and knowledge popularization. The reader may be listening live or reading independently; their task is not merely to understand a page, but to form understanding they can retell, methods they can execute, and actions they can transfer.
|
||||
|
||||
> Core position: design the learning first, the pages second; complete the content skeleton first, the visual system second. Reference samples only provide design cues worth borrowing — they are not default templates, nor styles that must be replicated. Courseware should carry enough content to support learning; do not turn body content into posters or whitespace albums for the sake of a "premium feel."
|
||||
|
||||
## 1. Define the Learning Task First; Do Not Pick a Style First
|
||||
|
||||
Before production, write down the following information; if any of it is missing, fill it in with reasonable assumptions and mark it in the design document.
|
||||
|
||||
- **Learner:** age, level of expertise, prior knowledge, easily confused terms, accessibility needs.
|
||||
- **Usage:** live projection, printed handouts, self-study reading, or mixed use; roughly how long each page stays in view.
|
||||
- **Entry state:** what the learner does not know now, cannot do now, or is prone to getting wrong at which step.
|
||||
- **Exit capability:** after the material, what the learner can explain, differentiate, judge, operate, check, or retell. Goals must be observable.
|
||||
- **Material boundaries:** which parts are external facts, and which are team inferences, fictional exercises, illustrative data, or to-be-filled material.
|
||||
- **Real task:** in what work, classroom, or life scenario the learner will next apply this method.
|
||||
|
||||
Do not first decide "use blue, use rounded corners, use some Sample" and then cram content into the pages. Every visual choice must be explainable as: it helped the learner see which relationship, remember which step, or complete which judgment.
|
||||
|
||||
## 2. General Prohibitions
|
||||
|
||||
The following are red lines running through the entire deck; they take effect before all layout and visual rules, and unless the user explicitly requests otherwise, none may be violated.
|
||||
|
||||
- **No cards by default:** unless the user explicitly requests it, strictly forbid using rounded rectangles or rectangular cards to build hierarchy or alignment. Line segments, whitespace, and font-size/weight differences are better solutions.
|
||||
- **No evenly divided compositions:** unless no other layout is available, do not default to one-third splits, four-way splits, or 2×2 matrices — including formulaic patterns such as "title + three parallel blocks + conclusion."
|
||||
- **No mediocre, common, or AI-typical color schemes:** unless the user explicitly requests them, strictly forbid blue-and-white pairings, blue-purple gradients, cyan-purple neon, rainbow flares, glassmorphism cards, and glowing borders.
|
||||
- **No elements that clash with the overall style:** no styles from outside the chosen style may appear, such as using rounded icons or rounded rectangles within a sharp style.
|
||||
|
||||
## 3. The Teaching Loop: Every Chapter and Every Page Needs a Take-Away Action
|
||||
|
||||
The default learning path is: **orientation → comprehension → demonstration → practice → feedback → transfer**. Not every short material needs a separate page for each stage, but when a stage is missing, you must be able to say that the task itself does not call for it — not that the page ran out of space.
|
||||
|
||||
- **Orientation:** explain why this is worth learning, what the learner can do after completing it, and how it relates to the chapters before and after. Use a real problem or scenario instead of grand slogans.
|
||||
- **Comprehension:** first give one core definition the learner can retell, then the boundaries, composition, counterexamples, or relationships. New terms must not appear only in figure captions.
|
||||
- **Demonstration:** show the process and the intermediate judgments, not just the final answer; put the key forks, the basis for each choice, and the common misconceptions on the same reading path.
|
||||
- **Practice:** specify the input, the actions, and the completion criteria. Practice scenarios may be fictional, but must be labeled "practice scenario / illustrative material."
|
||||
- **Feedback:** explain why an answer is right or wrong, where the error lies, and how to check next time. Answer pages and question pages must be distinguishable at a glance.
|
||||
- **Transfer:** connect to real use with checklists, templates, job-aid cards, or next-step actions; do not substitute a slogan for transfer.
|
||||
|
||||
Each page carries only one primary learning action:
|
||||
|
||||
- Concept pages get the learner to state clearly "what it is and where its boundaries lie."
|
||||
- Method pages get the learner to know "when to use it and how to choose."
|
||||
- Process pages get the learner to know "what to do first, what next, and how to check."
|
||||
- Demonstration / case pages let the learner see the judgment process.
|
||||
- Practice pages get the learner to act or make a choice.
|
||||
- Feedback / review pages correct and distill principles.
|
||||
|
||||
Titles must foretell the page's takeaway. Prefer question sentences, conclusion sentences, or "action + object"; avoid mere section labels like "Background," "Overview," or "Related content." Put all key definitions, steps, and answer criteria into the material — do not rely on the presenter to fill them in live.
|
||||
|
||||
## 4. Default Density for Courseware: Full Content; Whitespace Has a Job
|
||||
|
||||
Courseware is not a poster, nor a full script shrunk down and plastered across pages. The default density is "medium, leaning full": the learner can spot the main object at a glance, and can also find, on the same page, the support needed to understand or act.
|
||||
|
||||
- Body pages usually have at least three layers: learning question / conclusion title → one main exhibit → one layer of explanation, task, check, or source.
|
||||
- As a starting point, about 60% or more of a body page's main content area should be occupied by meaningful text, figures, tables, flows, or exercises; this is a density guardrail, not a hard ratio. Projection pages may reduce text and self-study pages may add explanation, but neither may use emptiness to mask insufficient content.
|
||||
- Whitespace is for grouping, establishing reading order, and highlighting key points — it is not the page's main product. Except for covers, section openers, key manifestos, or closing pages, do not produce consecutive pages of "one sentence + a big blank."
|
||||
- A page may have quiet areas, but only with an explicit reason: a pause, a transition, a memory anchor, or an emotional close. The reason should be writable into the page outline, not explained after the fact.
|
||||
- High-density pages reduce decoration and add grouping and labels; low-density pages add retellable definitions, examples, check questions, or next steps — not random graphics.
|
||||
- One set of courseware may have many page types, but the grid, the title anchor, the type hierarchy, the navigation position, and the color semantics stay stable. A unified system does not mean copying the same template onto every page.
|
||||
|
||||
Let density vary with the learning action:
|
||||
|
||||
| Page type | What it carries | Default density | What must be visible |
|
||||
| --- | --- | --- | --- |
|
||||
| Route / opening | Learning questions, goals, chapter relationships | Low to medium | Where we come from and where we are going |
|
||||
| Core concept | Definitions, boundaries, main relationships | Medium | One definition and one intuitive anchor |
|
||||
| Demonstration / case | Material, process, judgments, conclusion | Medium to high | The difference between evidence and interpretation |
|
||||
| Practice / worksheet | Input, operations, completion criteria | Medium to high | What the learner must do right now |
|
||||
| Feedback / review | Answers, reasons, error correction, principles | Medium | Why this judgment is made |
|
||||
| Transfer / summary | Checklists, templates, resources, next steps | Medium | How to use this after leaving the material |
|
||||
|
||||
## 5. Division of Labor: Text, Graphics, Examples, and Exercises Do Not Impersonate Each Other
|
||||
|
||||
- Text explains meaning, boundaries, conditions, and the basis for judgments.
|
||||
- Graphics reveal real relationships: flows for sequence, trees or nesting for hierarchy, parallel regions for comparison, one-way chains for causality, and cycles only where a genuine loop exists.
|
||||
- Examples lower abstraction, but facts, results, and explanations must be layered; a case's teaching role is labeled first (demonstration, comparison, diagnosis, or transfer).
|
||||
- Exercises test mastery; do not fill pages with irrelevant games.
|
||||
|
||||
One structural diagram expresses one primary relationship. Every connector needs a direction or a meaning; delete decorative arrows, meaningless rings, and nodes that exist only to fill the page. A key model is explained in full at first appearance, then keeps the same name, shape, color, and reading direction.
|
||||
|
||||
External facts, data, quotations, and method sources stay close to their corresponding exhibits, stating at least the source entity and the necessary measurement basis. Illustrative data, team inferences, and fictional material must be explicitly labeled. Without reliable data, use schematic structures or qualitative comparisons — never generate realistic-looking precise values.
|
||||
|
||||
Images are used only when "seeing works better than describing." An image may serve as observational evidence or establish a situation; purely decorative images must not crowd out the main learning object. The content of screenshots, photos, illustrations, and charts must be chosen fresh for each new topic — do not copy an object just because a reference sample used it.
|
||||
|
||||
## 6. Order of Visual Decisions: Samples Are a Reference Library, Not an Answer Key
|
||||
|
||||
Make visual decisions in this order:
|
||||
|
||||
1. Complete the learning objectives, the chapter path, and the page-by-page outline.
|
||||
2. Select each page's main learning object and density tier — do not pick colors or motifs first.
|
||||
3. Based on the topic, audience, scenario, and evidence types, write a one-page visual brief: temperament, grid, type hierarchy, color roles, graphic grammar, image strategy, component boundaries, headers/footers, and density.
|
||||
4. When a reference sample is needed, extract only "explainable design decisions," then translate them into the current topic.
|
||||
5. Form one main system plus several page-type variants; do not stitch together the motifs, fonts, cards, and colors of multiple samples.
|
||||
|
||||
### The Correct Way to Use Reference Samples
|
||||
|
||||
When you receive a Sample, template, or screenshot, first look at the page's actual visual effect (do not just extract the copy), then write a "reference extraction card" recording only:
|
||||
|
||||
- **Borrowable functions:** e.g., a stable grid, role color codes, step navigation, evidence zoning.
|
||||
- **Borrowable tone:** e.g., restrained, friendly, rigorous, live-event feel, or public-communication feel.
|
||||
- **Borrowable graphic grammar:** e.g., parallel modules, time paths, nested hierarchy, or photo frames.
|
||||
- **Objects that cannot be inherited:** the sample's brands, photos, characters, specific copy, proprietary color values, and decorative motifs.
|
||||
- **Applicable page types:** only note whether it suits concepts, cases, exercises, or steps — do not expand it into a whole-deck template.
|
||||
|
||||
One material borrows at most two or three mutually compatible features from a single reference sample; after borrowing, re-name the semantics of colors and components. Without a sample, still build the system yourself from the learning task. Sample 1 enjoys no default priority; it is only one reference direction for editorial-style courseware.
|
||||
|
||||
## 7. Reference Directions (Inspiration Only; New Directions May Be Generated)
|
||||
|
||||
These directions help you understand design choices quickly; they are not fixed palettes, font tables, or page templates. Take only the parts that fit the task.
|
||||
|
||||
### Direction A: Editorial Study Handbook (May Reference Sample 1)
|
||||
|
||||
- **Borrowable:** a clear title–exhibit–explanation hierarchy; a stable reading grid; few and explicit accent colors; cases, methods, and sources placed in their own zones; images separated from text.
|
||||
- **Suits:** concept teaching, methodology, case reading, blended courses, and materials that require self-study.
|
||||
- **No need to copy:** the blue-and-white palette, geometrically cropped images, capsule labels, fixed hairlines, large chapter whitespace, or any specific photo subject.
|
||||
- **Density note:** body pages are mainly medium density; cases and exercises can be fuller; only opening and closing pages drop density noticeably.
|
||||
|
||||
### Direction B: Role / System Infographics
|
||||
|
||||
- **Borrowable:** stable color codes distinguishing roles or modules; nesting, sectors, matrices, or interlocking blocks expressing real relationships; action text kept tight against its owning module.
|
||||
- **Suits:** multi-role collaboration, public education, system composition, division of responsibilities, and action maps.
|
||||
- **No need to copy:** colorful collages, full-page color blocks, flat silhouettes, right-angled modules, or any specific color combinations.
|
||||
- **Density note:** the smaller the module, the shorter its text; relationship-diagram pages keep the necessary gaps; role-action pages may be denser, but meaning must never ride on color alone.
|
||||
|
||||
### Direction C: Steps / Work Records
|
||||
|
||||
- **Borrowable:** a persistently visible progress cue; numbered steps; clear containers for photos, screenshots, or worksheets; layered actions, risks, and check items.
|
||||
- **Suits:** operation guides, compliance, safety, job skills, and on-site training.
|
||||
- **No need to copy:** file folders, paper textures, monospaced labels, fixed bottom stacks, or any skeuomorphic decoration.
|
||||
- **Density note:** each step page answers at least "what to do, why, and how to check"; route and review pages are not emptied for decoration.
|
||||
|
||||
### Direction D: Evidence / Data Editorial (Build Your Own as Needed)
|
||||
|
||||
- **Borrowable:** making charts, tables, or screenshots the main exhibit; the title gives the learning question or conclusion first; side text explains the measurement basis, the observation points, and the next step.
|
||||
- **Suits:** professional popular-science content, results interpretation, before-after comparison, research training, and courses needing evidence support.
|
||||
- **No need to copy:** dashboard black backgrounds, rainbow charts, giant numbers, dense annotations, or industry-specific brand styles.
|
||||
- **Density note:** data pages may be full, but each chart serves only one question; without data, do not force charts just to "look like a report."
|
||||
|
||||
If the topic fits none of these directions, establish a new reference direction and write down "what it helps the learner accomplish" — rather than adding more decorative rules to it.
|
||||
|
||||
## 8. Page Skeletons: Choose the Learning Action First, Then the Layout
|
||||
|
||||
Every page is written as a minimal closed loop of "title + main exhibit + support layer + action / check + source." The following page types are composable — not all must be used.
|
||||
|
||||
- **Learning route:** learning questions, chapter order, and exit capabilities; use short sentences and a path, not a long table of contents.
|
||||
- **Concept page:** one definition + boundaries / composition + one intuitive example or structural diagram.
|
||||
- **Method map:** the overall framework + current step / current module + a cue for what unfolds next.
|
||||
- **Demonstration page:** input material + intermediate judgments + basis for choices + result; put the error-prone forks on the same path.
|
||||
- **Case-evidence page:** a case-role label + factual material + interpretation + transferable judgments + source.
|
||||
- **Comparison / error-prone page:** two approaches or states placed side by side on the same skeleton + judgment criteria + common mistakes.
|
||||
- **Practice page:** task instructions + input / scenario + work area + completion criteria; do not hide the answer on the same page.
|
||||
- **Feedback page:** the reference approach + reasons + error location + how to check next time; make clear that this is the answer or the feedback.
|
||||
- **Transfer page:** checklists, templates, job-aid cards, resources, and next-step actions; avoid merely writing "apply what you learned."
|
||||
|
||||
A presentation version may move extended explanation into speaker notes, but the page must still keep the learning question, the main exhibit, the task / judgment criteria, and the necessary sources. A self-study version writes the definitions and conditions needed for understanding on the page itself.
|
||||
|
||||
## 9. Minimum Constraints for the Visual System
|
||||
|
||||
### Grid and Hierarchy
|
||||
|
||||
- First fix the main content area, the title anchor, the content's left axis, and the page-number / source position; then decide the page variants.
|
||||
- Each page has only one first visual entry: the learning question or principle is seen first, the main exhibit second, the explanation / task / source third.
|
||||
- Titles, body text, figure notes, and sources use the same type family or a clear two-family relationship; the Chinese fallback must genuinely work. Do not force in a font that does not support Chinese just to imitate a sample.
|
||||
- Titles and body text establish hierarchy through size, weight, and position. Projection materials prioritize body-text readability; self-study materials may add explanation, but body text is never pressed down to footnote size.
|
||||
- Ordinary body text reaches at least 4.5:1 contrast against the background; when contrast is insufficient, change the text color or background directly — do not patch it with shadows and strokes.
|
||||
|
||||
### Color
|
||||
|
||||
- Without brand guidelines, first choose a neutral background, one structural color, and one necessary semantic accent: few and stable colors are easier to learn from than many and lively ones.
|
||||
- Every color carries a fixed meaning (chapter, role, state, focus, or link) and must not decorate randomly; always also encode with text, shape, position, or icons alongside color.
|
||||
- A page is usually governed by a neutral background + one main structural color; add a second color family only when a genuine role / category mapping exists.
|
||||
- Body text on dark grounds uses high-contrast light colors; body text on light grounds uses near-black. Small type never sits on high-saturation colors.
|
||||
|
||||
**Starting point when there is no sample (not a fixed style):** adopt a near-white or light-gray background, near-black body text, one structural accent color, and one low-frequency semantic color; build the title-and-body hierarchy with a neutral sans-serif; pair a fixed title left axis with a "main exhibit + explanation / task" layout; keep body pages medium-leaning-full, with only covers, section openers, and closing pages noticeably dropping density. Then adjust by topic, audience, and material evidence — do not treat this starting set as a new template.
|
||||
|
||||
### Cards, Components, and Graphics
|
||||
|
||||
- Cards are used only for grouping, carrying states, or forming operable regions; continuously stacking cards is not a default layout.
|
||||
- Rounding, right angles, strokes, shadows, and capsules are all component grammar — once decided, stay consistent throughout; do not take one from each different Sample.
|
||||
- Shadows, textures, gradients, and decorative icons are used only when they serve a hierarchical or situational function. Anything whose removal does not hurt understanding should be removed.
|
||||
- Default Office charts, SmartArt, rainbow palettes, thick black table frames, and untuned bullet lists must be redone.
|
||||
|
||||
### Images and Charts
|
||||
|
||||
- Every image must state "what to see and why it needs to be seen"; evidence images, operation screenshots, and situational images are each labeled with their purpose.
|
||||
- Charts carry "see the relationship," side text carries "how to interpret and what to do next"; do not repeat the chart's labels verbatim.
|
||||
- Charts keep the necessary units, time range, legend, key labels, measurement basis, and source; delete non-essential gridlines, frames, and decoration.
|
||||
- Tables use clear headers, horizontal separators, correct alignment, and the necessary focus rows; do not use full rows of high-saturation color or color alone to express states.
|
||||
|
||||
## 10. Model Execution Protocol
|
||||
|
||||
### 1. Content First
|
||||
|
||||
First write the learner, entry state, exit capability, usage, evidence boundaries, and practice tasks; then write the chapter path. Record at least, for every page:
|
||||
|
||||
> Chapter / page type / learning action / title / main exhibit / explanation or task / feedback or check / source / density tier
|
||||
|
||||
Check whether every chapter has comprehension, demonstration, application, feedback, and transfer; if not, first add the content or note why it does not apply.
|
||||
|
||||
### 2. Reference-Sample Extraction
|
||||
|
||||
If there is a Sample, template, or screenshot, finish the reference extraction card before writing the visual brief. The card must not directly copy the object layer (brands, photos, copy, proprietary color values, proprietary motifs); keep only the transferable structure, tone, and graphic relationships. Samples 1, 2, and 3 are all merely candidate references and must not become implicit defaults.
|
||||
|
||||
### 3. Visual Brief
|
||||
|
||||
The visual brief states at least:
|
||||
|
||||
- the audience and usage environment;
|
||||
- the topic's temperament and density targets;
|
||||
- one main grid and two to six page types;
|
||||
- the hierarchy of title / body / source;
|
||||
- the semantic division of colors and the contrast plan;
|
||||
- the graphic grammars for flows, hierarchy, comparison, and evidence respectively;
|
||||
- the usage boundaries for images, screenshots, and illustrations;
|
||||
- the fixed positions of header, footer, page number, source, and navigation;
|
||||
- which elements appear only on covers, section pages, or closing pages.
|
||||
|
||||
### 4. Page-by-Page Generation
|
||||
|
||||
- Write titles that foretell the learning takeaway.
|
||||
- Choose the single main learning object.
|
||||
- Complete the definitions, boundaries, labels, tasks, check criteria, and sources.
|
||||
- Apply the grid, hierarchy, and components per the visual brief — do not copy a Sample page by page.
|
||||
- Delete graphics, images, quotes, and repeated text that serve no teaching function.
|
||||
- Check content and overflow first, then make visual fine-tuning; do not mask excessive content by shrinking font size.
|
||||
|
||||
### 5. Pre-Delivery Validation
|
||||
|
||||
Use the existing PPTD / PPTX validation and screenshot workflow to check overflow, overlap, truncation, garbled text, low contrast, missing page numbers, and missing sources. Review key pages individually at minimum; do not declare completion from thumbnails alone.
|
||||
|
||||
## 11. Delivery Acceptance and Reverse Checks
|
||||
|
||||
### Content and Teaching
|
||||
|
||||
- Reading only the titles, can you state the complete learning path — not a string of section names?
|
||||
- With the title covered, can the main exhibit still say what it teaches and what to do?
|
||||
- Does each page carry only one main learning action, and does the explanation truly lead to action?
|
||||
- Do exercises specify input, actions, and completion criteria? Does feedback explain the reasons and the error-correction method?
|
||||
- Are facts, illustrative material, inferences, and fictional material clearly distinguished? Does external evidence have sources and measurement basis?
|
||||
- Do the same concepts, roles, steps, and states keep the same name, color, and shape throughout?
|
||||
|
||||
### Density and Visuals
|
||||
|
||||
- Do body pages have enough content to support learning, rather than big titles plus blanks? Do consecutive sparse pages have an explicit teaching reason?
|
||||
- Does each page have one visual entry and one quiet area; are the title, charts, cards, and big images all competing for first attention at the same time?
|
||||
- With color removed, are steps, roles, states, and categories still legible?
|
||||
- With the reference sample's photos, color values, and motifs removed, is the courseware still a self-consistent system?
|
||||
- Is there only one main visual grammar, while different learning actions may use different page types?
|
||||
- Are images sharp, relevant, unstretched, with no text pressed over complex areas?
|
||||
- Do charts, tables, and structural diagrams express real relationships, rather than filling the page?
|
||||
|
||||
### The Final Cut
|
||||
|
||||
Ask item by item: "If I delete this, what does the learner lose?" If the answer is only "the page will look emptier," delete it. What truly needs filling is not decoration, but definitions, examples, judgment criteria, exercises, feedback, or transfer actions.
|
||||
@@ -34,7 +34,6 @@ lark-cli drive +task_result --scenario import --ticket <TICKET>
|
||||
理解页面后,直接在导入后的 Slides 上编辑。允许的操作包括:
|
||||
|
||||
- 填写、替换、凝练或删除文字。
|
||||
- 替换或补充图片。
|
||||
- 更新图表、表格、数字标签或节点标签里的内容。
|
||||
- 按需复制、删除或重排模板页。
|
||||
- 在源页面没有合适承载位置时,做局部、小范围新增元素。
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
```bash
|
||||
lark-cli slides +screenshot --as user \
|
||||
--presentation '<xml_presentation_id 或 slides/wiki URL>' \
|
||||
--slide-number 1
|
||||
--slide-id 'SLIDE_ID'
|
||||
```
|
||||
|
||||
渲染本地 XML 内容:
|
||||
@@ -25,12 +25,12 @@ lark-cli slides +screenshot --as user \
|
||||
|
||||
| 参数 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `--presentation` | list 模式必需 | `xml_presentation_id`、`/slides/` URL,或解析后为 slides 的 `/wiki/` URL。传 `--content` 时不能使用 |
|
||||
| `--slide-id` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面 short ID;多页截图时重复传入;一次最多 10 页(`--slide-id` + `--slide-number` 合计小于等于 10) |
|
||||
| `--slide-number` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面页号;多页截图时重复传入;一次最多 10 页(`--slide-id` + `--slide-number` 合计小于等于 10) |
|
||||
| `--presentation` | list 模式必需 | `xml_presentation_id`、`/slides/` URL,或解析后为 slides 的 `/wiki/` URL;只标识演示文稿,不会默认截图全部页面。传 `--content` 时不能使用 |
|
||||
| `--slide-id` | list 模式标准入参 | 页面 short ID;截图、修复和 review 状态均以它关联;多页截图时重复传入;一次最多 10 页。先从创建响应或 `slides +xml-get` 取得当前 `slide_ids` |
|
||||
| `--slide-number` | 用户只提供“第 N 页”或旧 deck 暂未取得 `slide_id` 时使用;成功定位后必须取得对应 `slide_id`,后续不再用页号关联截图或 review 状态。不能省略 `--slide-id` 和 `--slide-number` 两者 |
|
||||
| `--content` | render 模式必需 | 要直接渲染的 `<slide>` XML 片段;支持直接传值、`@file`、`-` stdin。传入后不能同时传 `--slide-id` / `--slide-number` |
|
||||
| `--output-dir` | 否 | 输出目录,默认 `.lark-slides/screenshots`;必须是当前目录内的相对路径 |
|
||||
| `--output-name` | 否 | render 模式的输出文件名 stem;未指定时优先用返回的 `slide_id`,否则用 `rendered-slide`。若目标文件已存在,会自动追加递增后缀避免覆盖 |
|
||||
| `--output-dir` | 否 | 输出目录,默认 `.lark-slides/screenshots`;必须是当前目录内的相对路径。截图可能返回多张图片,使用目录而不是 `--output` 文件路径 |
|
||||
| `--output-name` | 否 | 仅 render 模式(`--content`)的输出文件名 stem;未指定时优先用返回的 `slide_id`,否则用 `rendered-slide`。若目标文件已存在,会自动追加递增后缀避免覆盖 |
|
||||
|
||||
## 示例
|
||||
|
||||
@@ -39,21 +39,25 @@ lark-cli slides +screenshot --as user \
|
||||
```bash
|
||||
lark-cli slides +screenshot --as user \
|
||||
--presentation slides_example_presentation_id \
|
||||
--slide-number 1
|
||||
--slide-id 'SLIDE_ID'
|
||||
```
|
||||
|
||||
### 多页截图
|
||||
### 按 `slide_id` 截图与创建后视觉 review(推荐)
|
||||
|
||||
一次不要超过 10 页;如需更多页面,分批调用。
|
||||
视觉 review 以当前回读得到的 `slide_ids` 为页清单。单页传一个 `--slide-id`;多页可重复传入,单次最多 10 页,超过时按批次串行执行。
|
||||
|
||||
首次新建且之后没有增删页、整页替换或重排时,可复用创建响应中的 `slide_ids`。发生上述页面集合变化后,必须先回读并刷新清单;不能用页码或旧响应中的页列表绑定 review 状态。
|
||||
|
||||
```bash
|
||||
lark-cli slides +screenshot --as user \
|
||||
--presentation slides_example_presentation_id \
|
||||
--slide-number 1 \
|
||||
--slide-number 2 \
|
||||
--output-dir .lark-slides/screenshots/demo
|
||||
--presentation 'YOUR_PRESENTATION_ID' \
|
||||
--slide-id 'SLIDE_ID_1' \
|
||||
--slide-id 'SLIDE_ID_2' \
|
||||
--output-dir .lark-slides/review/<deck-or-task-id>/screenshots
|
||||
```
|
||||
|
||||
随后必须用具备图像查看能力的工具打开每个返回的 `path`,逐页记录 `pass/fix`。截图落盘、批量请求成功或只查看关键页,都不等于已完成视觉 review。
|
||||
|
||||
### 渲染 XML 预览
|
||||
|
||||
```bash
|
||||
@@ -89,9 +93,10 @@ lark-cli slides +screenshot --as user \
|
||||
## 注意事项
|
||||
|
||||
1. 优先使用 `slides +screenshot` 保存本地图片,不要把图片 Base64 打到 stdout。
|
||||
2. 已存在 PPT 页面截图时,不传 `--content`,用 `--presentation` + `--slide-id` 或 `--slide-number`。
|
||||
2. 已存在 PPT 页面截图时,不传 `--content`,用 `--presentation` + `--slide-id`。
|
||||
3. 本地 XML 预览时,传 `--content @file` 或 `--content -`,内容应为单个 `<slide>` XML 片段;此时不要传 `--presentation` / `--slide-id` / `--slide-number`。
|
||||
4. `slide_id` 是页面 short ID,页码请用 `--slide-number`。
|
||||
5. list 模式一次最多传 10 页(`--slide-id` + `--slide-number` 合计小于等于 10);更多页面请分批截图。
|
||||
4. `slide_id` 是页面 short ID,也是截图、修复和 review 状态的唯一关联键;页码仅作为用户可读的瞬时展示信息。
|
||||
5. list 模式一次最多传 10 个 `--slide-id`;更多页面请分批截图,每页仍要独立记录 review 结论。
|
||||
6. list 模式默认文件名包含 presentation ID、页码和/或 slide ID;文件已存在时自动追加 `_2`、`_3` 等后缀,避免覆盖旧截图。
|
||||
7. 截图来自服务端渲染结果,适合创建/替换后验证页面是否为空白、破图或布局明显异常。
|
||||
7. 截图来自服务端渲染结果,适合创建/替换后验证页面是否为空白、破图或布局明显异常;与 `validation-checklist.md` 的逐页 rubric 一起使用。
|
||||
8. 如果因用户只给页号而使用 `--slide-number`,截图后立即回读或从响应取得 `slide_id`,后续改用 `--slide-id`;如果收到频率限制,停止扩大发送并在短暂退避后逐批重试。截图 API 白名单失败时记录原始错误,继续完成 XML 静态检查,并把视觉状态标为 `not_verified`。
|
||||
|
||||
37
skills/lark-slides/references/management-report.md
Normal file
37
skills/lark-slides/references/management-report.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Management Reporting
|
||||
|
||||
## Clear and direct content; no empty talk
|
||||
|
||||
- A manager who flips through the whole deck reading only the page titles should grasp how the period went, why, and what is being asked — the title itself states a status or conclusion, such as "Q2 revenue reached 92% of target; the shortfall comes from North-region renewals," rather than a section label like "Business Performance Report."
|
||||
- Open the first page and know how the period went overall: besides the title, the cover presents the single most critical status line of the whole deck — fulfillment rate of core metrics, a red/green/yellow traffic-light overview, or the biggest gap.
|
||||
- Information is truthful and never fabricated: user-provided numbers and facts are neither exaggerated nor selectively clipped; any baseline that exists in the material travels alongside the results, and anything not in the material is not invented on the user's behalf; use numbers, charts, and tables flexibly to present key information.
|
||||
- Professional wording that fits the user's identity: no layman's language, and no one-size-fits-all generic business-speak.
|
||||
|
||||
## Visualize information with structural diagrams
|
||||
|
||||
**Progress, data, responsibilities, risks, and dependencies are the core relationships of a management report — extract the various relationships from the content and express them with structural diagrams and frameworks.**
|
||||
|
||||
## Key Requirements
|
||||
1. Create a top-tier visual experience: treat every detail of typesetting, fonts, and colors with care, crafting it like a work of art.
|
||||
2. Unless the user explicitly requests it, **strictly forbid using rounded rectangles or rectangular cards** to build hierarchy or alignment: line segments, whitespace, and font/size differences are better solutions.
|
||||
3. Unless no other layout is available, forbid evenly divided compositions; do not default to one-third splits, four-way splits, or 2×2 matrices — including three-part formulas such as "three columns + title + conclusion."
|
||||
|
||||
## Visual References
|
||||
|
||||
1. Fonts split into families: display type and body type have a clear division of labor (different weights of the same sans-serif family, or sans-serif titles paired with another family for body text); hierarchy is built on font-size and weight ratios; functional text such as numbering, labels, and footnotes is uniformly set small in caps / with added letter spacing; font sizes flex with content volume while the hierarchy ratios stay unchanged.
|
||||
2. Fonts and the color scheme must be matched and harmonious; reference each other when designing.
|
||||
3. Information-position discipline: conclusion/title → fixed title position; evidence (charts, numbered cards, photos) → a fixed area in the right column or lower half; measurement basis, sources, footnotes → small type in a fixed corner.
|
||||
4. De-default charts: remove axes, grids, legend boxes, and default colors; reassign all series colors to same-family shades; bars are flat and rounded, with no stroke and no shadow; data labels keep only key points or move to the text column; key metrics are marked directly with big-number callouts; charts and interpretive text are strictly zoned — no overprinting, no interleaving.
|
||||
5. Encourage using rich SmartArt to improve information efficiency and convey professionalism.
|
||||
6. Unified corner radius and texture: all rectangular components (cards, panels, photos, bar tops) share one corner-radius scale with no mixing throughout the deck; flat, no drop shadows, no 3D, no gradient text; atmospheric layers such as grain or light flares, if used, stay low-intensity, fixed in position, and beneath the content.
|
||||
7. Motifs run through the whole deck: establish 1–2 visual motifs (numbered badges, connecting dashed lines, decorative shapes, etc.) and reuse their variants across page types; when a page lacks a visual anchor, reuse a motif first instead of introducing new elements.
|
||||
|
||||
### Color Palette Reference
|
||||
|
||||
**Colors expected in spirit, unexpected in choice**:
|
||||
- Reject the most common, most mediocre formulaic palettes of each scenario (Office default blue, tech blue-purple gradients, promotion-season red-and-gold, gray-blue template colors, etc.); suggested palettes for some scenarios:
|
||||
* Operations/finance/board reporting: off-white #F7F3E8 base + ink green #123B2F + brass #B08D3E; or light stone #E7E2D8 base + deep navy #16283C + copper orange #C0652B; or ink black #1A1A2E base + brass #B08D3E + off-white #F7F3E8.
|
||||
* Business operations review (retail/manufacturing/consumer/supply chain): warm white #F8F5EF base + wine red #5E1F2D + beige gold #D8C3A5; or off-white #F6EFE2 base + ink green #1E3B33 + terracotta #C15F3C; or light steel #C3CCD4 base + deep blue-gray #1F3240 + signal yellow #F0A202.
|
||||
* Internet/tech/growth monthly reports: cool white #F4F7F6 base + graphite gray #2B2D42 + pine green #4C7A5A; or mist white #EDF1F7 base + deep indigo #1C2B5A + sky azure #4E9FE6; or ink black #101418 base + celadon green #3FA68C + cool white #F4F7F6.
|
||||
* HR/administration/government-affairs reporting: warm white #FAF6EE base + ink purple #3D2C4F + cream yellow #F2C14E; or paper white #F7F3E8 base + pine green #2F5233 + apricot yellow #E8B54A; or off-white #F6F4EF base + deep navy #1B2A4A + silver gray #AEB4BC.
|
||||
- Align background colors toward something more stable and restrained, e.g.: warm white #F7F3E8, light stone #ECEAE4, mist gray #E9E7E2; section/accent pages may use deep ink blue #16283C or ink black #1A1A2E.
|
||||
@@ -162,6 +162,7 @@ Use one of these `layout_type` values unless the user explicitly needs a custom
|
||||
- `comparison`
|
||||
- `architecture-diagram`
|
||||
- `process-flow`
|
||||
- `relationship-network`
|
||||
- `quote-highlight`
|
||||
- `conclusion`
|
||||
|
||||
|
||||
36
skills/lark-slides/references/slides_categories.md
Normal file
36
skills/lark-slides/references/slides_categories.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# PPT category guide
|
||||
|
||||
1. **Follow the general rules**: the general rules apply to all scenarios and all pages, and take effect together with the style guidance documents
|
||||
2. **Determine the scenario**: choose the matching scenario based on the user's input
|
||||
3. **Read the scenario document**: read the document for that scenario and design according to its expressive focus and approach
|
||||
|
||||
## step1. General rules
|
||||
|
||||
### Requirements
|
||||
1. **Every page has a clear reader task**: what this page should make the reader understand, believe, decide, or do — think this through before designing.
|
||||
2. **Paging has rhythm**: decide for yourself whether a table of contents or section dividers are needed; the reader should feel a change of rhythm as they flip through — some pages are taken in at a glance, others are worth stopping to read carefully.
|
||||
3. **Use charts and shape combinations flexibly**: if a body of information can be expressed through a complex chart that goes beyond what the current chart syntax can express, you are encouraged to flexibly use shapes and other means to construct the expression.
|
||||
4. **Master-level output**: every PPT is a carefully crafted work of art that could be entered into a competition; pay close attention to every detail of layout, typography, and color — details determine success or failure.
|
||||
5. **Use image search/generation sensibly**: use image search/generation tools to obtain images and place them in suitable positions. But image abuse is strictly forbidden. If the user's uploaded files contain useful images, use them on suitable pages.
|
||||
6. **Defer to the user and the subject**: user-specified templates, brand guidelines, color schemes, fonts, and style references take priority over this guide.
|
||||
7. **Source attribution**: pages involving external facts and data must state the source, date or time period, and measurement basis. Source text should use an <a href="url"> hyperlink pointing to the original report or data page. When citing specific sources in footnotes, likewise use <a href="url"> hyperlinks to the original source to strengthen professional credibility.
|
||||
|
||||
### Strictly forbidden
|
||||
- **Evidence boundaries**: do not fabricate data, citations, customer cases, experimental results, or sources; when material is missing, clearly mark it as a placeholder, an assumption, or to-be-supplied information.
|
||||
- **Classic AI patterns**: it is strictly forbidden to use cards to build hierarchy or alignment (rounded rectangles, rectangular cards, cards with a colored side strip): lines, whitespace, and font-size contrast are better solutions; it is strictly forbidden to use the AI color scheme where red, purple, yellow, and green are all gathered on one page;
|
||||
|
||||
## step2. Scenario determination
|
||||
Based on the user's input, analyze the presentation's audience and reader tasks, determine the scenario it belongs to, and read the corresponding style document.
|
||||
> Choose one primary scenario. When truly necessary, you may add one auxiliary scenario, but the primary scenario must prevail.
|
||||
|
||||
| Scenario type | Typical queries | Reader task | Style document |
|
||||
|---|---|---|---|
|
||||
| Analysis & decision | Consulting, finance, industry research, strategy, market opportunities, business analysis, investment analysis | Compare options, form judgments, support decisions | `analysis-decision.md` |
|
||||
| Business proposal | Marketing plans, sales proposals, fundraising pitches, partnership/investment promotion, product proposals, business plans | Understand the value, believe in the plan, take action | `business-plan.md` |
|
||||
| Management reporting | Work reports, project retrospectives, quarterly summaries, OKR, management briefings | Grasp the current state, surface problems, confirm actions | `management-report.md` |
|
||||
| Academic research | Graduate research projects, thesis defenses, research projects, proposal reports, mid-term reports, final/concluding reports | Evaluate the problem, method, evidence, and contribution | `academic-research.md` |
|
||||
| Education & training / knowledge popularization | K-12 courseware, teaching demonstrations, vocational training, patient education, professional popular science | Understand, remember, apply, or act correctly | `education-training.md` |
|
||||
| Tech & engineering | Engineering plans, architecture reviews, R&D reports, AI / data / ops / security | See the structure, dependencies, metrics, and trade-offs clearly | `tech-engineering.md` |
|
||||
| Brand / creative showcase | Brand stories, design proposals, portfolios, cultural events | Build perception, leave a memory, form identification | `brand-creative.md` |
|
||||
|
||||
**Fallback**: if the presentation does not clearly fit any scenario above, do not force the nearest one — fall back to the default design system [`doubao-design.md`](doubao-design.md) (extremely high-density image-and-text card layout).
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<presentation xmlns="/sml/2.0" width="960" height="540">
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<title>原生图表 Chart Demo</title>
|
||||
<theme>
|
||||
<textStyles>
|
||||
@@ -20,27 +20,27 @@
|
||||
<fill>
|
||||
<fillColor color="rgba(15, 30, 58, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="8" height="72" topLeftX="0" topLeftY="0" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(240, 129, 54, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="700" height="30" topLeftX="32" topLeftY="14" type="text">
|
||||
<content textType="headline" fontSize="22" fontFamily="思源黑体" color="rgba(255, 255, 255, 1)" bold="true">
|
||||
<p>柱状图 · Column Chart</p>
|
||||
<content textType="headline" fontSize="22" fontFamily="思源宋体" color="rgba(255, 255, 255, 1)" bold="true">
|
||||
<p>极简图表 · Minimal</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="700" height="20" topLeftX="32" topLeftY="46" type="text">
|
||||
<content textType="caption" fontSize="12" fontFamily="思源黑体" color="rgba(180, 192, 210, 1)">
|
||||
<p>季度出货量对比 · GROUPED / STACKED / 100% STACKED</p>
|
||||
<content textType="caption" fontSize="12" fontFamily="思源宋体" color="rgba(180, 192, 210, 1)">
|
||||
<p>可按需省略图例 / 标题 / 数据标签 · NO LEGEND / NO TITLE / NO LABELS</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="160" height="22" topLeftX="770" topLeftY="26" type="text">
|
||||
<content textType="caption" fontSize="11" fontFamily="思源黑体" color="rgba(220, 228, 240, 1)" textAlign="right">
|
||||
<p>SECTION 01 / COLUMN</p>
|
||||
<content textType="caption" fontSize="11" fontFamily="思源宋体" color="rgba(220, 228, 240, 1)" textAlign="right">
|
||||
<p>SECTION 01 / MINIMAL</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="290" height="400" topLeftX="32" topLeftY="92" presetHandlers="0" type="rect">
|
||||
@@ -48,16 +48,209 @@
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
<border color="rgba(226, 232, 240, 1)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="290" height="32" topLeftX="32" topLeftY="92" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(240, 244, 249, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="270" height="22" topLeftX="44" topLeftY="98" type="text">
|
||||
<content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<content fontSize="12" fontFamily="思源宋体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<p>① 柱状图 · 无图例无标题</p>
|
||||
</content>
|
||||
</shape>
|
||||
<chart width="270" height="350" topLeftX="42" topLeftY="132">
|
||||
<chartPlotArea>
|
||||
<chartPlot type="column">
|
||||
<chartExtra/>
|
||||
</chartPlot>
|
||||
<chartAxes>
|
||||
<chartAxis type="x">
|
||||
<chartLabel fontSize="9"/>
|
||||
</chartAxis>
|
||||
<chartAxis type="y" position="left">
|
||||
<chartGridLine color="rgb(226, 232, 240)"/>
|
||||
<chartLabel fontSize="9"/>
|
||||
</chartAxis>
|
||||
</chartAxes>
|
||||
</chartPlotArea>
|
||||
<chartData>
|
||||
<dim1>
|
||||
<chartField name="季度">Q1,Q2,Q3,Q4</chartField>
|
||||
</dim1>
|
||||
<dim2>
|
||||
<chartField name="营收">52,48,55,68</chartField>
|
||||
</dim2>
|
||||
</chartData>
|
||||
<chartStyle>
|
||||
<chartBackground color="rgba(0, 0, 0, 0)"/>
|
||||
<chartBorder color="rgb(222, 224, 227)" width="0"/>
|
||||
<chartColorTheme>
|
||||
<color value="rgb(28, 71, 120)"/>
|
||||
</chartColorTheme>
|
||||
</chartStyle>
|
||||
</chart>
|
||||
<shape width="290" height="400" topLeftX="335" topLeftY="92" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
<border color="rgba(226, 232, 240, 1)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="290" height="32" topLeftX="335" topLeftY="92" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(240, 244, 249, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="270" height="22" topLeftX="347" topLeftY="98" type="text">
|
||||
<content fontSize="12" fontFamily="思源宋体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<p>② 折线图 · 无图例无标题</p>
|
||||
</content>
|
||||
</shape>
|
||||
<chart width="270" height="350" topLeftX="345" topLeftY="132">
|
||||
<chartPlotArea>
|
||||
<chartPlot type="line">
|
||||
<chartExtra/>
|
||||
</chartPlot>
|
||||
<chartAxes>
|
||||
<chartAxis type="x">
|
||||
<chartLabel fontSize="9"/>
|
||||
</chartAxis>
|
||||
<chartAxis type="y" position="left">
|
||||
<chartGridLine color="rgb(226, 232, 240)"/>
|
||||
<chartLabel fontSize="9"/>
|
||||
</chartAxis>
|
||||
</chartAxes>
|
||||
</chartPlotArea>
|
||||
<chartData>
|
||||
<dim1>
|
||||
<chartField name="月份">1月,2月,3月,4月,5月</chartField>
|
||||
</dim1>
|
||||
<dim2>
|
||||
<chartField name="活跃用户">30,42,39,55,60</chartField>
|
||||
</dim2>
|
||||
</chartData>
|
||||
<chartStyle>
|
||||
<chartBackground color="rgba(0, 0, 0, 0)"/>
|
||||
<chartBorder color="rgb(222, 224, 227)" width="0"/>
|
||||
<chartColorTheme>
|
||||
<color value="rgb(28, 71, 120)"/>
|
||||
</chartColorTheme>
|
||||
</chartStyle>
|
||||
</chart>
|
||||
<shape width="290" height="400" topLeftX="638" topLeftY="92" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
<border color="rgba(226, 232, 240, 1)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="290" height="32" topLeftX="638" topLeftY="92" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(240, 244, 249, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="270" height="22" topLeftX="650" topLeftY="98" type="text">
|
||||
<content fontSize="12" fontFamily="思源宋体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<p>③ 饼图 · 标签在饼上无图例</p>
|
||||
</content>
|
||||
</shape>
|
||||
<chart width="270" height="350" topLeftX="648" topLeftY="132">
|
||||
<chartPlotArea>
|
||||
<chartPlot type="pie" yAxisPosition="right">
|
||||
<chartExtra/>
|
||||
<chartLabels position="inside" category="true" value="false" percentage="true" fontSize="10"/>
|
||||
</chartPlot>
|
||||
</chartPlotArea>
|
||||
<chartData>
|
||||
<dim1>
|
||||
<chartField name="渠道">直营,分销,线上</chartField>
|
||||
</dim1>
|
||||
<dim2>
|
||||
<chartField name="占比">45,30,25</chartField>
|
||||
</dim2>
|
||||
</chartData>
|
||||
<chartStyle>
|
||||
<chartBackground color="rgba(0, 0, 0, 0)"/>
|
||||
<chartBorder color="rgb(222, 224, 227)" width="0"/>
|
||||
<chartColorTheme>
|
||||
<color value="rgb(28, 71, 120)"/>
|
||||
<color value="rgb(240, 129, 54)"/>
|
||||
<color value="rgb(56, 142, 60)"/>
|
||||
</chartColorTheme>
|
||||
</chartStyle>
|
||||
</chart>
|
||||
<line startX="32" startY="506" endX="928.0005580355405" endY="506">
|
||||
<border color="rgba(226, 232, 240, 1)" width="1"/>
|
||||
</line>
|
||||
<shape width="500" height="18" topLeftX="32" topLeftY="512" type="text">
|
||||
<content textType="caption" fontSize="10" fontFamily="思源宋体" color="rgba(148, 163, 184, 1)">
|
||||
<p>Source: Consulting Insights Research · 数据仅用于示意</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="128" height="18" topLeftX="800" topLeftY="512" type="text">
|
||||
<content textType="caption" fontSize="10" fontFamily="思源宋体" color="rgba(148, 163, 184, 1)" textAlign="right">
|
||||
<p>01 / 08</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
<note>
|
||||
<content/>
|
||||
</note>
|
||||
</slide>
|
||||
<slide>
|
||||
<style>
|
||||
<fill>
|
||||
<fillColor color="rgba(248, 249, 251, 1)"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<shape width="960" height="72" topLeftX="0" topLeftY="0" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(15, 30, 58, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="8" height="72" topLeftX="0" topLeftY="0" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(240, 129, 54, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="700" height="30" topLeftX="32" topLeftY="14" type="text">
|
||||
<content textType="headline" fontSize="22" fontFamily="思源宋体" color="rgba(255, 255, 255, 1)" bold="true">
|
||||
<p>柱状图 · Column Chart</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="700" height="20" topLeftX="32" topLeftY="46" type="text">
|
||||
<content textType="caption" fontSize="12" fontFamily="思源宋体" color="rgba(180, 192, 210, 1)">
|
||||
<p>季度出货量对比 · GROUPED / STACKED / 100% STACKED</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="160" height="22" topLeftX="770" topLeftY="26" type="text">
|
||||
<content textType="caption" fontSize="11" fontFamily="思源宋体" color="rgba(220, 228, 240, 1)" textAlign="right">
|
||||
<p>SECTION 02 / COLUMN</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="290" height="400" topLeftX="32" topLeftY="92" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
<border color="rgba(226, 232, 240, 1)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="290" height="32" topLeftX="32" topLeftY="92" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(240, 244, 249, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="270" height="22" topLeftX="44" topLeftY="98" type="text">
|
||||
<content fontSize="12" fontFamily="思源宋体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<p>① 分组柱状图 · Grouped</p>
|
||||
</content>
|
||||
</shape>
|
||||
@@ -101,16 +294,16 @@
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
<border color="rgba(226, 232, 240, 1)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="290" height="32" topLeftX="335" topLeftY="92" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(240, 244, 249, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="270" height="22" topLeftX="347" topLeftY="98" type="text">
|
||||
<content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<content fontSize="12" fontFamily="思源宋体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<p>② 堆叠柱状图 · Stacked</p>
|
||||
</content>
|
||||
</shape>
|
||||
@@ -156,16 +349,16 @@
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
<border color="rgba(226, 232, 240, 1)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="290" height="32" topLeftX="638" topLeftY="92" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(240, 244, 249, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="270" height="22" topLeftX="650" topLeftY="98" type="text">
|
||||
<content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<content fontSize="12" fontFamily="思源宋体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<p>③ 百分比堆叠 · 100% Stacked</p>
|
||||
</content>
|
||||
</shape>
|
||||
@@ -219,13 +412,13 @@
|
||||
<border color="rgba(226, 232, 240, 1)" width="1"/>
|
||||
</line>
|
||||
<shape width="500" height="18" topLeftX="32" topLeftY="512" type="text">
|
||||
<content textType="caption" fontSize="10" fontFamily="思源黑体" color="rgba(148, 163, 184, 1)">
|
||||
<content textType="caption" fontSize="10" fontFamily="思源宋体" color="rgba(148, 163, 184, 1)">
|
||||
<p>Source: Consulting Insights Research · 数据仅用于示意</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="128" height="18" topLeftX="800" topLeftY="512" type="text">
|
||||
<content textType="caption" fontSize="10" fontFamily="思源黑体" color="rgba(148, 163, 184, 1)" textAlign="right">
|
||||
<p>03 / 12</p>
|
||||
<content textType="caption" fontSize="10" fontFamily="思源宋体" color="rgba(148, 163, 184, 1)" textAlign="right">
|
||||
<p>02 / 08</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
@@ -244,27 +437,27 @@
|
||||
<fill>
|
||||
<fillColor color="rgba(15, 30, 58, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="8" height="72" topLeftX="0" topLeftY="0" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(240, 129, 54, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="700" height="30" topLeftX="32" topLeftY="14" type="text">
|
||||
<content textType="headline" fontSize="22" fontFamily="思源黑体" color="rgba(255, 255, 255, 1)" bold="true">
|
||||
<content textType="headline" fontSize="22" fontFamily="思源宋体" color="rgba(255, 255, 255, 1)" bold="true">
|
||||
<p>折线图 · Line Chart</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="700" height="20" topLeftX="32" topLeftY="46" type="text">
|
||||
<content textType="caption" fontSize="12" fontFamily="思源黑体" color="rgba(180, 192, 210, 1)">
|
||||
<content textType="caption" fontSize="12" fontFamily="思源宋体" color="rgba(180, 192, 210, 1)">
|
||||
<p>用户规模趋势 · STRAIGHT / SMOOTH / STEP</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="160" height="22" topLeftX="770" topLeftY="26" type="text">
|
||||
<content textType="caption" fontSize="11" fontFamily="思源黑体" color="rgba(220, 228, 240, 1)" textAlign="right">
|
||||
<p>SECTION 02 / LINE</p>
|
||||
<content textType="caption" fontSize="11" fontFamily="思源宋体" color="rgba(220, 228, 240, 1)" textAlign="right">
|
||||
<p>SECTION 03 / LINE</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="290" height="400" topLeftX="32" topLeftY="92" presetHandlers="0" type="rect">
|
||||
@@ -272,16 +465,16 @@
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
<border color="rgba(226, 232, 240, 1)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="290" height="32" topLeftX="32" topLeftY="92" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(240, 244, 249, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="270" height="22" topLeftX="44" topLeftY="98" type="text">
|
||||
<content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<content fontSize="12" fontFamily="思源宋体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<p>① 直线折线图 · Straight</p>
|
||||
</content>
|
||||
</shape>
|
||||
@@ -325,16 +518,16 @@
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
<border color="rgba(226, 232, 240, 1)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="290" height="32" topLeftX="335" topLeftY="92" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(240, 244, 249, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="270" height="22" topLeftX="347" topLeftY="98" type="text">
|
||||
<content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<content fontSize="12" fontFamily="思源宋体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<p>② 平滑折线图 · Smooth</p>
|
||||
</content>
|
||||
</shape>
|
||||
@@ -380,16 +573,16 @@
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
<border color="rgba(226, 232, 240, 1)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="290" height="32" topLeftX="638" topLeftY="92" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(240, 244, 249, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="270" height="22" topLeftX="650" topLeftY="98" type="text">
|
||||
<content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<content fontSize="12" fontFamily="思源宋体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<p>③ 阶梯折线图 · Step</p>
|
||||
</content>
|
||||
</shape>
|
||||
@@ -434,13 +627,13 @@
|
||||
<border color="rgba(226, 232, 240, 1)" width="1"/>
|
||||
</line>
|
||||
<shape width="500" height="18" topLeftX="32" topLeftY="512" type="text">
|
||||
<content textType="caption" fontSize="10" fontFamily="思源黑体" color="rgba(148, 163, 184, 1)">
|
||||
<content textType="caption" fontSize="10" fontFamily="思源宋体" color="rgba(148, 163, 184, 1)">
|
||||
<p>Source: Consulting Insights Research · 数据仅用于示意</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="128" height="18" topLeftX="800" topLeftY="512" type="text">
|
||||
<content textType="caption" fontSize="10" fontFamily="思源黑体" color="rgba(148, 163, 184, 1)" textAlign="right">
|
||||
<p>04 / 12</p>
|
||||
<content textType="caption" fontSize="10" fontFamily="思源宋体" color="rgba(148, 163, 184, 1)" textAlign="right">
|
||||
<p>03 / 08</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
@@ -459,27 +652,27 @@
|
||||
<fill>
|
||||
<fillColor color="rgba(15, 30, 58, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="8" height="72" topLeftX="0" topLeftY="0" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(240, 129, 54, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="700" height="30" topLeftX="32" topLeftY="14" type="text">
|
||||
<content textType="headline" fontSize="22" fontFamily="思源黑体" color="rgba(255, 255, 255, 1)" bold="true">
|
||||
<content textType="headline" fontSize="22" fontFamily="思源宋体" color="rgba(255, 255, 255, 1)" bold="true">
|
||||
<p>饼图 · Pie Chart</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="700" height="20" topLeftX="32" topLeftY="46" type="text">
|
||||
<content textType="caption" fontSize="12" fontFamily="思源黑体" color="rgba(180, 192, 210, 1)">
|
||||
<content textType="caption" fontSize="12" fontFamily="思源宋体" color="rgba(180, 192, 210, 1)">
|
||||
<p>市场份额结构 · PIE / DONUT</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="160" height="22" topLeftX="770" topLeftY="26" type="text">
|
||||
<content textType="caption" fontSize="11" fontFamily="思源黑体" color="rgba(220, 228, 240, 1)" textAlign="right">
|
||||
<p>SECTION 03 / PIE</p>
|
||||
<content textType="caption" fontSize="11" fontFamily="思源宋体" color="rgba(220, 228, 240, 1)" textAlign="right">
|
||||
<p>SECTION 04 / PIE</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="440" height="400" topLeftX="32" topLeftY="92" presetHandlers="0" type="rect">
|
||||
@@ -487,16 +680,16 @@
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
<border color="rgba(226, 232, 240, 1)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="440" height="32" topLeftX="32" topLeftY="92" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(240, 244, 249, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="420" height="22" topLeftX="44" topLeftY="98" type="text">
|
||||
<content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<content fontSize="12" fontFamily="思源宋体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<p>① 饼图 · Pie</p>
|
||||
</content>
|
||||
</shape>
|
||||
@@ -504,7 +697,7 @@
|
||||
<chartPlotArea>
|
||||
<chartPlot type="pie" yAxisPosition="right">
|
||||
<chartExtra/>
|
||||
<chartLabels position="outside" value="false" percentage="true" fontSize="10"/>
|
||||
<chartLabels position="inside" value="false" percentage="true" fontSize="10"/>
|
||||
</chartPlot>
|
||||
</chartPlotArea>
|
||||
<chartLegend position="right" fontSize="10"/>
|
||||
@@ -534,16 +727,16 @@
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
<border color="rgba(226, 232, 240, 1)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="440" height="32" topLeftX="488" topLeftY="92" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(240, 244, 249, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="420" height="22" topLeftX="500" topLeftY="98" type="text">
|
||||
<content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<content fontSize="12" fontFamily="思源宋体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<p>② 环形图 · Donut</p>
|
||||
</content>
|
||||
</shape>
|
||||
@@ -551,7 +744,7 @@
|
||||
<chartPlotArea>
|
||||
<chartPlot type="pie" yAxisPosition="right">
|
||||
<chartExtra/>
|
||||
<chartLabels position="outside" category="true" value="false" percentage="true" fontSize="10"/>
|
||||
<chartLabels position="inside" category="true" value="false" percentage="true" fontSize="10"/>
|
||||
<chartSeriesList>
|
||||
<chartSeries index="1">
|
||||
<chartSectors innerRadius="0.55" offsetRadius="0" startAngle="0"/>
|
||||
@@ -583,13 +776,13 @@
|
||||
<border color="rgba(226, 232, 240, 1)" width="1"/>
|
||||
</line>
|
||||
<shape width="500" height="18" topLeftX="32" topLeftY="512" type="text">
|
||||
<content textType="caption" fontSize="10" fontFamily="思源黑体" color="rgba(148, 163, 184, 1)">
|
||||
<content textType="caption" fontSize="10" fontFamily="思源宋体" color="rgba(148, 163, 184, 1)">
|
||||
<p>Source: Consulting Insights Research · 数据仅用于示意</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="128" height="18" topLeftX="800" topLeftY="512" type="text">
|
||||
<content textType="caption" fontSize="10" fontFamily="思源黑体" color="rgba(148, 163, 184, 1)" textAlign="right">
|
||||
<p>05 / 12</p>
|
||||
<content textType="caption" fontSize="10" fontFamily="思源宋体" color="rgba(148, 163, 184, 1)" textAlign="right">
|
||||
<p>04 / 08</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
@@ -608,27 +801,27 @@
|
||||
<fill>
|
||||
<fillColor color="rgba(15, 30, 58, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="8" height="72" topLeftX="0" topLeftY="0" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(240, 129, 54, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="700" height="30" topLeftX="32" topLeftY="14" type="text">
|
||||
<content textType="headline" fontSize="22" fontFamily="思源黑体" color="rgba(255, 255, 255, 1)" bold="true">
|
||||
<content textType="headline" fontSize="22" fontFamily="思源宋体" color="rgba(255, 255, 255, 1)" bold="true">
|
||||
<p>条形图 · Bar Chart</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="700" height="20" topLeftX="32" topLeftY="46" type="text">
|
||||
<content textType="caption" fontSize="12" fontFamily="思源黑体" color="rgba(180, 192, 210, 1)">
|
||||
<content textType="caption" fontSize="12" fontFamily="思源宋体" color="rgba(180, 192, 210, 1)">
|
||||
<p>横向排名对比 · GROUPED / STACKED / 100% STACKED</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="160" height="22" topLeftX="770" topLeftY="26" type="text">
|
||||
<content textType="caption" fontSize="11" fontFamily="思源黑体" color="rgba(220, 228, 240, 1)" textAlign="right">
|
||||
<p>SECTION 04 / BAR</p>
|
||||
<content textType="caption" fontSize="11" fontFamily="思源宋体" color="rgba(220, 228, 240, 1)" textAlign="right">
|
||||
<p>SECTION 05 / BAR</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="290" height="400" topLeftX="32" topLeftY="92" presetHandlers="0" type="rect">
|
||||
@@ -636,16 +829,16 @@
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
<border color="rgba(226, 232, 240, 1)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="290" height="32" topLeftX="32" topLeftY="92" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(240, 244, 249, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="270" height="22" topLeftX="44" topLeftY="98" type="text">
|
||||
<content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<content fontSize="12" fontFamily="思源宋体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<p>① 分组条形图 · Grouped</p>
|
||||
</content>
|
||||
</shape>
|
||||
@@ -689,16 +882,16 @@
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
<border color="rgba(226, 232, 240, 1)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="290" height="32" topLeftX="335" topLeftY="92" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(240, 244, 249, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="270" height="22" topLeftX="347" topLeftY="98" type="text">
|
||||
<content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<content fontSize="12" fontFamily="思源宋体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<p>② 堆叠条形图 · Stacked</p>
|
||||
</content>
|
||||
</shape>
|
||||
@@ -744,16 +937,16 @@
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
<border color="rgba(226, 232, 240, 1)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="290" height="32" topLeftX="638" topLeftY="92" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(240, 244, 249, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="270" height="22" topLeftX="650" topLeftY="98" type="text">
|
||||
<content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<content fontSize="12" fontFamily="思源宋体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<p>③ 百分比堆叠 · 100% Stacked</p>
|
||||
</content>
|
||||
</shape>
|
||||
@@ -807,13 +1000,13 @@
|
||||
<border color="rgba(226, 232, 240, 1)" width="1"/>
|
||||
</line>
|
||||
<shape width="500" height="18" topLeftX="32" topLeftY="512" type="text">
|
||||
<content textType="caption" fontSize="10" fontFamily="思源黑体" color="rgba(148, 163, 184, 1)">
|
||||
<content textType="caption" fontSize="10" fontFamily="思源宋体" color="rgba(148, 163, 184, 1)">
|
||||
<p>Source: Consulting Insights Research · 数据仅用于示意</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="128" height="18" topLeftX="800" topLeftY="512" type="text">
|
||||
<content textType="caption" fontSize="10" fontFamily="思源黑体" color="rgba(148, 163, 184, 1)" textAlign="right">
|
||||
<p>06 / 12</p>
|
||||
<content textType="caption" fontSize="10" fontFamily="思源宋体" color="rgba(148, 163, 184, 1)" textAlign="right">
|
||||
<p>05 / 08</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
@@ -832,27 +1025,27 @@
|
||||
<fill>
|
||||
<fillColor color="rgba(15, 30, 58, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="8" height="72" topLeftX="0" topLeftY="0" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(240, 129, 54, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="700" height="30" topLeftX="32" topLeftY="14" type="text">
|
||||
<content textType="headline" fontSize="22" fontFamily="思源黑体" color="rgba(255, 255, 255, 1)" bold="true">
|
||||
<content textType="headline" fontSize="22" fontFamily="思源宋体" color="rgba(255, 255, 255, 1)" bold="true">
|
||||
<p>面积图 · Area Chart</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="700" height="20" topLeftX="32" topLeftY="46" type="text">
|
||||
<content textType="caption" fontSize="12" fontFamily="思源黑体" color="rgba(180, 192, 210, 1)">
|
||||
<content textType="caption" fontSize="12" fontFamily="思源宋体" color="rgba(180, 192, 210, 1)">
|
||||
<p>体量与结构演进 · OVERLAY / STACKED / 100% STACKED</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="160" height="22" topLeftX="770" topLeftY="26" type="text">
|
||||
<content textType="caption" fontSize="11" fontFamily="思源黑体" color="rgba(220, 228, 240, 1)" textAlign="right">
|
||||
<p>SECTION 05 / AREA</p>
|
||||
<content textType="caption" fontSize="11" fontFamily="思源宋体" color="rgba(220, 228, 240, 1)" textAlign="right">
|
||||
<p>SECTION 06 / AREA</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="290" height="400" topLeftX="32" topLeftY="92" presetHandlers="0" type="rect">
|
||||
@@ -860,16 +1053,16 @@
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
<border color="rgba(226, 232, 240, 1)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="290" height="32" topLeftX="32" topLeftY="92" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(240, 244, 249, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="270" height="22" topLeftX="44" topLeftY="98" type="text">
|
||||
<content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<content fontSize="12" fontFamily="思源宋体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<p>① 重叠面积图 · Overlay</p>
|
||||
</content>
|
||||
</shape>
|
||||
@@ -913,16 +1106,16 @@
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
<border color="rgba(226, 232, 240, 1)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="290" height="32" topLeftX="335" topLeftY="92" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(240, 244, 249, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="270" height="22" topLeftX="347" topLeftY="98" type="text">
|
||||
<content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<content fontSize="12" fontFamily="思源宋体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<p>② 堆叠面积图 · Stacked</p>
|
||||
</content>
|
||||
</shape>
|
||||
@@ -968,16 +1161,16 @@
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
<border color="rgba(226, 232, 240, 1)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="290" height="32" topLeftX="638" topLeftY="92" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(240, 244, 249, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="270" height="22" topLeftX="650" topLeftY="98" type="text">
|
||||
<content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<content fontSize="12" fontFamily="思源宋体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<p>③ 百分比堆叠 · 100% Stacked</p>
|
||||
</content>
|
||||
</shape>
|
||||
@@ -1023,13 +1216,13 @@
|
||||
<border color="rgba(226, 232, 240, 1)" width="1"/>
|
||||
</line>
|
||||
<shape width="500" height="18" topLeftX="32" topLeftY="512" type="text">
|
||||
<content textType="caption" fontSize="10" fontFamily="思源黑体" color="rgba(148, 163, 184, 1)">
|
||||
<content textType="caption" fontSize="10" fontFamily="思源宋体" color="rgba(148, 163, 184, 1)">
|
||||
<p>Source: Consulting Insights Research · 数据仅用于示意</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="128" height="18" topLeftX="800" topLeftY="512" type="text">
|
||||
<content textType="caption" fontSize="10" fontFamily="思源黑体" color="rgba(148, 163, 184, 1)" textAlign="right">
|
||||
<p>07 / 12</p>
|
||||
<content textType="caption" fontSize="10" fontFamily="思源宋体" color="rgba(148, 163, 184, 1)" textAlign="right">
|
||||
<p>06 / 08</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
@@ -1048,26 +1241,26 @@
|
||||
<fill>
|
||||
<fillColor color="rgba(15, 30, 58, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="8" height="72" topLeftX="0" topLeftY="0" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(240, 129, 54, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="700" height="30" topLeftX="32" topLeftY="14" type="text">
|
||||
<content textType="headline" fontSize="22" fontFamily="思源黑体" color="rgba(255, 255, 255, 1)" bold="true">
|
||||
<content textType="headline" fontSize="22" fontFamily="思源宋体" color="rgba(255, 255, 255, 1)" bold="true">
|
||||
<p>组合图 · Combo Chart</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="700" height="20" topLeftX="32" topLeftY="46" type="text">
|
||||
<content textType="caption" fontSize="12" fontFamily="思源黑体" color="rgba(180, 192, 210, 1)">
|
||||
<content textType="caption" fontSize="12" fontFamily="思源宋体" color="rgba(180, 192, 210, 1)">
|
||||
<p>营收规模 & 增长率双轴视图 · COLUMN + LINE</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="160" height="22" topLeftX="770" topLeftY="26" type="text">
|
||||
<content textType="caption" fontSize="11" fontFamily="思源黑体" color="rgba(220, 228, 240, 1)" textAlign="right">
|
||||
<content textType="caption" fontSize="11" fontFamily="思源宋体" color="rgba(220, 228, 240, 1)" textAlign="right">
|
||||
<p>SECTION 07 / COMBO</p>
|
||||
</content>
|
||||
</shape>
|
||||
@@ -1076,16 +1269,16 @@
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
<border color="rgba(226, 232, 240, 1)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="576" height="32" topLeftX="32" topLeftY="92" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(240, 244, 249, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="560" height="22" topLeftX="44" topLeftY="98" type="text">
|
||||
<content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<content fontSize="12" fontFamily="思源宋体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<p>八季度营收与同比增速</p>
|
||||
</content>
|
||||
</shape>
|
||||
@@ -1137,26 +1330,26 @@
|
||||
<fill>
|
||||
<fillColor color="rgba(15, 30, 58, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="304" height="4" topLeftX="624" topLeftY="92" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(240, 129, 54, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="264" height="26" topLeftX="644" topLeftY="118" type="text">
|
||||
<content textType="caption" fontSize="12" fontFamily="思源黑体" color="rgba(240, 129, 54, 1)" bold="true">
|
||||
<content textType="caption" fontSize="12" fontFamily="思源宋体" color="rgba(240, 129, 54, 1)" bold="true">
|
||||
<p>KEY TAKEAWAY</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="264" height="90" topLeftX="644" topLeftY="146" type="text">
|
||||
<content textType="headline" fontSize="18" fontFamily="思源黑体" color="rgba(255, 255, 255, 1)" bold="true">
|
||||
<content textType="headline" fontSize="18" fontFamily="思源宋体" color="rgba(255, 255, 255, 1)" bold="true">
|
||||
<p>营收连续 6 季度双位数增长</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="264" height="80" topLeftX="644" topLeftY="248" type="text">
|
||||
<content fontSize="13" fontFamily="思源黑体" color="rgba(220, 228, 240, 1)">
|
||||
<content fontSize="13" fontFamily="思源宋体" color="rgba(220, 228, 240, 1)">
|
||||
<p>24Q4 - 25Q4 期间同比增速稳定在 18-23%,营收规模从 245 亿扩张至 296 亿美元。</p>
|
||||
</content>
|
||||
</shape>
|
||||
@@ -1164,12 +1357,12 @@
|
||||
<border color="rgba(240, 129, 54, 1)"/>
|
||||
</line>
|
||||
<shape width="264" height="26" topLeftX="644" topLeftY="360" type="text">
|
||||
<content textType="caption" fontSize="11" fontFamily="思源黑体" color="rgba(240, 129, 54, 1)" bold="true">
|
||||
<content textType="caption" fontSize="11" fontFamily="思源宋体" color="rgba(240, 129, 54, 1)" bold="true">
|
||||
<p>WHAT TO WATCH</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="264" height="90" topLeftX="644" topLeftY="386" type="text">
|
||||
<content fontSize="12" fontFamily="思源黑体" color="rgba(220, 228, 240, 1)">
|
||||
<content fontSize="12" fontFamily="思源宋体" color="rgba(220, 228, 240, 1)">
|
||||
<p>· 25Q4 增速首次微降 2pp</p>
|
||||
<p>· 高基数效应即将显现</p>
|
||||
<p>· 需关注亚太区库存周期</p>
|
||||
@@ -1179,13 +1372,13 @@
|
||||
<border color="rgba(226, 232, 240, 1)" width="1"/>
|
||||
</line>
|
||||
<shape width="500" height="18" topLeftX="32" topLeftY="512" type="text">
|
||||
<content textType="caption" fontSize="10" fontFamily="思源黑体" color="rgba(148, 163, 184, 1)">
|
||||
<content textType="caption" fontSize="10" fontFamily="思源宋体" color="rgba(148, 163, 184, 1)">
|
||||
<p>Source: Consulting Insights Research · 数据仅用于示意</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="128" height="18" topLeftX="800" topLeftY="512" type="text">
|
||||
<content textType="caption" fontSize="10" fontFamily="思源黑体" color="rgba(148, 163, 184, 1)" textAlign="right">
|
||||
<p>09 / 12</p>
|
||||
<content textType="caption" fontSize="10" fontFamily="思源宋体" color="rgba(148, 163, 184, 1)" textAlign="right">
|
||||
<p>07 / 08</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
@@ -1204,26 +1397,26 @@
|
||||
<fill>
|
||||
<fillColor color="rgba(15, 30, 58, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="8" height="72" topLeftX="0" topLeftY="0" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(240, 129, 54, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="700" height="30" topLeftX="32" topLeftY="14" type="text">
|
||||
<content textType="headline" fontSize="22" fontFamily="思源黑体" color="rgba(255, 255, 255, 1)" bold="true">
|
||||
<content textType="headline" fontSize="22" fontFamily="思源宋体" color="rgba(255, 255, 255, 1)" bold="true">
|
||||
<p>雷达图 · Radar Chart</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="700" height="20" topLeftX="32" topLeftY="46" type="text">
|
||||
<content textType="caption" fontSize="12" fontFamily="思源黑体" color="rgba(180, 192, 210, 1)">
|
||||
<content textType="caption" fontSize="12" fontFamily="思源宋体" color="rgba(180, 192, 210, 1)">
|
||||
<p>产品能力多维对比 · POLYGON / CIRCLE / OUTLINE</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="160" height="22" topLeftX="770" topLeftY="26" type="text">
|
||||
<content textType="caption" fontSize="11" fontFamily="思源黑体" color="rgba(220, 228, 240, 1)" textAlign="right">
|
||||
<content textType="caption" fontSize="11" fontFamily="思源宋体" color="rgba(220, 228, 240, 1)" textAlign="right">
|
||||
<p>SECTION 08 / RADAR</p>
|
||||
</content>
|
||||
</shape>
|
||||
@@ -1232,16 +1425,16 @@
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
<border color="rgba(226, 232, 240, 1)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="290" height="32" topLeftX="32" topLeftY="92" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(240, 244, 249, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="270" height="22" topLeftX="44" topLeftY="98" type="text">
|
||||
<content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<content fontSize="12" fontFamily="思源宋体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<p>① 多边形雷达 · Polygon</p>
|
||||
</content>
|
||||
</shape>
|
||||
@@ -1288,16 +1481,16 @@
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
<border color="rgba(226, 232, 240, 1)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="290" height="32" topLeftX="335" topLeftY="92" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(240, 244, 249, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="270" height="22" topLeftX="347" topLeftY="98" type="text">
|
||||
<content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<content fontSize="12" fontFamily="思源宋体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<p>② 圆形雷达 · Circle</p>
|
||||
</content>
|
||||
</shape>
|
||||
@@ -1345,16 +1538,16 @@
|
||||
<fillColor color="rgba(255, 255, 255, 1)"/>
|
||||
</fill>
|
||||
<border color="rgba(226, 232, 240, 1)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="290" height="32" topLeftX="638" topLeftY="92" presetHandlers="0" type="rect">
|
||||
<fill>
|
||||
<fillColor color="rgba(240, 244, 249, 1)"/>
|
||||
</fill>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
<content fontSize="16" fontFamily="思源宋体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="270" height="22" topLeftX="650" topLeftY="98" type="text">
|
||||
<content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<content fontSize="12" fontFamily="思源宋体" color="rgba(15, 30, 58, 1)" bold="true">
|
||||
<p>③ 无填充雷达 · Outline</p>
|
||||
</content>
|
||||
</shape>
|
||||
@@ -1399,13 +1592,13 @@
|
||||
<border color="rgba(226, 232, 240, 1)" width="1"/>
|
||||
</line>
|
||||
<shape width="500" height="18" topLeftX="32" topLeftY="512" type="text">
|
||||
<content textType="caption" fontSize="10" fontFamily="思源黑体" color="rgba(148, 163, 184, 1)">
|
||||
<content textType="caption" fontSize="10" fontFamily="思源宋体" color="rgba(148, 163, 184, 1)">
|
||||
<p>Source: Consulting Insights Research · 数据仅用于示意</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="128" height="18" topLeftX="800" topLeftY="512" type="text">
|
||||
<content textType="caption" fontSize="10" fontFamily="思源黑体" color="rgba(148, 163, 184, 1)" textAlign="right">
|
||||
<p>10 / 12</p>
|
||||
<content textType="caption" fontSize="10" fontFamily="思源宋体" color="rgba(148, 163, 184, 1)" textAlign="right">
|
||||
<p>08 / 08</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
|
||||
204
skills/lark-slides/references/tech-engineering.md
Normal file
204
skills/lark-slides/references/tech-engineering.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# Tech & Engineering
|
||||
|
||||
Scope: sharing and presenting technical solutions, system architecture reviews, AI / data platform proposals, security design reviews, production incident retrospectives, technology selection, testing and validation, API / SDK documentation, and product technical training, among others.
|
||||
|
||||
Readers may be fellow engineers, but they may also be product partners or customers. The goal is for readers to understand how the system works, judge whether the solution is good, and see the trade-offs, risks, and next steps clearly.
|
||||
|
||||
## 1. Core Character
|
||||
|
||||
**Medium-to-high information density, with emphasis on technical explanation and on processes and mechanisms.** Pages are built around architectures, flows, sequences, data, comparisons, and real evidence; text explains the why, while visuals reveal the relationships.
|
||||
|
||||
- **Professional**: no empty talk of "high availability" or "high performance." Write out the metrics, environment, boundaries, dependencies, trade-offs, failure conditions, and recovery paths.
|
||||
- **Concise**: a relationship one diagram can explain must not meander into an essay; nor may necessary assumptions, evidence, and limitations be omitted just to keep the layout clean.
|
||||
- **Concrete**: architecture diagrams must show call directions, data flow, control flow, and trust or failure boundaries; code, configuration, and logs appear only when they genuinely support the argument.
|
||||
- **Evidence first**: every page advances one judgment. The title gives the conclusion, the body gives the evidence, and it explains what the evidence means for the solution, the risks, or the next steps.
|
||||
- **Restrained**: the wow factor comes from making a complex problem clear — not from glow, particles, walls of cards, and hollow "tech vibes."
|
||||
|
||||
## 2. General Prohibitions
|
||||
|
||||
The following are red lines running through the entire deck; they take effect before all layout and visual rules, and unless the user explicitly requests otherwise, none may be violated.
|
||||
|
||||
- **No cards by default**: unless the user explicitly requests it, strictly forbid using rounded rectangles or rectangular cards to build hierarchy or alignment. Line segments, whitespace, and font-size/weight differences are better solutions.
|
||||
- **No evenly divided compositions**: unless no other layout is available, do not default to one-third splits, four-way splits, or 2×2 matrices — including formulaic patterns such as "title + three parallel blocks + conclusion."
|
||||
- **No mediocre, common, or AI-typical color schemes**: unless the user explicitly requests them, strictly forbid blue-and-white pairings, blue-purple gradients, cyan-purple neon, rainbow flares, glassmorphism cards, and glowing borders.
|
||||
- **No elements that clash with the overall style**: no styles from outside the chosen style may appear, such as using rounded icons or rounded rectangles within a sharp style.
|
||||
|
||||
## 3. How to Present Technical Content
|
||||
|
||||
### 3.1 Explain how the system works first
|
||||
|
||||
When first explaining a system or module, cover these first:
|
||||
|
||||
- inputs, outputs, and success criteria;
|
||||
- the scope of the system and its external dependencies;
|
||||
- the normal path, exception paths, and fallback paths;
|
||||
- where data comes from, what processing it goes through, and where it ends up;
|
||||
- which positions are the critical boundaries for performance, security, cost, or reliability.
|
||||
|
||||
### 3.2 Put design rationale and costs side by side
|
||||
|
||||
- Explain why A was chosen and B was not; the comparison dimensions must come from real goals.
|
||||
- Write out the costs corresponding to the benefits, including performance, cost, complexity, team capability, vendor lock-in, observability, and migration risk.
|
||||
- Give the conditions under which it works and the conditions under which it fails; do not present a local optimum as the universal optimum.
|
||||
- Distinguish current state, target state, candidate options, hypothetical inference, and verified results.
|
||||
- Launch plans specify validation gates, the gradual rollout method, rollback conditions, and recovery paths.
|
||||
|
||||
### 3.3 Evidence must be verifiable
|
||||
|
||||
- Metrics come with their source, time, version, test environment, load, sample, and measurement basis.
|
||||
- Show P50 / P95 / P99, variability, outliers, or confidence intervals as the question demands — not just averages.
|
||||
- Team inferences, illustrative data, and yet-to-be-verified assumptions must be explicitly labeled.
|
||||
- When evidence is missing, use "to be filled in," "assumption," or a placeholder; never fabricate benchmarks, logs, cases, or failure causes.
|
||||
- Explain terms and abbreviations at their first appearance, and keep naming consistent afterward.
|
||||
|
||||
## 4. Narrative Skeletons by Engineering Task
|
||||
|
||||
One primary scenario determines the whole deck's structure; other scenarios only supply necessary additions — never stack another visual language on top.
|
||||
|
||||
| Engineering task | What the reader must judge | Recommended narrative order |
|
||||
|---|---|---|
|
||||
| System architecture review | Whether boundaries, dependencies, bottlenecks, and the evolution path are sound | Goals and constraints → current state → problems → alternatives → target architecture → migration and validation |
|
||||
| AI / data platform proposal | Whether the data, models, and service chain are trustworthy; how quality, latency, and cost are traded off | User tasks → data and model pipeline → core mechanisms → evaluation evidence → risk guardrails → launch loop |
|
||||
| Security design review | Whether trust boundaries, attack paths, controls, and residual risk are clear | Assets and boundaries → threats → attack paths → controls → residual risk → monitoring and response |
|
||||
| Production incident retrospective | What the impact was, how the failure happened, and whether the fixes can prevent recurrence | Impact summary → timeline → direct cause → systemic factors → fixes → prevention validation |
|
||||
| Technology selection review | Which option better fits the goals, and which assumptions the conclusion is sensitive to | Goals and criteria → candidates → like-for-like comparison → trade-offs → recommendation → exit conditions |
|
||||
| Testing / validation strategy | Whether risks are covered, and whether test layers and release gates suffice | Risk model → layered validation → environment and data → gates → coverage gaps → release decision |
|
||||
| External tech talk | Why the core mechanism is valuable, whether the evidence is credible, and where the boundaries lie | Problem → mechanism → technical implementation → evidence → limitations → adoption path |
|
||||
|
||||
When no better structure fits, start from "decision summary → goals and constraints → current state / problems → mechanism / architecture → key evidence → alternatives and trade-offs → implementation / validation → risks and next steps," then trim and adapt to the scenario.
|
||||
|
||||
## 5. Page Rhythm and Information Density
|
||||
|
||||
- Default to medium-high density, but keep only one main judgment and one main evidence object per page.
|
||||
- Alternate text-explanation pages and visual-evidence pages; avoid consecutive stacks of structurally identical bullet lists.
|
||||
- For complex concepts, give the whole first, then expand layer by layer along stable coordinates; do not redraw the same architecture on every page.
|
||||
- Show the normal path, exception paths, and migration path in layers; keep the unchanged parts and highlight only what changes.
|
||||
- Accent pages prioritize key architectures, key comparisons, incident causality, or the final decision — do not manufacture climaxes with big-type slogans.
|
||||
- Section transitions appear only when a genuine change of rhythm is needed; short materials are not force-fitted with tables of contents and section covers.
|
||||
- End by returning to the decision, the validation results, or the next steps — never replace the conclusion with a lone "Thank you."
|
||||
|
||||
### Common page types
|
||||
|
||||
- **Concept / background page:** the necessary explanation paired with one main figure, whitespace spread around the main object.
|
||||
- **Architecture / flow page:** graphics dominate; text keeps only the conclusion, the legend, and necessary side notes.
|
||||
- **Comparison / selection page:** options compared on the same scale, dimensions, and coordinates.
|
||||
- **Metrics / benchmark page:** charts lead; test conditions, baselines, and conclusions stay close to the chart.
|
||||
- **Incident / timeline page:** chronological order is the main axis; impact, evidence, and handling stay close to their corresponding events.
|
||||
- **Implementation / migration page:** phases, dependencies, gates, and rollback points are laid out according to their real relationships.
|
||||
- **Appendix / sources page:** may be denser, but must stay scannable via columns, numbering, and stable line spacing.
|
||||
|
||||
## 6. Architecture Diagrams and Flowcharts
|
||||
|
||||
### 6.1 The relationship determines the graphic
|
||||
|
||||
| Relationship | Preferred graphic | Information that must be conveyed |
|
||||
|---|---|---|
|
||||
| System regions and boundaries | Nested or side-by-side right-angled regions | System scope, external dependencies, trust / failure boundaries |
|
||||
| Calls and data flow | Node chains or networks with directional arrows | The two parties of each call, protocol / data, sync or async |
|
||||
| Multi-role interaction | Sequence diagrams | Roles, message directions, waits, and exception branches |
|
||||
| Deployment and failure domains | Topology diagrams | Regions / clusters / instances, redundancy, and isolation scope |
|
||||
| State transitions | State machines | States, trigger conditions, transitions, and error states |
|
||||
| Option comparison | Side-by-side topologies or matrices at the same scale | Identical evaluation dimensions, structural differences, and costs |
|
||||
| Incident causality | Timeline + causal chain / fault tree | Events, evidence, direct causes, contributing factors, and control gaps |
|
||||
| Validation coverage | Layered matrices or mapping diagrams | Risks, test layers, pass criteria, and coverage gaps |
|
||||
|
||||
### 6.2 Diagramming discipline
|
||||
|
||||
- Arrows must have direction and meaning; label the protocol, data, frequency, capacity, or trigger conditions where necessary.
|
||||
- Distinguish data flow, control flow, and exception paths; redundant encoding with color, line style, and labels is allowed.
|
||||
- External boundaries, internal modules, and critical paths use different but stable grammars.
|
||||
- Connectors terminate at node edges and never cross through text; when there are too many crossings, rearrange, layer, or split the page.
|
||||
- Bold the critical paths or use the accent color; other paths recede to neutral; no glow and no 3D arrows.
|
||||
- For complex architectures, overview first, then zoom into details; keep naming, colors, and coordinate cues consistent.
|
||||
- Legends explain only the encodings that cannot be labeled in place; anything that can sit beside its node or path is labeled directly.
|
||||
|
||||
## 7. Data Charts, Tables, Code, and Screenshots
|
||||
|
||||
### Data charts
|
||||
|
||||
- Trends use lines, comparisons use bars or side-by-side dot plots, composition uses stacks, latency distributions use histograms / box plots / quantile plots, and capacity-cost relationships may use scatter plots or sensitivity matrices.
|
||||
- Remove default frames, 3D effects, gradients, heavy gridlines, and meaningless legends.
|
||||
- Main series use the structural color or critical-path color; baselines and secondary series use grayscale.
|
||||
- Key values, inflection points, and anomalies are labeled directly, with the "why" and the "what it means."
|
||||
- Test environment, version, load, sample, units, and baseline appear alongside the chart.
|
||||
|
||||
### Tables
|
||||
|
||||
- Headers, grouping, status, numbers, and notes form clear hierarchy; default themes are not kept.
|
||||
- Notes left-aligned, numbers aligned by decimal point or unit, and status columns fixed in position.
|
||||
- Use thin separators and light group backgrounds; no thick outer frames, rainbow headers, or large red/green fills.
|
||||
- Options are compared on the same dimensions; missing and non-comparable items are explicitly marked.
|
||||
|
||||
### Code, configuration, and screenshots
|
||||
|
||||
- Keep only the minimal excerpt that supports the argument, and first tell the reader what to look at.
|
||||
- Code, interfaces, paths, and fields use monospaced fonts; syntax highlighting only accentuates the key lines or changes.
|
||||
- For configuration comparison, prefer a minimal diff or a key-fields table; logs are timestamped, identify the source component, and have sensitive data redacted or anonymized.
|
||||
- Crop screenshots tightly to the evidence boundary, keeping them right-angled rectangles; do not uniformly add device frames, rounded corners, or shadows.
|
||||
- Side-by-side screenshots keep equal height and width, identical zoom, and the same baseline.
|
||||
|
||||
## 8. Visual System
|
||||
|
||||
### 8.1 Layout
|
||||
|
||||
- A title conveys the conclusion in one sentence, and its visual weight must not exceed the main evidence.
|
||||
- Use stable safe margins and a title axis; ordinary pages prefer a single column, a left-right split, or text above and figure below.
|
||||
- Architecture nodes land on a regular grid; pages of the same kind keep the same skeleton.
|
||||
- Body content is organized by whitespace, alignment, and thin rules by default; cards are used only when content needs delimiting.
|
||||
- Panels are used only for system scope, code, configuration, or key conclusions — mostly right-angled or minimally rounded, with thin strokes and light fills, and no generic shadows.
|
||||
- Reading-type materials may keep the document name, section, page number, version, and confidentiality level; live-presented materials keep only the necessary identity information.
|
||||
|
||||
### 8.2 Colors and fonts
|
||||
|
||||
- Reviews, printing, and long-form reading usually use light backgrounds; dark-room launches may use a restrained dark background.
|
||||
- When the brand has existing visual assets, inherit them first; without brand constraints, build the palette along the lines of "background color, structural color, critical-path color, status colors, neutrals."
|
||||
- Each color carries a stable meaning throughout the deck; critical states are additionally distinguished by text, line style, or shape.
|
||||
- Titles and body text prefer highly legible sans-serif faces; when a document feel is needed, titles may use a restrained serif.
|
||||
- Code uses monospaced fonts; one deck usually uses no more than two body-type families.
|
||||
- When content grows, first condense the text, split pages, or adjust the text-to-graphic ratio — only then shrink font size slightly.
|
||||
|
||||
### 8.3 Lines, icons, and effects
|
||||
|
||||
- Ordinary borders and auxiliary lines are thin and even; only critical paths get visibly bolder.
|
||||
- Within one diagram, arrows, corners, endpoints, dash rhythms, and corner radii stay consistent.
|
||||
- Technical structures prefer right angles or minimal rounding; circles and curves appear only when semantics require them.
|
||||
- Architecture, flows, data, screenshots, and real product visuals take precedence over decorative illustration.
|
||||
- Icons must come from one stroke system and serve identification; do not mix emoji, 3D, realistic, and system-default icons.
|
||||
- Body surfaces stay clean: no noise, particles, grid-light effects, glassmorphism, or meaningless gradients.
|
||||
|
||||
## 9. Sample References
|
||||
|
||||
Samples are only for extracting mechanisms; wholesale replication is forbidden. Organize the Style first according to the current readers, evidence, brand, and usage; precise color values, fonts, covers, section pages, and footers are not inherited by default.
|
||||
|
||||
### Sample A: Warm Orange & Cool Blue — White-Base Engineering Document Style
|
||||
|
||||
- **Traits:** white background with black body text; the warm color carries titles and critical paths, the cool color carries nodes and structure; fixed header and page numbers; suited to reading and printing.
|
||||
- **Borrowable:** the warm/cool semantic division, a stable document axis, reuse of architecture coordinates, and hierarchical distinction through boundaries / nodes / paths.
|
||||
- **Good for reference:** architecture reviews, technical white papers, platform proposals, and materials that need to unfold the same architecture step by step.
|
||||
- **Not inherited automatically:** the orange and blue color values, Inter, the gradient-arc cover, orange table headers, and the fixed header position.
|
||||
|
||||
### Sample B: Four-Color Flat — Engineering Walkthrough Style
|
||||
|
||||
- **Traits:** white background with dark-gray text, a few high-saturation semantic colors, flat structural diagrams, and solid-color section pages; suited to live walkthroughs and cross-role communication.
|
||||
- **Borrowable:** semantic color coding, solid-color section pauses, text/graphic zoning, direct labeling, and equal-width comparison columns.
|
||||
- **Good for reference:** product technical training, role collaboration, and talks balancing concepts and flows.
|
||||
- **Not inherited automatically:** the fixed four colors, solid-per-chapter backgrounds, the dual-ended footer, and specific brand fonts.
|
||||
|
||||
### Sample C: White Base & Deep Ink Blue — Two-Color Short-Line Engineering Walkthrough
|
||||
|
||||
- **Traits:** deep ink-blue titles, short colored lines, and a white base forming a stable walkthrough skeleton; explanation pages alternate with evidence pages; visuals float directly on the white ground.
|
||||
- **Borrowable:** the fixed title axis, weak navigation, alternation of explanation and evidence, and reduced container noise.
|
||||
- **Good for reference:** live tech talks, building concepts step by step, and materials centered on screenshots and mechanism diagrams.
|
||||
- **Not inherited automatically:** the orange/green section colors, diagonal section color blocks, the deep-purple ending, the fixed short-line sizes, and the font combination.
|
||||
|
||||
Do not write "choose Sample A / B / C" in the plan. If the result still clearly looks like a direct replica of some Sample once the text is hidden, keep adjusting — but do not break relationship expression and consistency just to be novel.
|
||||
|
||||
## 10. Pre-Delivery Checklist
|
||||
|
||||
- Are inputs, outputs, dependencies, boundaries, the normal path, exception paths, and fallback paths clear?
|
||||
- Do key connectors have direction and meaning, and are current state, target state, assumptions, and verified results distinguished?
|
||||
- Do metrics carry environment, version, sample, load, units, baseline, and time window?
|
||||
- Does each page carry only one main judgment, and does the evidence truly support the title?
|
||||
- Does the visual weight fall on the evidence, and are colors, shapes, line styles, and naming consistent?
|
||||
- Are there unsourced data, default Office charts, walls of cards, decorative "tech vibes," or Sample replicas?
|
||||
- Was technical evidence altered, weakened, or falsified for the sake of visual unity?
|
||||
@@ -19,7 +19,7 @@
|
||||
2. 用 `slides +xml-get` 回读,确认是否已有部分页面写入。
|
||||
3. 检查失败页是否含未转义字符:`Q&A -> Q&A`,文本 `<` / `>` 写成 `<` / `>`,属性 URL `a=1&b=2 -> a=1&b=2`。
|
||||
4. 检查标签闭合、属性引号、`<content>` 结构,以及 `<slide>` 直接子元素。
|
||||
5. 页面空白、溢出、重叠或越界时,按 [validation-checklist.md](validation-checklist.md) 运行 XML 文本重叠检查,并人工核对越界、截断、图文压盖等视觉风险;工具当前只会报告 `xml_not_well_formed` / `bbox_overlap`。
|
||||
5. 页面空白、溢出、重叠或越界时,按 [validation-checklist.md](validation-checklist.md) 运行 XML 文本重叠检查,或者截图做视觉检查。
|
||||
6. 如果使用 `--slides '[...]'`,怀疑 shell 截断时直接切到两步创建:先 `slides +create`,再用 `xml_presentation.slide.create` 逐页添加。
|
||||
7. 局部问题用 `+replace-slide` 块级修正;整页结构要改时再用 `slide.delete` 旧页 + `slide.create` 新页。
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
| 看到的问题 | 处理方式 |
|
||||
|-----------|----------|
|
||||
| 文字被截断 / 看不全 | 增大 shape 的 `width` 或 `height`,或减少文本量 |
|
||||
| 文字被截断 / 看不全 | 增大 shape 的 `width` 或 `height`,或减少文本量,或设置 `wrap="true" autoFit="normal-auto-fit"` 属性自动换行和缩排 |
|
||||
| 元素重叠 | 调整 `topLeftX` / `topLeftY`,拉开间距 |
|
||||
| 页面大面积空白 | 回读确认内容是否写入;若内容存在,再缩小间距或增加主体元素 |
|
||||
| 文字和背景色太接近 | 深色背景用浅色文字,浅色背景用深色文字 |
|
||||
|
||||
@@ -6,15 +6,14 @@
|
||||
|
||||
## Required Flow
|
||||
|
||||
1. 记录创建或编辑返回的 `xml_presentation_id`,以及已知的 `slide_id` / `revision_id`。
|
||||
2. 用 `slides +xml-get` 回读全文 XML 到本地文件。
|
||||
3. 检查实际页数是否符合计划或用户要求。
|
||||
4. 检查每页 `<data>` 内是否有预期主要元素。
|
||||
5. 检查没有明显空白页、破损页、缺失标题或缺失主视觉。
|
||||
6. 检查页面不是全部退化为标题加 bullet list。
|
||||
7. 检查视觉层级:标题、主视觉、支撑信息三者可区分。
|
||||
8. 检查明显溢出和布局风险:重叠、越界、底部拥挤、长文本框。
|
||||
9. 在最终回复中给出简短验证记录。
|
||||
1. 记录创建或编辑返回的 `xml_presentation_id`,以及已知的 `slide_id` / `revision_id`。`slide_id` 是 review 状态唯一关联键;页码仅可作为展示信息。
|
||||
2. 用 `slides +xml-get` 回读全文 XML 到本地文件,并以当前结果建立本次 review 的 `slide_ids` 页清单。首次新建且页集合未变时,可复用创建响应;增删页、整页替换或重排后必须刷新清单。
|
||||
3. 运行 XML 静态检查,检查实际页数、主要元素、空白/破损页、主视觉和布局风险。
|
||||
4. 先在 `.lark-slides/review/<deck-or-task-id>/visual-review.md` 为全部 `slide_ids` 建立记录,初始状态均为 `not_reviewed`;静态检查通过后再用 `slides +screenshot` 截图。每批最多 10 页,输出到 `.lark-slides/review/<deck-or-task-id>/screenshots/`。
|
||||
5. 实际打开每张截图,按下方 rubric 逐页标记 `pass` 或 `fix`;截图文件存在但未被查看时,状态必须保留为 `not_reviewed`。**关键页抽查只可作为排障/预览,不能缩小本次 review 页清单,也不能支持“全部通过”的结论。**
|
||||
6. `fix` 页用 `+replace-slide` 或对应写入操作修复后,重新回读并重新截图该页;不要沿用修复前的截图结论。
|
||||
7. 截图白名单或服务端限制导致无法获取图片时,记录错误和受影响页,完成其余 XML 静态检查,并将视觉状态标记为 `not_verified`。
|
||||
8. 在最终回复中给出简短验证记录,明确区分静态检查和真实视觉 review。
|
||||
|
||||
回读命令:
|
||||
|
||||
@@ -27,30 +26,63 @@ lark-cli slides +xml-get --as user \
|
||||
|
||||
## Automated XML Text Overlap Lint
|
||||
|
||||
`slides +xml-get` 保存 XML 到本地文件后,优先运行 XML 语法和文本重叠静态检查:
|
||||
`slides +xml-get` 保存 XML 到本地文件后,必须运行 XML 语法和文本重叠静态检查;输入可以是单个 `<slide>` 或完整 `<presentation>`。
|
||||
|
||||
先取得当前已加载 `lark-slides/SKILL.md` 的父目录,记为 `<lark-slides-skill-dir>`;不要猜测全局安装路径。下面命令中的脚本路径相对于该目录。
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentation.xml>
|
||||
python3 "<lark-slides-skill-dir>/scripts/xml_text_overlap_lint.py" --input <presentation.xml>
|
||||
```
|
||||
|
||||
通过标准:
|
||||
|
||||
- `summary.error_count == 0`。任何 error 都必须先修复再交付。
|
||||
- 当前工具只检查 XML well-formed 和文本元素之间的明显重叠;它不检查越界、文本高度不足、图文压盖、表格/图表压盖或底部拥挤。
|
||||
- 工具会检查 XML well-formed 与 schema 合法性、文本元素重叠、元素越界画布、文本框可能溢出、表格声明尺寸与实际不一致、以及 icon 填充配置。
|
||||
- 该工具不能替代页数核对、关键内容核对或真实视觉验收。
|
||||
|
||||
## Automated Layout Density Lint
|
||||
|
||||
在 XML 结构检查通过后、截图视觉验收前,可运行布局密度静态检查:
|
||||
|
||||
```bash
|
||||
python3 "<lark-slides-skill-dir>/scripts/xml_layout_density_lint.py" --input <presentation.xml>
|
||||
```
|
||||
|
||||
它的用途是找出大型 `rect` 容器内的可见内容面积偏小的候选区域,常见于大卡片只放少量文字、空图片占位卡、单字符伪主视觉或目录卡内容过空。
|
||||
|
||||
调用顺序:
|
||||
|
||||
```text
|
||||
slides +xml-get 回读 XML
|
||||
→ xml_text_overlap_lint.py 检查 XML / 重叠等结构风险
|
||||
→ xml_layout_density_lint.py 输出内容覆盖率事实
|
||||
→ 截图 QA 判断留白是否有意设计
|
||||
```
|
||||
|
||||
`xml_layout_density_lint.py` 的 warning 只陈述可复算的几何事实:
|
||||
|
||||
- `target`:页码、容器 ID、坐标和尺寸;
|
||||
- `rule`:阈值与比较条件;
|
||||
- `measurement`:容器面积、可见内容面积、覆盖率和参与元素数量;
|
||||
- `elements`:容器及参与计算的 XML 元素 ID。
|
||||
|
||||
当 `measurement.content_coverage_ratio < rule.threshold` 时输出 `code = sparse_container_content`。这只是静态几何命中,不自动说明页面难看、留白错误或必须修改;必须结合同页截图进行视觉判断。
|
||||
|
||||
常见 code 的处理方向:
|
||||
|
||||
| code | 含义 | 处理方式 |
|
||||
|------|------|----------|
|
||||
| `xml_not_well_formed` | XML 语法错误或文本未转义 | 修复标签闭合、属性引号、`&` / `<` / `>` 转义 |
|
||||
| `sml_prefixed_tag` | SML 元素使用了命名空间前缀,如 `<ns0:slide>` 或 `<sml:shape>` | 使用 `<slide xmlns="http://www.larkoffice.com/sml/2.0">` 的默认命名空间,或使用无前缀标签 |
|
||||
| `sxsd_unsupported_tag` | 使用了 SXSD 不支持的标签 | 按 lint `hint` 替换为受支持标签;常见如 `textbox -> <shape type="text">`、`image -> <img>` |
|
||||
| `sxsd_unsupported_attr` | 支持的标签上使用了不支持的属性 | 按 lint `hint` 改为支持的属性;常见如 `x -> topLeftX`、`fontColor -> color` |
|
||||
| `iconpark_unsupported_icon_type` | `<icon>` 使用了 `iconpark-index.json` 中不存在的 `iconType` | 按 lint `hint` 改为名单内的 `iconType`,或先用 `scripts/iconpark_tool.py` 搜索 |
|
||||
| `icon_missing_fill_color` | 视觉规范要求 `<icon>` 设置 `<fill><fillColor color="..."/></fill>`,避免图标不可见 | 给 `<icon>` 添加显式非透明填充色,例如 `rgba(37, 99, 235, 1)` |
|
||||
| `icon_transparent_fill_color` | `<icon>` 的 `fillColor` 是透明色,不满足视觉可见性要求 | 改成与背景有足够对比的非透明颜色 |
|
||||
| `bbox_overlap` | 文本元素的估算绘制区域明显重叠 | 拉开文本坐标、缩小文本框/字号,或改成明确的分栏/分组结构 |
|
||||
| `sml_prefixed_tag` | SML 标签用了命名空间前缀(如 `sml:`) | 去掉前缀,用规范标签名 |
|
||||
| `sxsd_unsupported_tag` | 使用了 schema 不支持的标签 | 对照 `slides_xml_schema_definition.xml` 换成受支持的标签 |
|
||||
| `sxsd_unsupported_attr` | 标签上有 schema 不支持的属性 | 删除该属性或改用受支持的属性 |
|
||||
| `<kind>_out_of_canvas`(如 `text_out_of_canvas`) | 元素超出 960×540 画布 | 移回画布内,或缩小其 width/height |
|
||||
| `text_may_overflow_shape` | 文本按字号/行距估算会超出自身文本框 | 增大 shape 高度、精简文字,或给 `<content>` 设 `wrap="true" autoFit="normal-auto-fit"` |
|
||||
| `table_resolved_size_mismatch` | `<table>` 声明的 width/height 与 `<col>`/`<tr>` 解析出的实际总尺寸不一致 | 调整 col/tr 或表格整体尺寸使两者匹配 |
|
||||
| `icon_missing_fill_color` | `<icon>` 未设置不透明 `fillColor` | 在 `<icon>` 内加 `<fill><fillColor color="rgba(R,G,B,1)"/></fill>` |
|
||||
| `icon_transparent_fill_color` | `<icon>` 的 `fillColor` 是透明色 | 改用不透明颜色 |
|
||||
| `iconpark_unsupported_icon_type` | 用了 IconPark 不支持的 `iconType` | 对照 `iconpark-index.json` 换成受支持的类型 |
|
||||
|
||||
## Screenshot QA
|
||||
|
||||
@@ -119,11 +151,42 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
|
||||
|
||||
- 正文或标签框高度不足,文本很可能被截断。
|
||||
- 多个主体元素在同一区域重叠,而不是有意叠加背景。
|
||||
- 标题、标签、关键数字或相邻文本虽未几何重叠,但视觉间距过近,显得粘连、像重叠或破坏层级。
|
||||
- 重要内容越过画布边界,或贴近底部超过 `y=500`。
|
||||
- 高密度页使用单个长 bullet list,没有分栏、表格或分组。
|
||||
- 标题、主视觉、正文的字号和颜色差异太弱,视觉层级不清。
|
||||
- 所有内容页都是同一套标题加 bullets 坐标。
|
||||
|
||||
## Screenshot Visual Review
|
||||
|
||||
截图 review 是静态 XML 检查之后的第二道门。它用服务端真实渲染结果发现 XML 无法可靠判断的问题,例如文字截断、图片裁切、图表压盖和弱对比。
|
||||
|
||||
每页按以下检查项记录结论;页面含图表时,额外检查图表精确可读性:
|
||||
|
||||
| 项目 | Pass 标准 | Fix 信号 |
|
||||
|---|---|---|
|
||||
| 可读性 | 标题、正文、标签和关键数字可读;对比度足够,文本层级之间有清楚的视觉间距 | 文字截断、字号过小、低对比、关键标签不可读,或相邻文字间距过近而视觉粘连 |
|
||||
| 布局 | 主体未被意外遮挡,页边距和底部留白合理 | 重叠、越界、图片裁切、元素贴边、底部拥挤,或文字虽未相交但视觉上像碰撞 |
|
||||
| 视觉层级 | 主结论、主视觉、支撑信息一眼可区分 | 所有元素同权重、主视觉过小、页面退化为文字堆叠 |
|
||||
| 内容完整性 | 无空白、破图、占位符或错误页序;图示表达与页面角色匹配 | 空白/破损页、缺失图片、遗留模板文案或与计划不符 |
|
||||
| 图表精确可读性(有图表时) | 若页面结论依赖精确比较、排序或阈值判断,读者可直接获得每个关键数据点的值:柱/线/饼图有直接数据标签,或有与图表一一对应的等价数据表/注释 | 只能靠坐标轴估读关键数值、缺少决定结论的数据标签、图例与系列无法对应;仅用于展示趋势且不承载精确结论的图表可不强制逐点标签 |
|
||||
|
||||
图表检查先问“页面是否要求读者作精确判断”:
|
||||
|
||||
- **需要**:比较群体得分、排名、是否达到阈值、预算/目标差异、需要从图中选方案。没有直接数值或等价数据表即为 `fix`。
|
||||
- **不需要**:只表达上升/下降趋势、定性分布或结构关系,且标题/正文已经明确结论;可不逐点展示数值,但仍须检查轴、图例、系列和关键标注是否可读。
|
||||
|
||||
推荐把记录保存在 `.lark-slides/review/<deck-or-task-id>/visual-review.md`:
|
||||
|
||||
```text
|
||||
| slide_id | screenshot | status | findings | action |
|
||||
|---|---|---|---|---|
|
||||
| p001 | screenshots/p001.png | pass | hierarchy and contrast clear | - |
|
||||
| p002 | screenshots/p002.png | fix | bottom labels are clipped | enlarge text box, then rescreenshot |
|
||||
```
|
||||
|
||||
只有记录中的每个目标 `slide_id` 都是 `pass`,且记录数等于当前页清单数,才可写“已完成视觉 review”。截图不可用时沿用上文的 `not_verified` 状态,并说明原因。
|
||||
|
||||
## Verification Record
|
||||
|
||||
最终回复必须包含简短验证记录,建议格式:
|
||||
@@ -133,7 +196,8 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
|
||||
- 回读:已执行 slides +xml-get,实际页数 N / 预期 N。
|
||||
- 关键页:架构解释 / Self-Attention / 对比或演进 / 总结页均存在。
|
||||
- 结构:检查了主要 shape/img/table/chart 元素,无明显空白页或破损页。
|
||||
- 布局:检查了标题层级、主视觉、重叠/越界/文本溢出风险。
|
||||
- 静态检查:xml_text_overlap_lint error_count=0;已检查标题层级、主视觉、重叠/越界/文本溢出风险。
|
||||
- 视觉 review:已查看 N/N 张服务端截图,全部 pass;或 `not_verified`(截图不可用,原因:...)。
|
||||
```
|
||||
|
||||
不要声称完成了人工视觉验收,除非确实打开或获取了可视化结果。仅从 XML 静态检查得出的结论,应表述为“静态检查未发现明显问题”。
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
- `medium`: title plus 2-4 concise bullets or labeled regions.
|
||||
- `high`: use a table, columns, grouped labels, or annotations. Do not use one long bullet box.
|
||||
- Do not create a deck where every content page is title plus bullets. For 4 or more pages, use at least 4 different layout structures when the content allows.
|
||||
- Keep safe outer margins around `40` px on standard content pages, and fill the content area densely with a card grid rather than leaving large empty space. Only go full-bleed for an intentional image or cover treatment.
|
||||
- Keep safe outer margins around `40` px on standard content pages. Only go full-bleed for an intentional image or cover treatment. How densely to fill the content area, and whether to use a card grid, is set by the selected design system.
|
||||
- Reserve vertical space for titles. A typical content title area is `y=36..90`; main content should usually start at `y>=110`.
|
||||
- Avoid crowding the bottom edge. Keep non-background content above `y=500` unless it is a footer.
|
||||
- Keep backgrounds consistent with the deck's `visual_system.background_strategy`. Normal content pages should use the same base background unless there is a clear page-role reason to change.
|
||||
@@ -148,7 +148,7 @@ Purpose: show sequence, roadmap, history, or phases.
|
||||
|
||||
Geometry:
|
||||
- Create a horizontal or vertical spine with 3-6 milestones.
|
||||
- Each milestone should have a dot/card/date label connected by a line or arrow.
|
||||
- Each milestone should have a dot/card/date label connected by a line or arrow. Cards should be of equal size.
|
||||
- Title is separate from the sequence. The sequence is the visual focus.
|
||||
|
||||
Text:
|
||||
@@ -172,7 +172,7 @@ Text:
|
||||
|
||||
Purpose: explain components, dependencies, or system flow.
|
||||
|
||||
Implementation: use `<shape>` + `<line>`.
|
||||
Implementation: use `<shape>` + `<line>`. Control position and size precisely and carefully.
|
||||
|
||||
Geometry:
|
||||
- Main visual area should be a diagram, not prose.
|
||||
@@ -188,17 +188,34 @@ Text:
|
||||
|
||||
Purpose: show operational steps, workflow, or cause-effect path.
|
||||
|
||||
Implementation: use `<shape>` + `<line>`.
|
||||
Implementation: use `<shape>` + `<line>`. Control position and size precisely and carefully.
|
||||
|
||||
Geometry:
|
||||
- Use numbered steps connected by arrows or lines.
|
||||
- 3-5 steps is ideal for one slide. If there are more, group them into phases.
|
||||
- 3-5 steps is ideal for one slide. If there are more, group them into phases. Steps should be of equal size.
|
||||
- The flow direction must be visually obvious.
|
||||
|
||||
Text:
|
||||
- Each step gets a verb-led label and one short descriptor at most.
|
||||
- Step labels should be parallel in length and grammar. If one step needs a long explanation, move the explanation to a side note or speaker notes.
|
||||
|
||||
### `relationship-network`
|
||||
|
||||
Purpose: show entities and the ties, relationships, or influence that connect them.
|
||||
|
||||
Implementation: use `<shape>` + `<line>`. Use small `ellipse` dots as nodes, not large ones, and never put text inside them. Control position and size precisely and carefully.
|
||||
|
||||
Geometry:
|
||||
- Main visual area should be a web of nodes and connectors, not prose.
|
||||
- Keep each node a small dot (a marker, not a container); do not size it to fit text inside. Small dots leave room for many nodes, so a dense, complex network still stays readable.
|
||||
- Spread nodes to fill the canvas evenly; minimize line crossings and avoid clusters.
|
||||
- Encode relationship types with line style (e.g. solid vs dashed) and emphasize key nodes with a distinct color or slightly larger dot. Add a legend to decode styles.
|
||||
|
||||
Text:
|
||||
- Keep labels decoupled from nodes: put each name in its own text element beside the dot, never inside it and never on top of a connector.
|
||||
- Give each node a short name label (1-3 words). Relationship labels ride along their line and stay to 2-4 words; use them sparingly.
|
||||
- Keep labels from overlapping connectors or one another. Use one legend plus at most one short caption for explanation.
|
||||
|
||||
### `quote-highlight`
|
||||
|
||||
Purpose: emphasize a customer voice, principle, thesis, or decision statement.
|
||||
@@ -218,11 +235,10 @@ Purpose: close with decision, recommendation, or next action.
|
||||
Geometry:
|
||||
- Use one dominant closing statement or call to action.
|
||||
- Visual focus should be the recommendation or action, not decorative filler.
|
||||
- When using a full-bleed background image, add a semi-transparent scrim between the image and the text so the text stays legible; verify contrast.
|
||||
- Mirror the cover background but omit the image.
|
||||
|
||||
Text:
|
||||
- Keep the final page easy to remember. Avoid recap overload.
|
||||
- Conclusion pages may mirror the cover background.
|
||||
|
||||
## Screenshot And Paper Figure Pages
|
||||
|
||||
|
||||
@@ -246,7 +246,7 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
|
||||
表格宽高设置:
|
||||
|
||||
- 已设置的列宽和行高优先保留,未设置的列宽、行高会使用表格的目标总宽度、总高度分配剩余空间
|
||||
- **必须设置 `<table>` 的 `width` 和 `height` 固定表格大小,同时设置需要保留列宽或行高的 `<col>` 的 `width` 和 `<tr>` 的 `height`,其余自动分配。**
|
||||
- **`<table>` 必须设置 `width` 和 `height` 固定整体表格大小,行高列宽建议默认分配,只设置少数必要的 `<col>` 的 `width` 和 `<tr>` 的 `height`。**
|
||||
|
||||
不同字号的行高参考:
|
||||
|
||||
@@ -365,8 +365,8 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
|
||||
<title>季度报告</title>
|
||||
<theme>
|
||||
<textStyles>
|
||||
<title fontFamily="思源黑体" fontSize="54" fontColor="rgba(0, 0, 0, 1)"/>
|
||||
<body fontFamily="思源黑体" fontSize="18" fontColor="rgba(43, 47, 54, 1)"/>
|
||||
<title fontFamily="思源宋体" fontSize="54" fontColor="rgba(0, 0, 0, 1)"/>
|
||||
<body fontFamily="思源宋体" fontSize="18" fontColor="rgba(43, 47, 54, 1)"/>
|
||||
</textStyles>
|
||||
</theme>
|
||||
<slide>
|
||||
|
||||
419
skills/lark-slides/scripts/xml_layout_density_lint.py
Normal file
419
skills/lark-slides/scripts/xml_layout_density_lint.py
Normal file
@@ -0,0 +1,419 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
# SPDX-License-Identifier: MIT
|
||||
"""Warn when a large layout container has too little visible content inside it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from xml.etree import ElementTree as ET
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import xml_text_overlap_lint as xml_lint
|
||||
|
||||
|
||||
MIN_CONTAINER_WIDTH = 140
|
||||
MIN_CONTAINER_HEIGHT = 160
|
||||
MIN_SHORT_CARD_HEIGHT = 80
|
||||
MIN_CONTAINER_AREA = 20_000
|
||||
MIN_CONTENT_COVERAGE_RATIO = 0.15
|
||||
MIN_SLIDE_CONTENT_COVERAGE_RATIO = 0.035
|
||||
MIN_SLIDE_CONTENT_ELEMENT_COUNT = 4
|
||||
SHORT_CARD_SIZE_TOLERANCE_RATIO = 0.10
|
||||
MIN_SIMILAR_SHORT_CARD_COUNT = 2
|
||||
LARGE_VISUAL_CHILD_RATIO = 0.35
|
||||
LAYOUT_PANEL_SPAN_RATIO = 0.90
|
||||
IMAGE_OVERLAY_MATCH_RATIO = 0.90
|
||||
DENSITY_CONTAINMENT_TOLERANCE = 8
|
||||
|
||||
|
||||
def clipped_bbox(element: dict[str, Any], container: dict[str, Any]) -> dict[str, int | float] | None:
|
||||
left = max(element["x"], container["x"])
|
||||
top = max(element["y"], container["y"])
|
||||
right = min(element["x"] + element["width"], container["x"] + container["width"])
|
||||
bottom = min(element["y"] + element["height"], container["y"] + container["height"])
|
||||
if right <= left or bottom <= top:
|
||||
return None
|
||||
return {"x": left, "y": top, "width": right - left, "height": bottom - top}
|
||||
|
||||
|
||||
def rectangle_union_area(rectangles: list[dict[str, int | float]]) -> int | float:
|
||||
x_coordinates = sorted({coordinate for rect in rectangles for coordinate in (rect["x"], rect["x"] + rect["width"])})
|
||||
area = 0
|
||||
for left, right in zip(x_coordinates, x_coordinates[1:]):
|
||||
intervals = sorted(
|
||||
(rect["y"], rect["y"] + rect["height"])
|
||||
for rect in rectangles
|
||||
if rect["x"] < right and rect["x"] + rect["width"] > left
|
||||
)
|
||||
covered_height = 0
|
||||
interval_end: int | float | None = None
|
||||
for top, bottom in intervals:
|
||||
if interval_end is None:
|
||||
covered_height += bottom - top
|
||||
interval_end = bottom
|
||||
elif bottom > interval_end:
|
||||
covered_height += bottom - max(top, interval_end)
|
||||
interval_end = bottom
|
||||
area += (right - left) * covered_height
|
||||
return area
|
||||
|
||||
|
||||
def has_similar_short_card_peer(element: dict[str, Any], elements: list[dict[str, Any]]) -> bool:
|
||||
return sum(
|
||||
other["kind"] == "shape"
|
||||
and other["type"] == "rect"
|
||||
and other["width"] >= MIN_CONTAINER_WIDTH
|
||||
and other["height"] >= MIN_SHORT_CARD_HEIGHT
|
||||
and xml_lint.element_area(other) >= MIN_CONTAINER_AREA
|
||||
and abs(other["width"] - element["width"]) / max(other["width"], element["width"])
|
||||
<= SHORT_CARD_SIZE_TOLERANCE_RATIO
|
||||
and abs(other["height"] - element["height"]) / max(other["height"], element["height"])
|
||||
<= SHORT_CARD_SIZE_TOLERANCE_RATIO
|
||||
for other in elements
|
||||
) >= MIN_SIMILAR_SHORT_CARD_COUNT
|
||||
|
||||
|
||||
def is_layout_container(
|
||||
element: dict[str, Any],
|
||||
slide_width: int | float,
|
||||
slide_height: int | float,
|
||||
elements: list[dict[str, Any]] | None = None,
|
||||
) -> bool:
|
||||
has_supported_height = element["height"] >= MIN_CONTAINER_HEIGHT or (
|
||||
elements is not None
|
||||
and element["height"] >= MIN_SHORT_CARD_HEIGHT
|
||||
and has_similar_short_card_peer(element, elements)
|
||||
)
|
||||
return (
|
||||
element["kind"] == "shape"
|
||||
and element["type"] == "rect"
|
||||
and element["width"] >= MIN_CONTAINER_WIDTH
|
||||
and has_supported_height
|
||||
and xml_lint.element_area(element) >= MIN_CONTAINER_AREA
|
||||
and not (
|
||||
element["x"] <= 2
|
||||
and element["y"] <= 2
|
||||
and element["width"] >= slide_width - 4
|
||||
and element["height"] >= slide_height - 4
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def is_edge_spanning_layout_panel(
|
||||
element: dict[str, Any], slide_width: int | float, slide_height: int | float
|
||||
) -> bool:
|
||||
touches_horizontal_edge = element["x"] <= 2 or element["x"] + element["width"] >= slide_width - 2
|
||||
touches_vertical_edge = element["y"] <= 2 or element["y"] + element["height"] >= slide_height - 2
|
||||
return (touches_horizontal_edge and element["height"] >= slide_height * LAYOUT_PANEL_SPAN_RATIO) or (
|
||||
touches_vertical_edge and element["width"] >= slide_width * LAYOUT_PANEL_SPAN_RATIO
|
||||
)
|
||||
|
||||
|
||||
def has_matching_image_overlay(container: dict[str, Any], elements: list[dict[str, Any]]) -> bool:
|
||||
container_area = xml_lint.element_area(container)
|
||||
return any(
|
||||
element["kind"] == "img"
|
||||
and xml_lint.intersection_area(container, element)
|
||||
/ max(1, min(container_area, xml_lint.element_area(element)))
|
||||
>= IMAGE_OVERLAY_MATCH_RATIO
|
||||
for element in elements
|
||||
)
|
||||
|
||||
|
||||
def is_nested_in_layout_panel(
|
||||
container: dict[str, Any], elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float
|
||||
) -> bool:
|
||||
return any(
|
||||
element is not container
|
||||
and element["kind"] == "shape"
|
||||
and element["type"] == "rect"
|
||||
and is_edge_spanning_layout_panel(element, slide_width, slide_height)
|
||||
and xml_lint.contains(element, container, tolerance=DENSITY_CONTAINMENT_TOLERANCE)
|
||||
for element in elements
|
||||
)
|
||||
|
||||
|
||||
def extract_density_elements(slide_xml: str) -> list[dict[str, Any]]:
|
||||
elements = xml_lint.extract_elements(slide_xml)
|
||||
elements_by_id = {element["id"]: element for element in elements}
|
||||
root = ET.fromstring(slide_xml)
|
||||
for node in root.iter():
|
||||
if xml_lint.xml_local_name(node.tag) != "shape":
|
||||
continue
|
||||
element = elements_by_id.get(node.attrib.get("id", ""))
|
||||
if element is None:
|
||||
continue
|
||||
content_node = next(
|
||||
(child for child in node if xml_lint.xml_local_name(child.tag) == "content"),
|
||||
None,
|
||||
)
|
||||
paragraphs = (
|
||||
[
|
||||
" ".join("".join(paragraph.itertext()).split())
|
||||
for paragraph in content_node.iter()
|
||||
if xml_lint.xml_local_name(paragraph.tag) == "p"
|
||||
]
|
||||
if content_node is not None
|
||||
else []
|
||||
)
|
||||
raw_font_size = (
|
||||
content_node.attrib.get("fontSize") if content_node is not None else None
|
||||
) or node.attrib.get("fontSize")
|
||||
try:
|
||||
base_font_size = float(raw_font_size or 16)
|
||||
except ValueError:
|
||||
base_font_size = 16.0
|
||||
element.update(
|
||||
{
|
||||
"textType": content_node.attrib.get("textType") if content_node is not None else None,
|
||||
"textAlign": content_node.attrib.get("textAlign") if content_node is not None else None,
|
||||
"autoFit": content_node.attrib.get("autoFit") if content_node is not None else None,
|
||||
"fontSize": base_font_size,
|
||||
"text": "\n".join(paragraph for paragraph in paragraphs if paragraph),
|
||||
}
|
||||
)
|
||||
if not xml_lint.has_text_content(element):
|
||||
continue
|
||||
declared_font_sizes = [
|
||||
float(descendant.attrib["fontSize"])
|
||||
for descendant in node.iter()
|
||||
if descendant.attrib.get("fontSize") is not None
|
||||
]
|
||||
if declared_font_sizes:
|
||||
element["fontSize"] = max(declared_font_sizes)
|
||||
for match in re.finditer(r"<icon\b([^>]*)>", slide_xml):
|
||||
attrs = match.group(1)
|
||||
x = xml_lint.extract_numeric_attribute(attrs, "topLeftX")
|
||||
y = xml_lint.extract_numeric_attribute(attrs, "topLeftY")
|
||||
width = xml_lint.extract_numeric_attribute(attrs, "width")
|
||||
height = xml_lint.extract_numeric_attribute(attrs, "height")
|
||||
if any(value is None for value in (x, y, width, height)):
|
||||
continue
|
||||
elements.append(
|
||||
{
|
||||
"id": xml_lint.extract_attribute(attrs, "id") or f"icon-{len(elements) + 1}",
|
||||
"kind": "icon",
|
||||
"type": "icon",
|
||||
"x": x,
|
||||
"y": y,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"rotation": xml_lint.extract_numeric_attribute(attrs, "rotation") or 0,
|
||||
"order": len(elements),
|
||||
}
|
||||
)
|
||||
return elements
|
||||
|
||||
|
||||
def visual_bbox(element: dict[str, Any], container: dict[str, Any]) -> dict[str, int | float] | None:
|
||||
if xml_lint.is_text_element(element):
|
||||
estimated = xml_lint.estimate_text_visual_bbox(element)
|
||||
return clipped_bbox(estimated, container) if estimated else None
|
||||
return clipped_bbox(element, container)
|
||||
|
||||
|
||||
def own_text_visual_bbox(container: dict[str, Any]) -> dict[str, int | float] | None:
|
||||
if container["kind"] != "shape" or not xml_lint.has_text_content(container):
|
||||
return None
|
||||
text_proxy = {**container, "type": "text"}
|
||||
estimated = xml_lint.estimate_text_visual_bbox(text_proxy)
|
||||
return clipped_bbox(estimated, container) if estimated else None
|
||||
|
||||
|
||||
def slide_content_visual_bbox(
|
||||
element: dict[str, Any], slide_bbox: dict[str, int | float]
|
||||
) -> dict[str, int | float] | None:
|
||||
if xml_lint.is_text_element(element):
|
||||
estimated = xml_lint.estimate_text_visual_bbox(element)
|
||||
return clipped_bbox(estimated, slide_bbox) if estimated else None
|
||||
if element["kind"] == "shape" and xml_lint.has_text_content(element):
|
||||
estimated = own_text_visual_bbox(element)
|
||||
return clipped_bbox(estimated, slide_bbox) if estimated else None
|
||||
if element["kind"] in {"img", "chart", "table", "whiteboard", "icon"}:
|
||||
return clipped_bbox(element, slide_bbox)
|
||||
return None
|
||||
|
||||
|
||||
def is_large_visual_child(element: dict[str, Any], container: dict[str, Any]) -> bool:
|
||||
if element["kind"] not in {"img", "chart", "table", "whiteboard"}:
|
||||
return False
|
||||
return xml_lint.element_area(element) / xml_lint.element_area(container) >= LARGE_VISUAL_CHILD_RATIO
|
||||
|
||||
|
||||
def detect_sparse_container_content(
|
||||
elements: list[dict[str, Any]], slide_number: int, slide_width: int | float, slide_height: int | float
|
||||
) -> list[dict[str, Any]]:
|
||||
issues: list[dict[str, Any]] = []
|
||||
for container in (
|
||||
element for element in elements if is_layout_container(element, slide_width, slide_height, elements)
|
||||
):
|
||||
if (
|
||||
is_edge_spanning_layout_panel(container, slide_width, slide_height)
|
||||
or is_nested_in_layout_panel(container, elements, slide_width, slide_height)
|
||||
or has_matching_image_overlay(container, elements)
|
||||
):
|
||||
continue
|
||||
children = [
|
||||
element
|
||||
for element in elements
|
||||
if element is not container
|
||||
and xml_lint.contains(container, element, tolerance=DENSITY_CONTAINMENT_TOLERANCE)
|
||||
]
|
||||
if any(is_large_visual_child(child, container) for child in children):
|
||||
continue
|
||||
own_text_bbox = own_text_visual_bbox(container)
|
||||
rectangles = ([own_text_bbox] if own_text_bbox else []) + [
|
||||
bbox for child in children if (bbox := visual_bbox(child, container)) is not None
|
||||
]
|
||||
content_area = rectangle_union_area(rectangles) if rectangles else 0
|
||||
coverage_ratio = content_area / xml_lint.element_area(container)
|
||||
if coverage_ratio >= MIN_CONTENT_COVERAGE_RATIO:
|
||||
continue
|
||||
issues.append(
|
||||
{
|
||||
"level": "warning",
|
||||
"code": "sparse_container_content",
|
||||
"schema_version": "1.0",
|
||||
"target": {
|
||||
"slide_number": slide_number,
|
||||
"container_id": container["id"],
|
||||
"container_type": container["type"],
|
||||
"bbox": {key: container[key] for key in ("x", "y", "width", "height")},
|
||||
},
|
||||
"rule": {
|
||||
"name": "large_container_visible_content_coverage",
|
||||
"threshold": MIN_CONTENT_COVERAGE_RATIO,
|
||||
"comparison": "content_coverage_ratio < threshold",
|
||||
},
|
||||
"measurement": {
|
||||
"container_area": xml_lint.element_area(container),
|
||||
"visible_content_area": round(content_area, 3),
|
||||
"content_coverage_ratio": round(coverage_ratio, 3),
|
||||
"content_element_count": len(children) + (1 if own_text_bbox else 0),
|
||||
},
|
||||
"elements": [container["id"], *[child["id"] for child in children]],
|
||||
}
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def detect_sparse_slide_content(
|
||||
elements: list[dict[str, Any]], slide_number: int, slide_width: int | float, slide_height: int | float
|
||||
) -> list[dict[str, Any]]:
|
||||
slide_bbox = {"x": 0, "y": 0, "width": slide_width, "height": slide_height}
|
||||
content = [
|
||||
(element, bbox)
|
||||
for element in elements
|
||||
if (bbox := slide_content_visual_bbox(element, slide_bbox)) is not None
|
||||
]
|
||||
if len(content) < MIN_SLIDE_CONTENT_ELEMENT_COUNT:
|
||||
return []
|
||||
content_area = rectangle_union_area([bbox for _, bbox in content])
|
||||
slide_area = slide_width * slide_height
|
||||
coverage_ratio = content_area / slide_area
|
||||
if coverage_ratio >= MIN_SLIDE_CONTENT_COVERAGE_RATIO:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"level": "warning",
|
||||
"code": "sparse_slide_content",
|
||||
"schema_version": "1.0",
|
||||
"target": {
|
||||
"slide_number": slide_number,
|
||||
"bbox": slide_bbox,
|
||||
},
|
||||
"rule": {
|
||||
"name": "slide_visible_content_coverage",
|
||||
"threshold": MIN_SLIDE_CONTENT_COVERAGE_RATIO,
|
||||
"comparison": "content_coverage_ratio < threshold",
|
||||
},
|
||||
"measurement": {
|
||||
"slide_area": slide_area,
|
||||
"visible_content_area": round(content_area, 3),
|
||||
"content_coverage_ratio": round(coverage_ratio, 3),
|
||||
"content_element_count": len(content),
|
||||
},
|
||||
"elements": [element["id"] for element, _ in content],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def detect_blank_slide(elements: list[dict[str, Any]], slide_number: int) -> list[dict[str, Any]]:
|
||||
if elements:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"level": "warning",
|
||||
"code": "blank_slide",
|
||||
"schema_version": "1.0",
|
||||
"target": {"slide_number": slide_number},
|
||||
"rule": {
|
||||
"name": "slide_has_visible_content",
|
||||
"comparison": "visible_element_count == 0",
|
||||
},
|
||||
"measurement": {"visible_element_count": 0},
|
||||
"elements": [],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
|
||||
root, xml_error = xml_lint.parse_xml_root(xml)
|
||||
if xml_error:
|
||||
return {
|
||||
"file": source_path,
|
||||
"summary": {"slide_count": 0, "warning_count": 0, "error_count": 1},
|
||||
"issues": [xml_error],
|
||||
"slides": [],
|
||||
}
|
||||
if root is None:
|
||||
raise AssertionError("parse_xml_root must return a root or error")
|
||||
presentation = xml_lint.parse_presentation(xml)
|
||||
slides = []
|
||||
for index, slide_xml in enumerate(presentation["slides"]):
|
||||
elements = extract_density_elements(slide_xml)
|
||||
slide_number = index + 1
|
||||
slides.append(
|
||||
{
|
||||
"slide_number": slide_number,
|
||||
"element_count": len(elements),
|
||||
"issues": detect_blank_slide(elements, slide_number)
|
||||
+ detect_sparse_container_content(elements, slide_number, presentation["width"], presentation["height"])
|
||||
+ detect_sparse_slide_content(elements, slide_number, presentation["width"], presentation["height"]),
|
||||
}
|
||||
)
|
||||
warning_count = sum(len(slide["issues"]) for slide in slides)
|
||||
return {
|
||||
"file": source_path,
|
||||
"slide_size": {"width": presentation["width"], "height": presentation["height"]},
|
||||
"summary": {"slide_count": len(slides), "warning_count": warning_count, "error_count": 0},
|
||||
"slides": slides,
|
||||
}
|
||||
|
||||
|
||||
def print_usage() -> None:
|
||||
print("Usage:\n python3 xml_layout_density_lint.py --input <presentation.xml>", file=sys.stderr)
|
||||
|
||||
|
||||
def run_cli(argv: list[str] | None = None) -> None:
|
||||
options = xml_lint.parse_args(argv or sys.argv[1:])
|
||||
if options.get("help") or options.get("--help"):
|
||||
print_usage()
|
||||
raise SystemExit(0)
|
||||
if not options.get("input"):
|
||||
print_usage()
|
||||
raise xml_lint.XmlTextOverlapLintError("--input is required")
|
||||
input_path = Path(options["input"]).resolve()
|
||||
print(json.dumps(lint_xml(xml_lint.read_file(input_path), str(input_path)), ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
run_cli()
|
||||
except xml_lint.XmlTextOverlapLintError as error:
|
||||
print(f"xml-layout-density-lint error: {error}", file=sys.stderr)
|
||||
raise SystemExit(1) from error
|
||||
458
skills/lark-slides/scripts/xml_layout_density_lint_test.py
Normal file
458
skills/lark-slides/scripts/xml_layout_density_lint_test.py
Normal file
@@ -0,0 +1,458 @@
|
||||
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
# SPDX-License-Identifier: MIT
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
import xml_layout_density_lint
|
||||
|
||||
|
||||
class XmlLayoutDensityLintTest(unittest.TestCase):
|
||||
def test_lint_xml_warns_for_blank_slide(self) -> None:
|
||||
result = xml_layout_density_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide id="content-slide">
|
||||
<data>
|
||||
<shape id="title" type="text" topLeftX="60" topLeftY="60" width="400" height="50">
|
||||
<content fontSize="28"><p>Investment report</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
<slide id="blank-slide">
|
||||
<style><fill><fillColor color="rgba(255, 255, 255, 1)"/></fill></style>
|
||||
<data/>
|
||||
<note><content/></note>
|
||||
</slide>
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(result["summary"], {"slide_count": 2, "warning_count": 1, "error_count": 0})
|
||||
self.assertEqual(result["slides"][0]["issues"], [])
|
||||
self.assertEqual(result["slides"][1]["element_count"], 0)
|
||||
self.assertEqual(
|
||||
result["slides"][1]["issues"],
|
||||
[
|
||||
{
|
||||
"level": "warning",
|
||||
"code": "blank_slide",
|
||||
"schema_version": "1.0",
|
||||
"target": {"slide_number": 2},
|
||||
"rule": {
|
||||
"name": "slide_has_visible_content",
|
||||
"comparison": "visible_element_count == 0",
|
||||
},
|
||||
"measurement": {"visible_element_count": 0},
|
||||
"elements": [],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
def test_lint_xml_warns_when_large_container_is_mostly_empty(self) -> None:
|
||||
result = xml_layout_density_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="trend-card" type="rect" topLeftX="500" topLeftY="135" width="410" height="370"/>
|
||||
<shape id="trend-title" type="text" topLeftX="515" topLeftY="147" width="380" height="28">
|
||||
<content fontSize="15"><p>Core trends</p></content>
|
||||
</shape>
|
||||
<shape id="trend-copy" type="text" topLeftX="515" topLeftY="177" width="380" height="315">
|
||||
<content fontSize="12"><p>First point</p><p>Second point</p><p>Third point</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
issue = result["slides"][0]["issues"][0]
|
||||
self.assertEqual(issue["code"], "sparse_container_content")
|
||||
self.assertEqual(issue["target"]["container_id"], "trend-card")
|
||||
self.assertEqual(issue["target"], {
|
||||
"slide_number": 1,
|
||||
"container_id": "trend-card",
|
||||
"container_type": "rect",
|
||||
"bbox": {"x": 500, "y": 135, "width": 410, "height": 370},
|
||||
})
|
||||
self.assertLess(issue["measurement"]["content_coverage_ratio"], 0.15)
|
||||
self.assertEqual(issue["rule"], {
|
||||
"name": "large_container_visible_content_coverage",
|
||||
"threshold": 0.15,
|
||||
"comparison": "content_coverage_ratio < threshold",
|
||||
})
|
||||
self.assertEqual(issue["measurement"]["container_area"], 151700)
|
||||
self.assertEqual(issue["measurement"]["content_coverage_ratio"], 0.032)
|
||||
self.assertEqual(issue["elements"], ["trend-card", "trend-title", "trend-copy"])
|
||||
self.assertEqual(set(issue), {"level", "code", "schema_version", "target", "rule", "measurement", "elements"})
|
||||
|
||||
def test_lint_xml_warns_for_sparse_short_cards(self) -> None:
|
||||
result = xml_layout_density_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="card-1" type="rect" topLeftX="60" topLeftY="180" width="400" height="105"/>
|
||||
<shape id="text-1" type="text" topLeftX="80" topLeftY="220" width="360" height="30">
|
||||
<content fontSize="14"><p>期待认识大家</p></content>
|
||||
</shape>
|
||||
<shape id="card-2" type="rect" topLeftX="490" topLeftY="180" width="400" height="105"/>
|
||||
<shape id="text-2" type="text" topLeftX="510" topLeftY="220" width="360" height="30">
|
||||
<content fontSize="14"><p>化学一起讨论</p></content>
|
||||
</shape>
|
||||
<shape id="card-3" type="rect" topLeftX="60" topLeftY="310" width="400" height="105"/>
|
||||
<shape id="text-3" type="text" topLeftX="80" topLeftY="350" width="360" height="30">
|
||||
<content fontSize="14"><p>吉他随时交流</p></content>
|
||||
</shape>
|
||||
<shape id="card-4" type="rect" topLeftX="490" topLeftY="310" width="400" height="105"/>
|
||||
<shape id="text-4" type="text" topLeftX="510" topLeftY="350" width="360" height="30">
|
||||
<content fontSize="14"><p>共度美好四年</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
container_issues = [
|
||||
issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_container_content"
|
||||
]
|
||||
self.assertEqual(
|
||||
[issue["target"]["container_id"] for issue in container_issues],
|
||||
["card-1", "card-2", "card-3", "card-4"],
|
||||
)
|
||||
self.assertTrue(all(issue["target"]["bbox"]["height"] == 105 for issue in container_issues))
|
||||
self.assertTrue(all(issue["measurement"]["content_coverage_ratio"] < 0.15 for issue in container_issues))
|
||||
self.assertEqual(
|
||||
[issue["code"] for issue in result["slides"][0]["issues"]],
|
||||
[
|
||||
"sparse_container_content",
|
||||
"sparse_container_content",
|
||||
"sparse_container_content",
|
||||
"sparse_container_content",
|
||||
"sparse_slide_content",
|
||||
],
|
||||
)
|
||||
|
||||
def test_lint_xml_warns_when_whole_slide_has_too_little_effective_content(self) -> None:
|
||||
result = xml_layout_density_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="background" type="rect" topLeftX="0" topLeftY="0" width="960" height="540"/>
|
||||
<shape id="text-1" type="text" topLeftX="60" topLeftY="80" width="200" height="30">
|
||||
<content fontSize="14"><p>One short line</p></content>
|
||||
</shape>
|
||||
<shape id="text-2" type="text" topLeftX="500" topLeftY="180" width="200" height="30">
|
||||
<content fontSize="14"><p>Another line</p></content>
|
||||
</shape>
|
||||
<shape id="text-3" type="text" topLeftX="60" topLeftY="310" width="200" height="30">
|
||||
<content fontSize="14"><p>Third line</p></content>
|
||||
</shape>
|
||||
<shape id="text-4" type="text" topLeftX="500" topLeftY="410" width="200" height="30">
|
||||
<content fontSize="14"><p>Fourth line</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
issues = [issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_slide_content"]
|
||||
self.assertEqual(len(issues), 1)
|
||||
issue = issues[0]
|
||||
self.assertEqual(issue["target"]["bbox"], {"x": 0, "y": 0, "width": 960, "height": 540})
|
||||
self.assertEqual(issue["rule"]["threshold"], 0.035)
|
||||
self.assertLess(issue["measurement"]["content_coverage_ratio"], 0.035)
|
||||
self.assertEqual(issue["measurement"]["content_element_count"], 4)
|
||||
self.assertNotIn("background", issue["elements"])
|
||||
|
||||
def test_lint_xml_ignores_isolated_short_layout_bar(self) -> None:
|
||||
result = xml_layout_density_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="summary-bar" type="rect" topLeftX="52" topLeftY="82" width="856" height="105"/>
|
||||
<shape id="summary" type="text" topLeftX="72" topLeftY="115" width="816" height="30">
|
||||
<content fontSize="14"><p>One concise summary</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(result["slides"][0]["issues"], [])
|
||||
|
||||
def test_lint_xml_counts_rect_own_content_as_visible_content(self) -> None:
|
||||
result = xml_layout_density_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="load-card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
|
||||
<content fontSize="18">
|
||||
<p>被吊物</p>
|
||||
<p><span fontSize="36">32.0 t</span></p>
|
||||
<p>钢结构模块</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(result["slides"][0]["issues"], [])
|
||||
|
||||
def test_lint_xml_reports_nonzero_coverage_for_rect_own_content_reproduction(self) -> None:
|
||||
result = xml_layout_density_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="load-card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
|
||||
<content fontSize="18">
|
||||
<p>被吊物</p>
|
||||
<p>32.0 t</p>
|
||||
<p>钢结构模块</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
issue = result["slides"][0]["issues"][0]
|
||||
self.assertGreater(issue["measurement"]["visible_content_area"], 0)
|
||||
self.assertEqual(issue["measurement"]["content_element_count"], 1)
|
||||
self.assertGreater(issue["measurement"]["content_coverage_ratio"], 0)
|
||||
|
||||
def test_lint_xml_still_warns_for_sparse_rect_own_content(self) -> None:
|
||||
result = xml_layout_density_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="sparse-card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
|
||||
<content fontSize="12"><p>A</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
issue = result["slides"][0]["issues"][0]
|
||||
self.assertEqual(issue["target"]["container_id"], "sparse-card")
|
||||
self.assertGreater(issue["measurement"]["visible_content_area"], 0)
|
||||
self.assertEqual(issue["measurement"]["content_element_count"], 1)
|
||||
self.assertEqual(issue["elements"], ["sparse-card"])
|
||||
|
||||
def test_lint_xml_unions_rect_own_content_with_child_content(self) -> None:
|
||||
self_only = xml_layout_density_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
|
||||
<content fontSize="12"><p>A</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
with_overlapping_child = xml_layout_density_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
|
||||
<content fontSize="12"><p>A</p></content>
|
||||
</shape>
|
||||
<shape id="child" type="text" topLeftX="60" topLeftY="140" width="220" height="184">
|
||||
<content fontSize="12"><p>A</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
self_issue = self_only["slides"][0]["issues"][0]
|
||||
mixed_issue = with_overlapping_child["slides"][0]["issues"][0]
|
||||
self.assertEqual(
|
||||
mixed_issue["measurement"]["visible_content_area"],
|
||||
self_issue["measurement"]["visible_content_area"],
|
||||
)
|
||||
self.assertEqual(mixed_issue["measurement"]["content_element_count"], 2)
|
||||
|
||||
def test_extract_density_elements_reads_nested_font_size_from_rect_content(self) -> None:
|
||||
elements = xml_layout_density_lint.extract_density_elements(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
|
||||
<content fontSize="12"><p><span fontSize="36">32.0 t</span></p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(elements[0]["fontSize"], 36)
|
||||
|
||||
def test_extract_density_elements_does_not_attach_following_text_to_self_closing_rect(self) -> None:
|
||||
elements = xml_layout_density_lint.extract_density_elements(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184"/>
|
||||
<shape id="title" type="text" topLeftX="80" topLeftY="160" width="180" height="30">
|
||||
<content fontSize="18"><p>Following title</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(elements[0]["text"], "")
|
||||
self.assertEqual(elements[1]["text"], "Following title")
|
||||
|
||||
def test_lint_xml_allows_container_with_large_visual_child(self) -> None:
|
||||
result = xml_layout_density_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="chart-card" type="rect" topLeftX="500" topLeftY="135" width="410" height="300"/>
|
||||
<chart id="chart" topLeftX="525" topLeftY="170" width="350" height="220"/>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(result["summary"]["warning_count"], 0)
|
||||
|
||||
def test_lint_xml_warns_for_small_empty_visual_placeholder_cards(self) -> None:
|
||||
result = xml_layout_density_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="letter-placeholder" type="rect" topLeftX="520" topLeftY="180" width="200" height="200"/>
|
||||
<shape id="letter" type="text" topLeftX="540" topLeftY="250" width="160" height="70">
|
||||
<content fontSize="46"><p>Z</p></content>
|
||||
</shape>
|
||||
<shape id="empty-placeholder" type="rect" topLeftX="744" topLeftY="180" width="144" height="200"/>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
issues = result["slides"][0]["issues"]
|
||||
self.assertEqual(
|
||||
[issue["target"]["container_id"] for issue in issues],
|
||||
["letter-placeholder", "empty-placeholder"],
|
||||
)
|
||||
self.assertEqual(issues[1]["measurement"]["content_element_count"], 0)
|
||||
|
||||
def test_lint_xml_applies_global_threshold_to_normal_text_card(self) -> None:
|
||||
result = xml_layout_density_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="card" type="rect" topLeftX="70" topLeftY="184" width="260" height="288"/>
|
||||
<shape id="title" type="text" topLeftX="90" topLeftY="215" width="220" height="30">
|
||||
<content fontSize="18"><p>梦境与现实</p></content>
|
||||
</shape>
|
||||
<shape id="copy" type="text" topLeftX="90" topLeftY="330" width="220" height="70">
|
||||
<content fontSize="13"><p>边界溶解,逻辑失效。观众被拽入潜意识的迷宫。</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
issue = result["slides"][0]["issues"][0]
|
||||
self.assertEqual(issue["target"]["container_id"], "card")
|
||||
self.assertEqual(issue["rule"]["threshold"], 0.15)
|
||||
|
||||
def test_lint_xml_allows_image_overlay_rect(self) -> None:
|
||||
result = xml_layout_density_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<img id="hero" topLeftX="560" topLeftY="0" width="400" height="540"/>
|
||||
<shape id="tint" type="rect" topLeftX="560" topLeftY="0" width="400" height="540"/>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(result["summary"]["warning_count"], 0)
|
||||
|
||||
def test_lint_xml_allows_edge_spanning_layout_panel_and_nested_decoration(self) -> None:
|
||||
result = xml_layout_density_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="panel" type="rect" topLeftX="600" topLeftY="0" width="360" height="540"/>
|
||||
<shape id="decoration" type="rect" topLeftX="660" topLeftY="150" width="240" height="240"/>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(result["summary"]["warning_count"], 0)
|
||||
|
||||
def test_lint_xml_counts_icons_as_visible_content(self) -> None:
|
||||
result = xml_layout_density_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="card" type="rect" topLeftX="80" topLeftY="140" width="320" height="240"/>
|
||||
<icon id="visual" iconType="shield" topLeftX="100" topLeftY="160" width="180" height="180"/>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(result["summary"]["warning_count"], 0)
|
||||
|
||||
def test_lint_xml_warns_when_coverage_is_below_global_threshold(self) -> None:
|
||||
result = xml_layout_density_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="card" type="rect" topLeftX="80" topLeftY="140" width="200" height="200"/>
|
||||
<icon id="visual" iconType="shield" topLeftX="100" topLeftY="160" width="70" height="70"/>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
issue = result["slides"][0]["issues"][0]
|
||||
self.assertEqual(issue["target"]["container_id"], "card")
|
||||
self.assertEqual(issue["measurement"]["content_coverage_ratio"], 0.122)
|
||||
self.assertEqual(issue["rule"]["threshold"], 0.15)
|
||||
|
||||
def test_lint_xml_allows_quarter_coverage_under_lower_threshold(self) -> None:
|
||||
result = xml_layout_density_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="card" type="rect" topLeftX="80" topLeftY="140" width="200" height="200"/>
|
||||
<icon id="visual" iconType="shield" topLeftX="100" topLeftY="160" width="100" height="100"/>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(result["slides"][0]["issues"], [])
|
||||
|
||||
def test_lint_xml_allows_large_metric_card_above_lower_threshold(self) -> None:
|
||||
result = xml_layout_density_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="metric-card" type="rect" topLeftX="80" topLeftY="140" width="360" height="300"/>
|
||||
<shape id="metric" type="text" topLeftX="104" topLeftY="190" width="340" height="90">
|
||||
<content fontSize="12.4"><p><strong><span fontSize="62">400</span></strong>+ 项</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(result["slides"][0]["issues"], [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1418,4 +1418,4 @@ if __name__ == "__main__":
|
||||
run_cli()
|
||||
except XmlTextOverlapLintError as error:
|
||||
print(f"xml-text-overlap-lint error: {error}", file=sys.stderr)
|
||||
raise SystemExit(1) from error
|
||||
raise SystemExit(1) from error
|
||||
@@ -22,22 +22,21 @@ metadata:
|
||||
|
||||
**身份**:画板操作默认使用 `--as user`。仅当需要以应用身份上传时使用 `--as bot`。
|
||||
|
||||
| 用户需求 | 行动 |
|
||||
|-----------------------------------------|---------------------------------------------------------------------------------------------------|
|
||||
| 查看画板内容 / 导出图片 | [`+export --output-type preview`](references/lark-whiteboard-export.md) |
|
||||
| 导出 SVG 矢量图 | [`+export --output-type svg`](references/lark-whiteboard-export.md) |
|
||||
| 获取画板的 Mermaid/PlantUML 代码 | [`+export --output-type source`](references/lark-whiteboard-export.md) |
|
||||
| 检查画板是否由代码绘制 | [`+export --output-type source`](references/lark-whiteboard-export.md) |
|
||||
| 仅微调节点文字/颜色 | `+export --output-type raw` → 手动改 JSON → `+update --input_format raw` |
|
||||
| 用户需求 | 行动 |
|
||||
|-----------------------------------------|-----------------------------------------------------------------------------------------------|
|
||||
| 查看画板内容 / 导出图片 / 导出 SVG 矢量图 | [`+query --output_as image/svg`](references/lark-whiteboard-query.md) |
|
||||
| 获取画板的 Mermaid/PlantUML 代码 | [`+query --output_as code`](references/lark-whiteboard-query.md) |
|
||||
| 检查画板是否由代码绘制 | [`+query --output_as code`](references/lark-whiteboard-query.md) |
|
||||
| 仅微调节点文字/颜色 | `+query --output_as raw` → 手动改 JSON → `+update --input_format raw` |
|
||||
| 用户**已提供** Mermaid/PlantUML/SVG 代码,或明确指定用该格式 | 自己生成/使用代码 → [`+update --input_format mermaid/plantuml/svg`](references/lark-whiteboard-update.md) |
|
||||
| 新建/创作复杂图表(架构/流程/组织等) | → **[§ 创作 Workflow](references/lark-whiteboard-workflow.md#创作-workflow)** |
|
||||
| 修改/重绘已有画板 | → **[§ 修改 Workflow](references/lark-whiteboard-workflow.md#修改-workflow)** |
|
||||
| 新建/创作复杂图表(架构/流程/组织等) | → **[§ 创作 Workflow](references/lark-whiteboard-workflow.md#创作-workflow)** |
|
||||
| 修改/重绘已有画板 | → **[§ 修改 Workflow](references/lark-whiteboard-workflow.md#修改-workflow)** |
|
||||
|
||||
## Shortcuts
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|---------------------------------------------------|---|
|
||||
| [`+export`](references/lark-whiteboard-export.md) | 导出画板为预览图片、SVG 矢量图、代码或原始节点结构。 |
|
||||
| Shortcut | 说明 |
|
||||
|---|---|
|
||||
| [`+query`](references/lark-whiteboard-query.md) | 查询画板,导出为预览图片、SVG 矢量图、代码或原始节点结构。 |
|
||||
| [`+update`](references/lark-whiteboard-update.md) | 更新画板,支持 PlantUML、Mermaid、SVG 或 OpenAPI 原生格式 |
|
||||
|
||||
---
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
# whiteboard +export(导出画板)
|
||||
# whiteboard +query(查询画板)
|
||||
|
||||
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
导出画板内容,支持导出为预览图片、SVG 矢量图、提取 PlantUML/Mermaid 代码,或获取飞书 OpenAPI 原生画板节点格式。
|
||||
查询画板内容,支持导出为预览图片、SVG 矢量图、提取 PlantUML/Mermaid 代码,或获取飞书 OpenAPI 原生画板节点格式。
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|----------------------|----|------------------------------------------------------------------------|
|
||||
| `--whiteboard-token` | 是 | 画板 token,需要拥有画板的读权限 |
|
||||
| `--output-type` | 是 | 输出格式:`preview`(预览图片)、`svg`(SVG 矢量图)、`source`(PlantUML/Mermaid 代码)、`raw`(OpenAPI 原生画板节点格式) |
|
||||
| `--output` | 否 | 输出路径。当 `--output-type preview` 时必填,推荐传入无后缀文件路径(如 `./preview`);当 `--output-type svg/source/raw` 时可选,不填则直接输出到终端 |
|
||||
| `--output_as` | 是 | 输出格式:`image`(预览图片)、`svg`(SVG 矢量图)、`code`(PlantUML/Mermaid 代码)、`raw`(OpenAPI 原生画板节点格式) |
|
||||
| `--output` | 否 | 输出路径。当 `--output_as image` 时必填;当 `--output_as svg/code/raw` 时可选,不填则直接输出到终端 |
|
||||
| `--overwrite` | 否 | 覆盖已存在的文件,默认为 false |
|
||||
|
||||
## 输出格式
|
||||
|
||||
- `preview`:预览图片。推荐 `--output ./preview` 这类无后缀文件路径,CLI 会按实际图片类型保存为 `./preview.png` 或 `./preview.jpg`。如果 `--output` 是目录,会保存为该目录下的 `whiteboard_<whiteboard-token>.png/.jpg`;如果显式写了后缀,需要和实际图片类型匹配。`--overwrite` 检查的是补齐后缀后的最终路径,例如返回 PNG 时 `--output ./preview` 对应覆盖 `./preview.png`。
|
||||
- `image`:预览图片
|
||||
- `svg`:导出画板为标准 SVG 矢量图。可用于 SVG 编辑后回写画板(见 [`routes/svg-edit.md`](../routes/svg-edit.md))。注意:导出为纯视觉快照,思维导图层级、表格结构、连接器绑定等语义信息会丢失。
|
||||
- `source`:PlantUML/Mermaid 代码。仅限画板内有且仅有一个 PlantUML/Mermaid 图时,才可导出代码,否则会在返回值中告知不存在/有多个节点。
|
||||
- `code`:PlantUML/Mermaid 代码。仅限画板内有且仅有一个 PlantUML/Mermaid 图时,才可导出代码,否则会在返回值中告知不存在/有多个节点。
|
||||
- `raw`:飞书 OpenAPI 原生画板节点格式。这一 json 格式不适合直接编辑复杂布局或内容,建议仅限于需要修改简单的文本内容/颜色等细节时使用。需要进行更复杂的设计/修改时,建议参考 [§ 渲染 & 写入画板](../SKILL.md#渲染--写入画板)。
|
||||
|
||||
## 示例
|
||||
@@ -25,26 +25,26 @@
|
||||
### 示例 1:导出画板为预览图片
|
||||
|
||||
```bash
|
||||
lark-cli whiteboard +export \
|
||||
lark-cli whiteboard +query \
|
||||
--whiteboard-token "wbcnxxxxxxxx" \
|
||||
--output-type preview \
|
||||
--output ./preview
|
||||
--output_as image \
|
||||
--output ./preview.png
|
||||
```
|
||||
|
||||
### 示例 2:提取画板中的代码并直接输出
|
||||
|
||||
```bash
|
||||
lark-cli whiteboard +export \
|
||||
lark-cli whiteboard +query \
|
||||
--whiteboard-token "wbcnxxxxxxxx" \
|
||||
--output-type source
|
||||
--output_as code
|
||||
```
|
||||
|
||||
### 示例 3:导出画板为 SVG 矢量图
|
||||
|
||||
```bash
|
||||
lark-cli whiteboard +export \
|
||||
lark-cli whiteboard +query \
|
||||
--whiteboard-token "wbcnxxxxxxxx" \
|
||||
--output-type svg \
|
||||
--output_as svg \
|
||||
--output ./whiteboard.svg \
|
||||
--as user
|
||||
```
|
||||
@@ -52,9 +52,9 @@ lark-cli whiteboard +export \
|
||||
### 示例 4:导出画板原始节点结构到文件
|
||||
|
||||
```bash
|
||||
lark-cli whiteboard +export \
|
||||
lark-cli whiteboard +query \
|
||||
--whiteboard-token "wbcnxxxxxxxx" \
|
||||
--output-type raw \
|
||||
--output_as raw \
|
||||
--output ./nodes.json \
|
||||
--overwrite
|
||||
```
|
||||
@@ -26,12 +26,12 @@
|
||||
**Step 2:判断修改策略**
|
||||
|
||||
```
|
||||
+export --output-type source
|
||||
+query --output_as code
|
||||
├─ 返回 Mermaid/PlantUML 代码
|
||||
│ → 在原代码上修改 → +update --input_format mermaid/plantuml
|
||||
├─ 无代码(SVG/DSL 或其他方式绘制的画板)
|
||||
│ ├─ 需纯新增(思维导图、流程图、时序图、类图、饼图、甘特图)图表节点
|
||||
│ │ → +export --output-type preview → 看图 → +export --output-type raw → 确定新节点坐标和层级 → [§ 渲染 & 写入画板]
|
||||
│ │ → +query --output_as image → 看图 → +query --output_as raw → 确定新节点坐标和层级 → [§ 渲染 & 写入画板]
|
||||
│ └─ 其他改动(几何变动/增删元素/结构调整/混合编辑等)
|
||||
│ → [`../routes/svg-edit.md`](../routes/svg-edit.md)(视觉高保真还原,大部分场景适用)
|
||||
└─ 用户有明确要求 → 以用户要求优先
|
||||
|
||||
@@ -25,9 +25,9 @@ SVG 导出是**纯视觉快照**,再次导入后画板语义(思维导图层
|
||||
### 1. 导出当前画板 SVG
|
||||
|
||||
```bash
|
||||
lark-cli whiteboard +export \
|
||||
lark-cli whiteboard +query \
|
||||
--whiteboard-token <TOKEN> \
|
||||
--output-type svg \
|
||||
--output_as svg \
|
||||
--output <dir>/original.svg \
|
||||
--as user
|
||||
```
|
||||
|
||||
@@ -54,6 +54,8 @@
|
||||
- 阴影:`<filter>` 里放 `<feDropShadow>` 或标准 drop/inner primitive 链 (`<feGaussianBlur in="SourceAlpha">` + `<feOffset>` + `<feFlood>` + `<feComposite>` + `<feMerge>`), 会被识别成节点阴影, drop 至多 1 个, inner 至多 1 个; 其余 filter 效果不识别
|
||||
- 渐变:`<linearGradient>` / `<radialGradient>` 在 `<defs>` 中定义, 通过 `fill="url(#id)"` 引用 (载体限 `<rect>` / `<circle>` / `<ellipse>` / `<polygon>` / `<path>`), 需要至少 2 个 `<stop>`, `gradientUnits` 只支持默认的 `objectBoundingBox` (不写即可);
|
||||
|
||||
**⚠️ [!IMPORTANT] 不支持的装饰特性**
|
||||
> [!IMPORTANT]
|
||||
> ⚠️ **不支持的装饰特性**
|
||||
|
||||
- `<pattern>` / `<clipPath>` / `<mask>` / 非阴影用途的 `<filter>` (blur / hue-rotate / 复合合成 / `flood-color=url(...)` / 多个 `<feDropShadow>` 等) → 画板不支持,**请避免使用,否则会导致画板渲染问题**
|
||||
- 渐变边界:`gradientUnits="userSpaceOnUse"` / `spreadMethod="reflect|repeat"` / stops 少于 2 个 / 复杂 `gradientTransform` 会变成不可编辑图片, 视觉正确但失去可编辑性, 若无必要请沿用默认 `objectBoundingBox`
|
||||
|
||||
@@ -24,7 +24,6 @@ metadata:
|
||||
|
||||
## 快速决策
|
||||
|
||||
- 用户要**按特定主题 / 关键词 / 内容线索查找资料并收集到知识库节点或新建知识库节点下**,必须先阅读 [`../lark-drive/references/lark-drive-workflow.md`](../lark-drive/references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`topic_move_collector`](../lark-drive/references/lark-drive-workflow-topic-move-collector.md) workflow。该 workflow 使用 Drive 全量搜索召回,再按 Wiki 目标解析、确认和移动;不要只用 Wiki 节点列表做局部遍历。
|
||||
- 用户要**整理 / 盘点 / 归类 / 重构知识库、个人文档库、文档库目录或 Wiki 节点结构**,或要生成整理方案、目标目录树、移动计划时,不要只使用 Wiki 节点 API。必须先阅读 [`../lark-drive/references/lark-drive-workflow.md`](../lark-drive/references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](../lark-drive/references/lark-drive-workflow-knowledge-organize.md) workflow;该 workflow 负责 Drive / Wiki / 个人文档库的统一入口解析、资源盘点、分类计划、写前确认和结果验证。
|
||||
- 用户要把**已有 Wiki 节点移出知识库,放到 Drive 文件夹或“我的空间”根目录**:使用 `wiki +move-to-drive`,不要使用 `wiki +move` 或 `drive +move`。这是会改变节点归属和权限继承的写操作,执行前确认源节点与目标位置。
|
||||
- 用户给的是知识库 URL(`.../wiki/<token>`),且后续要查成员/加成员/删成员:先调用 `lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}'` 获取 `space_id`,后续成员接口统一使用 `space_id`。
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestBaseFieldUpdateAutoNumberDryRun(t *testing.T) {
|
||||
result := runBaseDryRun(t, 0,
|
||||
"base", "+field-update",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--field-id", "fld_x",
|
||||
"--json", `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}`,
|
||||
"--yes",
|
||||
)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x", gjson.Get(out, "data.api.0.url").String(), out)
|
||||
require.Equal(t, "PUT", gjson.Get(out, "data.api.0.method").String(), out)
|
||||
require.Equal(t, "编号", gjson.Get(out, "data.api.0.body.name").String(), out)
|
||||
require.Equal(t, "auto_number", gjson.Get(out, "data.api.0.body.type").String(), out)
|
||||
require.Equal(t, "created_time", gjson.Get(out, "data.api.0.body.style.rules.1.type").String(), out)
|
||||
require.Equal(t, "yyyyMM", gjson.Get(out, "data.api.0.body.style.rules.1.date_format").String(), out)
|
||||
require.Equal(t, int64(4), gjson.Get(out, "data.api.0.body.style.rules.3.length").Int(), out)
|
||||
require.False(t, gjson.Get(out, "data.api.0.body.property.auto_serial").Exists(), out)
|
||||
require.NotContains(t, out, "reformat_existing_records", out)
|
||||
require.NotContains(t, out, "/open-apis/bitable/v1/", out)
|
||||
}
|
||||
|
||||
func TestBaseFieldUpdateDryRunAllowsRatingMaxAboveLimit(t *testing.T) {
|
||||
result := runBaseDryRun(t, 0,
|
||||
"base", "+field-update",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--field-id", "fld_x",
|
||||
"--json", `{"name":"评分","type":"number","style":{"type":"rating","icon":"star","min":0,"max":20}}`,
|
||||
"--yes",
|
||||
)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x", gjson.Get(out, "data.api.0.url").String(), out)
|
||||
require.Equal(t, "PUT", gjson.Get(out, "data.api.0.method").String(), out)
|
||||
require.Equal(t, "评分", gjson.Get(out, "data.api.0.body.name").String(), out)
|
||||
require.Equal(t, "rating", gjson.Get(out, "data.api.0.body.style.type").String(), out)
|
||||
require.Equal(t, int64(20), gjson.Get(out, "data.api.0.body.style.max").Int(), out)
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestBaseRecordListDryRunAcceptsFieldsAlias(t *testing.T) {
|
||||
result := runBaseDryRun(t, 0,
|
||||
"base", "+record-list",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--fields", `["Name","Age"]`,
|
||||
"--limit", "3",
|
||||
)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "GET", gjson.Get(out, "data.api.0.method").String(), out)
|
||||
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/records?field_id=Name&field_id=Age&limit=3&offset=0", gjson.Get(out, "data.api.0.url").String(), out)
|
||||
}
|
||||
|
||||
func TestBaseRecordSearchDryRunAcceptsFieldsAlias(t *testing.T) {
|
||||
result := runBaseDryRun(t, 0,
|
||||
"base", "+record-search",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--keyword", "Alice",
|
||||
"--search-field", "Name",
|
||||
"--fields", `["Name","Age"]`,
|
||||
)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "POST", gjson.Get(out, "data.api.0.method").String(), out)
|
||||
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search", gjson.Get(out, "data.api.0.url").String(), out)
|
||||
require.Equal(t, "Name", gjson.Get(out, "data.api.0.body.select_fields.0").String(), out)
|
||||
require.Equal(t, "Age", gjson.Get(out, "data.api.0.body.select_fields.1").String(), out)
|
||||
}
|
||||
|
||||
func TestBaseRecordGetDryRunAcceptsFieldNamesAlias(t *testing.T) {
|
||||
result := runBaseDryRun(t, 0,
|
||||
"base", "+record-get",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_1",
|
||||
"--field-names", "Name",
|
||||
"--field-names", "Age",
|
||||
)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "POST", gjson.Get(out, "data.api.0.method").String(), out)
|
||||
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get", gjson.Get(out, "data.api.0.url").String(), out)
|
||||
require.Equal(t, "rec_1", gjson.Get(out, "data.api.0.body.record_id_list.0").String(), out)
|
||||
require.Equal(t, "Name", gjson.Get(out, "data.api.0.body.select_fields.0").String(), out)
|
||||
require.Equal(t, "Age", gjson.Get(out, "data.api.0.body.select_fields.1").String(), out)
|
||||
}
|
||||
|
||||
func TestBaseRecordGetDryRunTreatsNullProjectionAsOmitted(t *testing.T) {
|
||||
result := runBaseDryRun(t, 0,
|
||||
"base", "+record-get",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--json", `{"record_id_list":["rec_1"],"select_fields":null}`,
|
||||
)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "POST", gjson.Get(out, "data.api.0.method").String(), out)
|
||||
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get", gjson.Get(out, "data.api.0.url").String(), out)
|
||||
require.Equal(t, "rec_1", gjson.Get(out, "data.api.0.body.record_id_list.0").String(), out)
|
||||
require.False(t, gjson.Get(out, "data.api.0.body.select_fields").Exists(), out)
|
||||
}
|
||||
|
||||
func TestBaseRecordGetDryRunUsesFlagProjectionWhenJSONProjectionIsNull(t *testing.T) {
|
||||
result := runBaseDryRun(t, 0,
|
||||
"base", "+record-get",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--json", `{"record_id_list":["rec_1"],"select_fields":null}`,
|
||||
"--field-id", "Name",
|
||||
)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "POST", gjson.Get(out, "data.api.0.method").String(), out)
|
||||
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get", gjson.Get(out, "data.api.0.url").String(), out)
|
||||
require.Equal(t, "rec_1", gjson.Get(out, "data.api.0.body.record_id_list.0").String(), out)
|
||||
require.Equal(t, "Name", gjson.Get(out, "data.api.0.body.select_fields.0").String(), out)
|
||||
}
|
||||
|
||||
func TestBaseRecordListDryRunPreservesFieldNamesCSVSemantics(t *testing.T) {
|
||||
result := runBaseDryRun(t, 0,
|
||||
"base", "+record-list",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--field-names", `"A,B",@Owner`,
|
||||
"--limit", "3",
|
||||
)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "GET", gjson.Get(out, "data.api.0.method").String(), out)
|
||||
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/records?field_id=A%2CB&field_id=%40Owner&limit=3&offset=0", gjson.Get(out, "data.api.0.url").String(), out)
|
||||
}
|
||||
|
||||
func TestBaseRecordListDryRunTreatsLeadingAtFieldNameLiterally(t *testing.T) {
|
||||
result := runBaseDryRun(t, 0,
|
||||
"base", "+record-list",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--field-names", "@Owner",
|
||||
"--limit", "3",
|
||||
)
|
||||
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/records?field_id=%40Owner&limit=3&offset=0", gjson.Get(result.Stdout, "data.api.0.url").String(), result.Stdout)
|
||||
}
|
||||
|
||||
func TestBaseRecordSearchDryRunJSONConflictReportsActualParams(t *testing.T) {
|
||||
result := runBaseDryRun(t, 2,
|
||||
"base", "+record-search",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--json", `{"keyword":"Alice","search_fields":["Name"]}`,
|
||||
"--field-names", "Age",
|
||||
)
|
||||
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), result.Stderr)
|
||||
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), result.Stderr)
|
||||
require.Equal(t, "--json", gjson.Get(result.Stderr, "error.param").String(), result.Stderr)
|
||||
require.Equal(t, int64(2), gjson.Get(result.Stderr, "error.params.#").Int(), result.Stderr)
|
||||
require.Equal(t, "--json", gjson.Get(result.Stderr, "error.params.0.name").String(), result.Stderr)
|
||||
require.Equal(t, "--field-names", gjson.Get(result.Stderr, "error.params.1.name").String(), result.Stderr)
|
||||
require.Contains(t, gjson.Get(result.Stderr, "error.hint").String(), "inside --json")
|
||||
require.Empty(t, result.Stdout)
|
||||
}
|
||||
|
||||
func TestBaseRecordProjectionDryRunKeepsActiveParamForFlagLikeFieldNames(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "canonical",
|
||||
args: []string{
|
||||
"base", "+record-list", "--base-token", "app_x", "--table-id", "tbl_x",
|
||||
"--field-id", "Cost--USD", "--field-id", "Cost--USD",
|
||||
},
|
||||
wantParam: "--field-id",
|
||||
},
|
||||
{
|
||||
name: "fields alias",
|
||||
args: []string{
|
||||
"base", "+record-list", "--base-token", "app_x", "--table-id", "tbl_x",
|
||||
"--fields", `["Cost--USD","Cost--USD"]`,
|
||||
},
|
||||
wantParam: "--fields",
|
||||
},
|
||||
{
|
||||
name: "field names alias",
|
||||
args: []string{
|
||||
"base", "+record-list", "--base-token", "app_x", "--table-id", "tbl_x",
|
||||
"--field-names", "Cost--USD", "--field-names", "Cost--USD",
|
||||
},
|
||||
wantParam: "--field-names",
|
||||
},
|
||||
{
|
||||
name: "json projection",
|
||||
args: []string{
|
||||
"base", "+record-search", "--base-token", "app_x", "--table-id", "tbl_x",
|
||||
"--json", `{"keyword":"cost","search_fields":["Name"],"select_fields":["Cost--USD","Cost--USD"]}`,
|
||||
},
|
||||
wantParam: "--json",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := runBaseDryRun(t, 2, tc.args...)
|
||||
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), result.Stderr)
|
||||
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), result.Stderr)
|
||||
require.Equal(t, tc.wantParam, gjson.Get(result.Stderr, "error.param").String(), result.Stderr)
|
||||
require.Equal(t, int64(1), gjson.Get(result.Stderr, "error.params.#").Int(), result.Stderr)
|
||||
require.Equal(t, tc.wantParam, gjson.Get(result.Stderr, "error.params.0.name").String(), result.Stderr)
|
||||
require.Contains(t, gjson.Get(result.Stderr, "error.message").String(), "duplicate field id")
|
||||
require.Empty(t, result.Stdout)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,21 +18,6 @@ import (
|
||||
|
||||
const cleanupTimeout = 30 * time.Second
|
||||
|
||||
func runBaseDryRun(t *testing.T, wantExitCode int, args ...string) *clie2e.Result {
|
||||
t.Helper()
|
||||
setBaseDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
requestArgs := append([]string(nil), args...)
|
||||
requestArgs = append(requestArgs, "--dry-run")
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: requestArgs, DefaultAs: "user"})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, wantExitCode)
|
||||
return result
|
||||
}
|
||||
|
||||
func reportCleanupFailure(parentT *testing.T, prefix string, result *clie2e.Result, err error) {
|
||||
parentT.Helper()
|
||||
|
||||
|
||||
@@ -88,6 +88,12 @@ func SkipWithoutTenantAccessToken(t *testing.T) {
|
||||
if token == "" || appID == "" {
|
||||
t.Skip("skipped: tenant test credentials not set")
|
||||
}
|
||||
|
||||
// Scope standard env credentials to tests that explicitly require a live
|
||||
// tenant token. Keeping TEST_* variables in the gotestsum parent prevents
|
||||
// config and dry-run CLI subprocesses from activating the env provider.
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", appID)
|
||||
t.Setenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN", token)
|
||||
}
|
||||
|
||||
// DryRunGet reads a field from the dry-run payload inside the standard success envelope.
|
||||
@@ -239,36 +245,14 @@ func buildCommandEnv(req Request) []string {
|
||||
for k, v := range req.Env {
|
||||
overrides[k] = v
|
||||
}
|
||||
|
||||
// Shared TEST_* credentials are fallbacks for explicitly identified live
|
||||
// commands. Existing standard env (including dry-run fixtures) and
|
||||
// per-request overrides always take precedence.
|
||||
switch req.DefaultAs {
|
||||
case "bot":
|
||||
if !hasCredentialEnv(req.Env,
|
||||
"LARKSUITE_CLI_APP_ID",
|
||||
"LARKSUITE_CLI_APP_SECRET",
|
||||
"LARKSUITE_CLI_TENANT_ACCESS_TOKEN",
|
||||
) {
|
||||
appID := os.Getenv("TEST_BOT1_APP_ID")
|
||||
token := os.Getenv("TEST_TENANT_ACCESS_TOKEN")
|
||||
if appID != "" && token != "" {
|
||||
overrides["LARKSUITE_CLI_APP_ID"] = appID
|
||||
overrides["LARKSUITE_CLI_TENANT_ACCESS_TOKEN"] = token
|
||||
}
|
||||
// Keep user-token injection scoped to user-only test commands so bot
|
||||
// commands retain the process-level bot credentials.
|
||||
if req.DefaultAs == "user" {
|
||||
if appID := os.Getenv("TEST_BOT1_APP_ID"); appID != "" {
|
||||
overrides["LARKSUITE_CLI_APP_ID"] = appID
|
||||
}
|
||||
case "user":
|
||||
if !hasCredentialEnv(req.Env,
|
||||
"LARKSUITE_CLI_APP_ID",
|
||||
"LARKSUITE_CLI_APP_SECRET",
|
||||
"LARKSUITE_CLI_USER_ACCESS_TOKEN",
|
||||
) {
|
||||
appID := os.Getenv("TEST_BOT1_APP_ID")
|
||||
token := os.Getenv("TEST_USER_ACCESS_TOKEN")
|
||||
if appID != "" && token != "" {
|
||||
overrides["LARKSUITE_CLI_APP_ID"] = appID
|
||||
overrides["LARKSUITE_CLI_USER_ACCESS_TOKEN"] = token
|
||||
}
|
||||
if token := os.Getenv("TEST_USER_ACCESS_TOKEN"); token != "" {
|
||||
overrides["LARKSUITE_CLI_USER_ACCESS_TOKEN"] = token
|
||||
}
|
||||
}
|
||||
for k, v := range overrides {
|
||||
@@ -288,18 +272,6 @@ func buildCommandEnv(req Request) []string {
|
||||
return env
|
||||
}
|
||||
|
||||
func hasCredentialEnv(requestEnv map[string]string, keys ...string) bool {
|
||||
for _, key := range keys {
|
||||
if _, ok := requestEnv[key]; ok {
|
||||
return true
|
||||
}
|
||||
if os.Getenv(key) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RunCmdWithRetry reruns a command when the result matches the configured retry condition.
|
||||
func RunCmdWithRetry(ctx context.Context, req Request, opts RetryOptions) (*Result, error) {
|
||||
if opts.Attempts <= 0 {
|
||||
|
||||
@@ -190,7 +190,7 @@ func TestSkipWithoutTenantAccessToken(t *testing.T) {
|
||||
assert.True(t, ran)
|
||||
})
|
||||
|
||||
t.Run("accepts shared tenant credentials without mutating standard env", func(t *testing.T) {
|
||||
t.Run("scopes shared tenant credentials to the requiring test", func(t *testing.T) {
|
||||
t.Setenv("TEST_BOT1_APP_ID", "shared-test-app")
|
||||
t.Setenv("TEST_TENANT_ACCESS_TOKEN", "shared-test-token")
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "")
|
||||
@@ -198,8 +198,8 @@ func TestSkipWithoutTenantAccessToken(t *testing.T) {
|
||||
|
||||
ok := t.Run("inner", func(t *testing.T) {
|
||||
SkipWithoutTenantAccessToken(t)
|
||||
assert.Empty(t, os.Getenv("LARKSUITE_CLI_APP_ID"))
|
||||
assert.Empty(t, os.Getenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN"))
|
||||
assert.Equal(t, "shared-test-app", os.Getenv("LARKSUITE_CLI_APP_ID"))
|
||||
assert.Equal(t, "shared-test-token", os.Getenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN"))
|
||||
})
|
||||
require.True(t, ok)
|
||||
assert.Empty(t, os.Getenv("LARKSUITE_CLI_APP_ID"))
|
||||
@@ -274,65 +274,25 @@ func TestRunCmd(t *testing.T) {
|
||||
assert.Equal(t, "hello from stdin\n", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("injects shared credentials by requested identity", func(t *testing.T) {
|
||||
t.Run("injects user token env only for user commands", func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "")
|
||||
t.Setenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN", "")
|
||||
t.Setenv("LARKSUITE_CLI_USER_ACCESS_TOKEN", "")
|
||||
t.Setenv("TEST_BOT1_APP_ID", "cli_app_test")
|
||||
t.Setenv("TEST_TENANT_ACCESS_TOKEN", "tat_test")
|
||||
t.Setenv("TEST_USER_ACCESS_TOKEN", "uat_test")
|
||||
|
||||
env := buildCommandEnv(Request{DefaultAs: "bot"})
|
||||
assert.Contains(t, env, "LARKSUITE_CLI_APP_ID=cli_app_test")
|
||||
assert.Contains(t, env, "LARKSUITE_CLI_TENANT_ACCESS_TOKEN=tat_test")
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_USER_ACCESS_TOKEN=uat_test")
|
||||
|
||||
env = buildCommandEnv(Request{DefaultAs: "user"})
|
||||
env := buildCommandEnv(Request{DefaultAs: "user"})
|
||||
assert.Contains(t, env, "LARKSUITE_CLI_APP_ID=cli_app_test")
|
||||
assert.Contains(t, env, "LARKSUITE_CLI_USER_ACCESS_TOKEN=uat_test")
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_TENANT_ACCESS_TOKEN=tat_test")
|
||||
|
||||
env = buildCommandEnv(Request{DefaultAs: "bot"})
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_APP_ID=cli_app_test")
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_USER_ACCESS_TOKEN=uat_test")
|
||||
|
||||
env = buildCommandEnv(Request{})
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_APP_ID=cli_app_test")
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_TENANT_ACCESS_TOKEN=tat_test")
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_USER_ACCESS_TOKEN=uat_test")
|
||||
})
|
||||
|
||||
t.Run("preserves standard dry-run bot credentials", func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "dry-run-app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "dry-run-secret")
|
||||
t.Setenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN", "")
|
||||
t.Setenv("TEST_BOT1_APP_ID", "shared-test-app")
|
||||
t.Setenv("TEST_TENANT_ACCESS_TOKEN", "shared-test-token")
|
||||
|
||||
env := buildCommandEnv(Request{DefaultAs: "bot"})
|
||||
assert.Contains(t, env, "LARKSUITE_CLI_APP_ID=dry-run-app")
|
||||
assert.Contains(t, env, "LARKSUITE_CLI_APP_SECRET=dry-run-secret")
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_APP_ID=shared-test-app")
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_TENANT_ACCESS_TOKEN=shared-test-token")
|
||||
})
|
||||
|
||||
t.Run("request env overrides shared bot credentials", func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "")
|
||||
t.Setenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN", "")
|
||||
t.Setenv("TEST_BOT1_APP_ID", "shared-test-app")
|
||||
t.Setenv("TEST_TENANT_ACCESS_TOKEN", "shared-test-token")
|
||||
|
||||
env := buildCommandEnv(Request{
|
||||
DefaultAs: "bot",
|
||||
Env: map[string]string{
|
||||
"LARKSUITE_CLI_APP_ID": "request-app",
|
||||
"LARKSUITE_CLI_TENANT_ACCESS_TOKEN": "",
|
||||
},
|
||||
})
|
||||
assert.Contains(t, env, "LARKSUITE_CLI_APP_ID=request-app")
|
||||
assert.Contains(t, env, "LARKSUITE_CLI_TENANT_ACCESS_TOKEN=")
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_APP_ID=shared-test-app")
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_TENANT_ACCESS_TOKEN=shared-test-token")
|
||||
})
|
||||
|
||||
t.Run("retries structured retryable service errors by default", func(t *testing.T) {
|
||||
fake := newFakeCLI(t)
|
||||
statePath := filepath.Join(t.TempDir(), "retry-count")
|
||||
|
||||
@@ -91,11 +91,10 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
||||
"docs", "+update",
|
||||
"--doc", "doxcnDryRunE2E",
|
||||
"--command", "block_delete",
|
||||
"--block-id", "blkA, blkB, blkC",
|
||||
"--block-id", "blkA,blkB,blkC",
|
||||
"--dry-run",
|
||||
},
|
||||
wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E"},
|
||||
wantBody: map[string]any{"block_id": "blkA,blkB,blkC"},
|
||||
},
|
||||
{
|
||||
name: "history list",
|
||||
@@ -226,60 +225,3 @@ func TestDocs_CreateTitleDryRunPrependsContent(t *testing.T) {
|
||||
require.Equal(t, "markdown", clie2e.DryRunGet(out, "api.0.body.format").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "<title>Dry Run & Title</title>\n## Body", clie2e.DryRunGet(out, "api.0.body.content").String(), "stdout:\n%s", out)
|
||||
}
|
||||
|
||||
func TestDocs_CreateTitleDryRunNormalizesXMLTitle(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"docs", "+create",
|
||||
"--title", "Flag title",
|
||||
"--content", "<title>Content title</title><p>body</p>",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
require.Equal(t, "<title>Flag title</title>\n<p>body</p>", clie2e.DryRunGet(result.Stdout, "api.0.body.content").String())
|
||||
}
|
||||
|
||||
func TestDocs_DryRunRejectsUnsafeWriteInputs(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "multiline XML str_replace",
|
||||
args: []string{"docs", "+update", "--doc", "doxcnDryRunE2E", "--command", "str_replace", "--pattern", "line one\nline two", "--content", "replacement", "--dry-run"},
|
||||
want: "must be inline",
|
||||
},
|
||||
{
|
||||
name: "duplicate block delete ID",
|
||||
args: []string{"docs", "+update", "--doc", "doxcnDryRunE2E", "--command", "block_delete", "--block-id", "blkA,blkA", "--dry-run"},
|
||||
want: "duplicate ID",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: tt.args, DefaultAs: "bot"})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
require.Contains(t, result.Stdout+"\n"+result.Stderr, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,8 +35,6 @@ func TestOKR_CycleListDryRun(t *testing.T) {
|
||||
output := result.Stdout
|
||||
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/cycles"), "dry-run should contain API path, got: %s", output)
|
||||
assert.True(t, strings.Contains(output, "ou_dryrun_test"), "dry-run should contain user-id, got: %s", output)
|
||||
assert.Equal(t, int64(100), clie2e.DryRunGet(output, "api.0.params.page_size").Int(), "dry-run should contain default page_size=100, got: %s", output)
|
||||
assert.False(t, clie2e.DryRunGet(output, "api.0.params.page_token").Exists(), "empty page_token should be omitted, got: %s", output)
|
||||
}
|
||||
|
||||
// TestOKR_CycleListDryRun_WithTimeRange validates +cycle-list dry-run with --time-range flag.
|
||||
@@ -59,26 +57,3 @@ func TestOKR_CycleListDryRun_WithTimeRange(t *testing.T) {
|
||||
output := result.Stdout
|
||||
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/cycles"), "dry-run should contain API path, got: %s", output)
|
||||
}
|
||||
|
||||
// TestOKR_CycleListDryRun_WithPagination validates +cycle-list dry-run with explicit pagination.
|
||||
func TestOKR_CycleListDryRun_WithPagination(t *testing.T) {
|
||||
setDryRunConfigEnv(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"okr", "+cycle-list",
|
||||
"--user-id", "ou_dryrun_test",
|
||||
"--page-size", "20",
|
||||
"--page-token", "next_page",
|
||||
"--dry-run",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := result.Stdout
|
||||
assert.Equal(t, int64(20), clie2e.DryRunGet(output, "api.0.params.page_size").Int(), "dry-run should contain page_size=20, got: %s", output)
|
||||
assert.Equal(t, "next_page", clie2e.DryRunGet(output, "api.0.params.page_token").String(), "dry-run should contain page_token, got: %s", output)
|
||||
}
|
||||
|
||||
@@ -298,8 +298,6 @@ func TestOKR_ProgressListDryRun_Objective(t *testing.T) {
|
||||
output := result.Stdout
|
||||
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/objectives/123456789/progresses"), "dry-run should contain objective API path, got: %s", output)
|
||||
assert.True(t, strings.Contains(output, "GET"), "dry-run should contain GET method, got: %s", output)
|
||||
assert.Equal(t, int64(100), clie2e.DryRunGet(output, "api.0.params.page_size").Int(), "dry-run should contain default page_size=100, got: %s", output)
|
||||
assert.False(t, clie2e.DryRunGet(output, "api.0.params.page_token").Exists(), "empty page_token should be omitted, got: %s", output)
|
||||
}
|
||||
|
||||
// TestOKR_ProgressListDryRun_KeyResult validates +progress-list dry-run for key_result.
|
||||
@@ -323,27 +321,3 @@ func TestOKR_ProgressListDryRun_KeyResult(t *testing.T) {
|
||||
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/key_results/987654321/progresses"), "dry-run should contain key_result API path, got: %s", output)
|
||||
assert.True(t, strings.Contains(output, "GET"), "dry-run should contain GET method, got: %s", output)
|
||||
}
|
||||
|
||||
// TestOKR_ProgressListDryRun_WithPagination validates +progress-list dry-run with explicit pagination.
|
||||
func TestOKR_ProgressListDryRun_WithPagination(t *testing.T) {
|
||||
setDryRunConfigEnv(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"okr", "+progress-list",
|
||||
"--target-id", "123456789",
|
||||
"--target-type", "objective",
|
||||
"--page-size", "25",
|
||||
"--page-token", "next_page",
|
||||
"--dry-run",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := result.Stdout
|
||||
assert.Equal(t, int64(25), clie2e.DryRunGet(output, "api.0.params.page_size").Int(), "dry-run should contain page_size=25, got: %s", output)
|
||||
assert.Equal(t, "next_page", clie2e.DryRunGet(output, "api.0.params.page_token").String(), "dry-run should contain page_token, got: %s", output)
|
||||
}
|
||||
|
||||
@@ -18,62 +18,7 @@ import (
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// --- Dry-run E2E tests for +create, +batch-create, +reorder, +weight ---
|
||||
|
||||
// TestOKR_CreateDryRun_Objective validates +create dry-run for objective creation.
|
||||
func TestOKR_CreateDryRun_Objective(t *testing.T) {
|
||||
setDryRunConfigEnv(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"okr", "+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123456",
|
||||
"--content", `{"text":"Objective 1","mention":["ou_123"]}`,
|
||||
"--dry-run",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := result.Stdout
|
||||
assert.Equal(t, "POST", gjson.Get(output, "data.api.0.method").String(), "dry-run should contain POST method")
|
||||
assert.Equal(t, "/open-apis/okr/v2/cycles/123456/objectives", gjson.Get(output, "data.api.0.url").String(), "dry-run should contain objective API path")
|
||||
assert.Equal(t, "123456", gjson.Get(output, "data.api.0.params.cycle_id").String(), "dry-run should contain cycle-id query param")
|
||||
assert.Equal(t, "open_id", gjson.Get(output, "data.api.0.params.user_id_type").String(), "dry-run should contain default user-id-type")
|
||||
assert.Equal(t, "Objective 1", gjson.Get(output, "data.api.0.body.content.blocks.0.paragraph.elements.0.text_run.text").String(), "dry-run should contain serialized content text")
|
||||
assert.Equal(t, "ou_123", gjson.Get(output, "data.api.0.body.content.blocks.0.paragraph.elements.1.mention.user_id").String(), "dry-run should contain serialized mention")
|
||||
}
|
||||
|
||||
// TestOKR_CreateDryRun_KeyResult validates +create dry-run for key-result creation.
|
||||
func TestOKR_CreateDryRun_KeyResult(t *testing.T) {
|
||||
setDryRunConfigEnv(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"okr", "+create",
|
||||
"--level", "key-result",
|
||||
"--objective-id", "789",
|
||||
"--style", "richtext",
|
||||
"--content", `{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"KR 1"}}]}}]}`,
|
||||
"--user-id-type", "user_id",
|
||||
"--dry-run",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := result.Stdout
|
||||
assert.Equal(t, "POST", gjson.Get(output, "data.api.0.method").String(), "dry-run should contain POST method")
|
||||
assert.Equal(t, "/open-apis/okr/v2/objectives/789/key_results", gjson.Get(output, "data.api.0.url").String(), "dry-run should contain key-result API path")
|
||||
assert.Equal(t, "789", gjson.Get(output, "data.api.0.params.objective_id").String(), "dry-run should contain objective-id query param")
|
||||
assert.Equal(t, "user_id", gjson.Get(output, "data.api.0.params.user_id_type").String(), "dry-run should contain explicit user-id-type")
|
||||
assert.Equal(t, "KR 1", gjson.Get(output, "data.api.0.body.content.blocks.0.paragraph.elements.0.text_run.text").String(), "dry-run should contain rich-text body")
|
||||
}
|
||||
// --- Dry-run E2E tests for +batch-create, +reorder, +weight ---
|
||||
|
||||
// TestOKR_BatchCreateDryRun validates +batch-create dry-run output contains expected API paths.
|
||||
func TestOKR_BatchCreateDryRun(t *testing.T) {
|
||||
@@ -438,44 +383,6 @@ func cleanupLiveTest(t *testing.T, created []liveTestCreated) {
|
||||
}
|
||||
}
|
||||
|
||||
func createLiveObjective(t *testing.T, ctx context.Context, cycleID string, suffix string) liveTestCreated {
|
||||
t.Helper()
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"okr", "+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", cycleID,
|
||||
"--content", fmt.Sprintf(`{"text":"E2E Single Objective %s","mention":["ou_test"]}`, suffix),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "failed to create live objective")
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
objectiveID := gjson.Get(result.Stdout, "data.objective_id").String()
|
||||
require.NotEmpty(t, objectiveID, "objective_id should not be empty")
|
||||
|
||||
return liveTestCreated{ObjectiveID: objectiveID}
|
||||
}
|
||||
|
||||
func createLiveKeyResult(t *testing.T, ctx context.Context, objectiveID string, suffix string) string {
|
||||
t.Helper()
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"okr", "+create",
|
||||
"--level", "key-result",
|
||||
"--objective-id", objectiveID,
|
||||
"--content", fmt.Sprintf(`{"text":"E2E Single KR %s","mention":["ou_test"]}`, suffix),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "failed to create live key result")
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
keyResultID := gjson.Get(result.Stdout, "data.key_result_id").String()
|
||||
require.NotEmpty(t, keyResultID, "key_result_id should not be empty")
|
||||
|
||||
return keyResultID
|
||||
}
|
||||
|
||||
// TestOKR_BatchCreateLive validates +batch-create with real API calls: create, verify, cleanup.
|
||||
func TestOKR_BatchCreateLive(t *testing.T) {
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
@@ -525,87 +432,6 @@ func TestOKR_BatchCreateLive(t *testing.T) {
|
||||
assert.Equal(t, len(created), foundCount, "all created objectives should be found in cycle detail")
|
||||
}
|
||||
|
||||
// TestOKR_CreateLive_Objective validates +create objective with real API calls: create, verify, cleanup.
|
||||
func TestOKR_CreateLive_Objective(t *testing.T) {
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
cycleID := getTestCycleID(t)
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
created := createLiveObjective(t, ctx, cycleID, suffix)
|
||||
t.Cleanup(func() {
|
||||
cleanupLiveTest(t, []liveTestCreated{created})
|
||||
})
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"okr", "+cycle-detail",
|
||||
"--cycle-id", cycleID,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
objectives := gjson.Get(result.Stdout, "data.objectives").Array()
|
||||
found := false
|
||||
for _, obj := range objectives {
|
||||
if obj.Get("id").String() == created.ObjectiveID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "created objective should be visible in cycle detail")
|
||||
}
|
||||
|
||||
// TestOKR_CreateLive_KeyResultUnderExistingObjective validates +create key-result under an existing objective.
|
||||
func TestOKR_CreateLive_KeyResultUnderExistingObjective(t *testing.T) {
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
cycleID := getTestCycleID(t)
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
created := createLiveObjective(t, ctx, cycleID, suffix)
|
||||
t.Cleanup(func() {
|
||||
cleanupLiveTest(t, []liveTestCreated{created})
|
||||
})
|
||||
|
||||
keyResultID := createLiveKeyResult(t, ctx, created.ObjectiveID, suffix)
|
||||
created.KRIDs = append(created.KRIDs, keyResultID)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"okr", "+cycle-detail",
|
||||
"--cycle-id", cycleID,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
objectives := gjson.Get(result.Stdout, "data.objectives").Array()
|
||||
foundObjective := false
|
||||
foundKR := false
|
||||
for _, obj := range objectives {
|
||||
if obj.Get("id").String() != created.ObjectiveID {
|
||||
continue
|
||||
}
|
||||
foundObjective = true
|
||||
for _, kr := range obj.Get("key_results").Array() {
|
||||
if kr.Get("id").String() == keyResultID {
|
||||
foundKR = true
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
assert.True(t, foundObjective, "created objective should be visible in cycle detail")
|
||||
assert.True(t, foundKR, "created key result should be visible under the created objective")
|
||||
}
|
||||
|
||||
// TestOKR_ReorderLive validates +reorder with real API calls: create, reorder, verify, cleanup.
|
||||
func TestOKR_ReorderLive(t *testing.T) {
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestWhiteboardExportDryRun_RequestShapes(t *testing.T) {
|
||||
setWhiteboardDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantMethod string
|
||||
wantSuffix string
|
||||
wantBody map[string]string
|
||||
}{
|
||||
{
|
||||
name: "preview",
|
||||
args: []string{
|
||||
"whiteboard", "+export",
|
||||
"--whiteboard-token", "wbcnDryRunPreview",
|
||||
"--output-type", "preview",
|
||||
"--output", "preview",
|
||||
"--dry-run",
|
||||
},
|
||||
wantMethod: "GET",
|
||||
wantSuffix: "/download_as_image",
|
||||
},
|
||||
{
|
||||
name: "svg",
|
||||
args: []string{
|
||||
"whiteboard", "+export",
|
||||
"--whiteboard-token", "wbcnDryRunSvg",
|
||||
"--output-type", "svg",
|
||||
"--dry-run",
|
||||
},
|
||||
wantMethod: "POST",
|
||||
wantSuffix: "/export",
|
||||
wantBody: map[string]string{
|
||||
"export_type": "svg",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "source",
|
||||
args: []string{
|
||||
"whiteboard", "+export",
|
||||
"--whiteboard-token", "wbcnDryRunSource",
|
||||
"--output-type", "source",
|
||||
"--dry-run",
|
||||
},
|
||||
wantMethod: "GET",
|
||||
wantSuffix: "/nodes",
|
||||
},
|
||||
{
|
||||
name: "raw",
|
||||
args: []string{
|
||||
"whiteboard", "+export",
|
||||
"--whiteboard-token", "wbcnDryRunRaw",
|
||||
"--output-type", "raw",
|
||||
"--dry-run",
|
||||
},
|
||||
wantMethod: "GET",
|
||||
wantSuffix: "/nodes",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: tt.args,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := clie2e.DryRunGet(out, "api.#").Int(); got != 1 {
|
||||
t.Fatalf("api count=%d, want 1\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := clie2e.DryRunGet(out, "api.0.method").String(); got != tt.wantMethod {
|
||||
t.Fatalf("method=%q, want %q\nstdout:\n%s", got, tt.wantMethod, out)
|
||||
}
|
||||
gotURL := clie2e.DryRunGet(out, "api.0.url").String()
|
||||
if !strings.HasPrefix(gotURL, "/open-apis/board/v1/whiteboards/") || !strings.HasSuffix(gotURL, tt.wantSuffix) {
|
||||
t.Fatalf("url=%q, want board whiteboard URL ending %q\nstdout:\n%s", gotURL, tt.wantSuffix, out)
|
||||
}
|
||||
for key, want := range tt.wantBody {
|
||||
if got := clie2e.DryRunGet(out, "api.0.body."+key).String(); got != want {
|
||||
t.Fatalf("body.%s=%q, want %q\nstdout:\n%s", key, got, want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardQueryDryRun_LegacySmoke(t *testing.T) {
|
||||
setWhiteboardDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"whiteboard", "+query",
|
||||
"--whiteboard-token", "wbcnDryRunLegacy",
|
||||
"--output_as", "image",
|
||||
"--output", "preview",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := clie2e.DryRunGet(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("method=%q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
gotURL := clie2e.DryRunGet(out, "api.0.url").String()
|
||||
if !strings.HasPrefix(gotURL, "/open-apis/board/v1/whiteboards/") || !strings.HasSuffix(gotURL, "/download_as_image") {
|
||||
t.Fatalf("url=%q, want preview download\nstdout:\n%s", gotURL, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardExportSelectorRequiredBeforeAuth(t *testing.T) {
|
||||
setWhiteboardDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
t.Run("export requires output-type", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"whiteboard", "+export",
|
||||
"--whiteboard-token", "wbcnMissingSelector",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
output := result.Stdout + "\n" + result.Stderr
|
||||
if got := gjson.Get(output, "error.type").String(); got != "validation" {
|
||||
t.Fatalf("error.type=%q, want validation\nstdout:\n%s\nstderr:\n%s", got, result.Stdout, result.Stderr)
|
||||
}
|
||||
if got := gjson.Get(output, "error.message").String(); !strings.Contains(got, "output-type") {
|
||||
t.Fatalf("error.message=%q, want output-type\nstdout:\n%s\nstderr:\n%s", got, result.Stdout, result.Stderr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("legacy query requires output_as", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"whiteboard", "+query",
|
||||
"--whiteboard-token", "wbcnMissingSelector",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
output := result.Stdout + "\n" + result.Stderr
|
||||
if got := gjson.Get(output, "error.type").String(); got != "validation" {
|
||||
t.Fatalf("error.type=%q, want validation\nstdout:\n%s\nstderr:\n%s", got, result.Stdout, result.Stderr)
|
||||
}
|
||||
if got := gjson.Get(output, "error.message").String(); !strings.Contains(got, "output_as") {
|
||||
t.Fatalf("error.message=%q, want output_as\nstdout:\n%s\nstderr:\n%s", got, result.Stdout, result.Stderr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func setWhiteboardDryRunEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "whiteboard_dryrun_test")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "whiteboard_dryrun_secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWhiteboardExportPreview_JPEGLiveWorkflow(t *testing.T) {
|
||||
token := os.Getenv("LARK_WHITEBOARD_E2E_TOKEN")
|
||||
if token == "" {
|
||||
t.Skip("skipped: LARK_WHITEBOARD_E2E_TOKEN not set")
|
||||
}
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
workDir := t.TempDir()
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"whiteboard", "+export",
|
||||
"--whiteboard-token", token,
|
||||
"--output-type", "preview",
|
||||
"--output", "preview",
|
||||
"--overwrite",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
Format: "json",
|
||||
WorkDir: workDir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
saved := filepath.Join(workDir, "preview.jpg")
|
||||
data, err := os.ReadFile(saved)
|
||||
require.NoError(t, err, "expected JPEG preview at %s\nstdout:\n%s\nstderr:\n%s", saved, result.Stdout, result.Stderr)
|
||||
require.True(t, isJPEG(data), "expected JPEG data in %s", saved)
|
||||
|
||||
mismatch, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"whiteboard", "+export",
|
||||
"--whiteboard-token", token,
|
||||
"--output-type", "preview",
|
||||
"--output", "preview.png",
|
||||
"--overwrite",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
Format: "json",
|
||||
WorkDir: workDir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
mismatch.AssertExitCode(t, 2)
|
||||
if !strings.Contains(mismatch.Stdout+"\n"+mismatch.Stderr, "failed_precondition") {
|
||||
t.Fatalf("expected failed_precondition for mismatched extension\nstdout:\n%s\nstderr:\n%s", mismatch.Stdout, mismatch.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func isJPEG(data []byte) bool {
|
||||
return len(data) >= 3 && data[0] == 0xff && data[1] == 0xd8 && data[2] == 0xff
|
||||
}
|
||||
Reference in New Issue
Block a user