Compare commits

...

13 Commits

Author SHA1 Message Date
liangshuo-1
b37adfd0ee chore(release): v1.0.22 (#719)
Change-Id: If383f91a8b934a4feec3ff6d371a3f2f6a94ec09
2026-04-29 20:04:06 +08:00
bytedance-zxy
082275f32b feat(task): add resource agent & agent_task_step_info (#693)
Change-Id: I3b2d8ee72361aee9b68a5bbbafcf594f220d3105
2026-04-29 19:13:05 +08:00
zero-my
2eb9fae575 Feat/task app members (#712)
* feat: support app task members by id

* docs: clarify task member id formats
2026-04-29 19:04:27 +08:00
sang-neo03
418192507e fix(install): make Windows zip extraction resilient (issue #603) (#713)
The Windows extraction step relied on `powershell -Command Expand-Archive`,
which fails when:
  - Microsoft.PowerShell.Archive (a script module) cannot be loaded due to
    PSModulePath shadowing (Store-installed pwsh injecting WindowsApps
    paths) or ExecutionPolicy Restricted (issue #603), or
  - the temp directory contains characters that corrupt PowerShell string
    parsing (e.g. a single quote in TEMP).

Switch to a two-tier extraction:
  1. Primary: Add-Type System.IO.Compression.FileSystem +
     [ZipFile]::ExtractToDirectory. Bypasses the PowerShell module system
     entirely. .NET 4.5+, available on Win 8 / Server 2012 by default and
     widely on Win 7 SP1.
  2. Fallback: Expand-Archive -LiteralPath, kept for the rare host without
     .NET 4.5 but with PS 5.0+ (e.g. Win 7 SP1 with WMF 5).

Both paths pass file paths through env vars ($env:LARK_CLI_ARCHIVE /
$env:LARK_CLI_DEST) so quoting / wildcard chars in the path can no longer
break command parsing. -LiteralPath ensures Expand-Archive treats the value
literally rather than as a wildcard pattern. $ErrorActionPreference='Stop'
makes non-terminating cmdlet errors propagate as non-zero exit codes.

Also drop `stdio: "ignore"` so the actual PowerShell error surfaces in the
postinstall log when both paths fail, instead of leaving users with
"Command failed: powershell ..." with no detail.

Verified on Windows 10 + PS 5.1:
  - Reproduced #603 with shadow Microsoft.PowerShell.Archive +
    Restricted ExecutionPolicy: original install.js fails, patched
    install.js succeeds.
  - Reproduced single-quote-in-TEMP path corruption: original fails,
    patched succeeds.
  - Fallback path verified end-to-end with primary forced to fail.
  - Normal-environment install: no regression.
2026-04-29 17:50:46 +08:00
liangshuo-1
7752afab96 fix(config/init): respect --brand flag in --new mode (#711)
* feat(contact +search-user): add --queries multi-name fanout

Add --queries CSV flag to lark-cli contact +search-user for parallel
multi-name fanout (up to 20 entries, partial-failure tolerant).

Output shape in fanout mode:
- data.users[] rows carry matched_query (string)
- data.queries[] sidecar lists each input with {query, error?, has_more}
- top-level data.has_more removed (per-query in queries[])
- error is omitempty; absent on success

Single --query mode is byte-for-byte unchanged (regression-guarded).
--queries is mutually exclusive with --query and --user-ids; bool
filters propagate to every sub-request.

Workers run with WaitGroup + buffered semaphore + index-slot writes;
each has defer recover() converting panics to internal error: ... in
the sidecar (no stack to stderr). Pre-canceled context returns
context canceled without making the request.

All-failed exit propagates first failure's HTTP/API code via ErrAPI;
falls back to ExitInternal for transport/parse/panic/ctx-canceled
(avoids emitting code 0, which means success in the Lark protocol).
HTTP non-200 ErrMsg now includes truncated response body for diagnosis.

Drive-by: signature field is now omitempty (mostly empty in practice).

Infrastructure:
- internal/httpmock gains BodyFilter/OnMatch/Reusable/CapturedBodies
  hooks to support concurrent stub-driven tests
- internal/output adds 'users' to knownArrayFields so CSV picks the
  primary array correctly

Change-Id: I3c14195fb8e094ae150002d90c36a0e4a0cc97d0

* fix(config/init): use parseBrand(opts.Brand) instead of hardcoded BrandFeishu in --new mode

The --new flag was ignoring the --brand flag and always passing BrandFeishu
to runCreateAppFlow. Now it correctly uses parseBrand(opts.Brand) to
respect the user's --brand parameter (e.g., --brand lark for international).

Change-Id: I1d4d78b3d586142b0210e6ceaeeb467b14e9c1a1
2026-04-29 17:13:47 +08:00
liangshuo-1
f7a56f38b1 feat(contact +search-user): add --queries multi-name fanout (#707)
Add --queries CSV flag to lark-cli contact +search-user for parallel
multi-name fanout (up to 20 entries, partial-failure tolerant).

Output shape in fanout mode:
- data.users[] rows carry matched_query (string)
- data.queries[] sidecar lists each input with {query, error?, has_more}
- top-level data.has_more removed (per-query in queries[])
- error is omitempty; absent on success

Single --query mode is byte-for-byte unchanged (regression-guarded).
--queries is mutually exclusive with --query and --user-ids; bool
filters propagate to every sub-request.

Workers run with WaitGroup + buffered semaphore + index-slot writes;
each has defer recover() converting panics to internal error: ... in
the sidecar (no stack to stderr). Pre-canceled context returns
context canceled without making the request.

All-failed exit propagates first failure's HTTP/API code via ErrAPI;
falls back to ExitInternal for transport/parse/panic/ctx-canceled
(avoids emitting code 0, which means success in the Lark protocol).
HTTP non-200 ErrMsg now includes truncated response body for diagnosis.

Drive-by: signature field is now omitempty (mostly empty in practice).

Infrastructure:
- internal/httpmock gains BodyFilter/OnMatch/Reusable/CapturedBodies
  hooks to support concurrent stub-driven tests
- internal/output adds 'users' to knownArrayFields so CSV picks the
  primary array correctly

Change-Id: I3c14195fb8e094ae150002d90c36a0e4a0cc97d0
2026-04-29 17:03:21 +08:00
sang-neo03
ea056d132e feat(install): enhance binary URL resolution with environment variabl… (#690)
* feat(install): enhance binary URL resolution with environment variable support

* fix(install): defer mirror resolution into install() to surface friendly errors

resolveMirrorUrl was called at module scope, so an invalid
LARK_CLI_DOWNLOAD_HOST (e.g. file://) threw before the try/catch in the
postinstall entrypoint, dumping a raw stack trace instead of the recovery
guidance with proxy/registry/host-override options.

Move resolution into install() via getMirrorUrl() so the throw is caught
and the user sees the actionable help text.

* fix(install): keep npmmirror fallback when npm_config_registry is set

resolveMirrorUrl returned a single URL, so any non-default
npm_config_registry replaced the npmmirror fallback entirely. Corporate
npm proxies (Verdaccio, Artifactory, Nexus) often only serve npm package
metadata and don't host /-/binary/<pkg>/..., turning previously-working
installs into 404s when GitHub is unreachable.

Switch to resolveMirrorUrls returning an ordered chain:
  - LARK_CLI_DOWNLOAD_HOST set → [override] only (explicit user choice;
    no silent leak to npmmirror).
  - Otherwise → [derived_from_registry?, npmmirror_default]; npmmirror
    is always the final entry, restoring the pre-PR safety net.

install() now walks [GITHUB_URL, ...mirrorUrls] and stops at the first
success.

* fix(install): skip GitHub when LARK_CLI_DOWNLOAD_HOST is set

The download loop unconditionally tried GITHUB_URL first, even when the
user explicitly named a download host. In locked-down networks, probing
github.com can trigger DLP / firewall alerts and contradicts the
explicit-override semantics ("use only this host, nothing else").

When LARK_CLI_DOWNLOAD_HOST is set, the chain is now just [override].
When it isn't, behavior is unchanged: [GITHUB_URL, derived?, npmmirror].

* refactor(install): drop LARK_CLI_DOWNLOAD_HOST env override

Issue #640 only asked for --registry to influence the binary download.
The LARK_CLI_DOWNLOAD_HOST escape hatch was added speculatively for
locked-down networks but is YAGNI — users in those environments already
have npm-level mirrors (--registry) or proxy controls (https_proxy).

Removing it shrinks the surface area:
  - delete parseDownloadBase() and its strict https-only validation
  - drop the install() branch that skipped GitHub on explicit override
  - simplify failure-help message to two recovery options

Resolution chain becomes [GITHUB, derived_from_npm_config_registry?,
npmmirror_default]. The npmmirror tail still preserves the pre-PR safety
net when a corp registry doesn't actually serve /-/binary/<pkg>/...

End-to-end verified on Linux + Windows via real `npm install -g <tgz>`:
all four user scenarios pass, with the issue #640 path (--registry=
npmmirror + GitHub blocked) finishing in 2s on Linux / 6s on Windows.
2026-04-29 16:46:30 +08:00
kongenpei
7fc963f455 docs: clarify base search routing (#708)
* docs: clarify base search routing

* docs: refine base search guidance

* docs: clarify complex base search cases

* docs: define complex base search

---------

Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-04-29 16:21:34 +08:00
ethan-zhx
520acb618c feat(slides):slides template (#684)
* feat(slides):slides template

chore:add scripts

feat(slides): add template-first guidance to lark-slides skill

docs: restructure slide templates to flat layout with catalog routing

- Move 42 template XMLs from 8 category subdirs into single templates/ dir
- Encode category in filename: {category}--{name}.xml
- Add template-catalog.md as lightweight routing index (scene/tone/formality)
- Update SKILL.md workflow to include template matching step (Step 2)
- Update style guide to reference templates instead of hardcoded colors

docs: add categorized slides template XML references

Add 42 slide templates extracted from API responses, organized by category:
office(8), product(6), operations(4), marketing(8), hr(3), administration(4), personal(6), misc(3)

Change-Id: Ib3d85ffd7563a1693d4ed603fe9435fd716890ca

* refactor: optimize lark slides template

Change-Id: I40ab98d3882095262cc533bcb9baf614cff9adfa

---------

Co-authored-by: caichengjie.viper <caichengjie.viper@bytedance.com>
2026-04-29 16:00:03 +08:00
chanthuang
dce2beb91c feat(mail): support calendar events in emails (#646)
* feat(ics): add RFC 5545 iCalendar generator and parser

Add shortcuts/mail/ics package:
- builder.go: generates METHOD:REQUEST ICS with VEVENT, ORGANIZER,
  ATTENDEE, DTSTART/DTEND with timezone, UID, and X-LARK-MAIL-DRAFT
- parser.go: parses ICS into ParsedEvent struct, detects IsLarkDraft
- Handles CN quoting, control-char sanitization, email validation,
  line folding per RFC 5545, and TZID edge cases

Change-Id: I01d13285a57a5a4de50891c54d655efa8423c3c1

* feat(mail): support calendar events in emails

- Add --event-summary/start/end/location flags to +send, +reply,
  +reply-all, +forward, +draft-create
- Build ICS and embed as text/calendar in multipart/alternative
- Validate event time range and enforce --event/--send-time mutual
  exclusion (extracted into validateEventSendTimeExclusion)
- CalendarBody() in emlbuilder places ICS correctly
- Exclude BCC from ATTENDEE list

Change-Id: Icf9e49ababebc4e8fcf36760ab613c64938c2744

* feat(mail): X-LARK-MAIL-DRAFT and read-only calendar guard

- ics.Build() writes X-LARK-MAIL-DRAFT:TRUE so Feishu client
  recognizes CLI-created calendar events as editable
- ics.ParseEvent() detects IsLarkDraft field
- +draft-edit rejects --set-event-* on calendars without
  X-LARK-MAIL-DRAFT marker (read-only after send)
- Export FindPartByMediaType from draft package for cross-package use
- Add set_calendar/remove_calendar patch ops with full test coverage

Change-Id: I7d547a4b40880e8d4ee3fecf68864d6ea89e66cd

* feat(mail): forward preserves original calendar ICS

When forwarding an email that contains a calendar event (body_calendar),
pass through the original ICS bytes as text/calendar part if no new
--event-* flags are specified.

Change-Id: I67d2e82604eaf969cee8c7e0bedcf32198d12d57

* docs(mail): document calendar invitation feature

- Add --event-* params to +send, +reply, +reply-all, +forward,
  +draft-create, +draft-edit reference docs
- Add calendar_event output section to +message reference
- Add calendar invitation workflow to skill-template/domains/mail.md
- Regenerate SKILL.md via gen-skills

Change-Id: Iccacd06990d91e1cf3beb896d5b772d27e5e29ff

* fix(mail): reject --set-event-start/end/location without --set-event-summary

Change-Id: Icb651ff28ede526ff96b22e7b304b7bdea86d01f
Co-Authored-By: AI

* fix(mail): include --event-location in validateEventFlags; fix stale comment

Change-Id: I2f47016b6bfa11957dfe2c8c499cf36737efba53
Co-Authored-By: AI

* fix(mail): clear stale headers when wrapping single-leaf body in multipart/alternative

Change-Id: I29fe883c9151570f7939d372523b128cbea0b1ed
Co-Authored-By: AI

* fix(mail): add method=REQUEST to text/calendar MIME part created by set_calendar

Change-Id: I4d23674e20e4c42adab36385ff5ee8bb6d97625d
Co-Authored-By: AI

* fix(mail): use post-edit recipients for ICS attendees when --set-to combined with --set-event-*

Change-Id: I659e06635dd043f798d2f2e90d7dbca6e13d7f3d
Co-Authored-By: AI

* fix(mail): cover add_recipient/remove_recipient in ICS attendee resolution

Extract effectiveRecipients() to replay all three recipient op types
(set_recipients, add_recipient, remove_recipient) before building the
ICS for set_calendar, so patch-file recipient changes are reflected in
ATTENDEE metadata.

Change-Id: I3a7a55f96df8fac7d924a4dbeedd5b3d0d9d443c
Co-Authored-By: AI

* fix(mail): derive method= from ICS body in writeCalendarPart instead of hardcoding REQUEST

Passthrough ICS (e.g. forwarded METHOD:CANCEL) previously emitted a
Content-Type with method=REQUEST, disagreeing with the body. Now
extractICSMethod() scans the ICS for METHOD: and falls back to REQUEST
when absent, keeping existing behavior for our own generated ICS.

Change-Id: I4bf6c3755a189a436c2d26b082372d9f838f4051
Co-Authored-By: AI

* fix(mail): normalize calendar_event start/end to UTC in output

Callers expect RFC 3339 UTC strings; source ICS with TZID offsets
previously emitted +08:00 instead of Z.

Change-Id: I88bd4b925f8fc3b4f569e41712ae58ab50d94a2f
Co-Authored-By: AI

* fix(mail): make ICS parser case-insensitive and handle parameterized property names

RFC 5545 §3.1 allows any case and optional parameters on all property
names. Unify UID/SUMMARY/LOCATION/DTSTART/etc. to compare via
strings.ToUpper(name) and add HasPrefix checks for the NAME; form,
consistent with how ORGANIZER and ATTENDEE were already handled.

Change-Id: I7dc642dd210a3256f2189a901a2d9518ea284815
Co-Authored-By: AI
2026-04-29 15:31:38 +08:00
zgz2048
97968b6ef2 docs(base): align base skills and view config contracts (#653)
* docs(base): align base skills and view config contracts

1. Rework the lark-base source-of-truth docs around canonical field, cell, record and view payload shapes.

2. Refresh view, workflow, lookup and related references against current openapi behavior and remove stale or broken guidance.

3. Remove dead array-wrapper handling from view sort/group setters and add unit plus dry-run e2e coverage for object-only input.

* docs(base): drop view config code changes from doc refactor

1. Revert the temporary Base view config Go and test adjustments so this PR only keeps lark-base skill and reference updates.

2. Preserve the documentation contract changes while leaving runtime behavior unchanged from the pre-refactor implementation.

* docs(base): revert temporary view config code cleanup

1. Restore the pre-refactor Base view config Go paths and related unit tests so this PR keeps runtime behavior unchanged.

2. Leave the lark-base skill and reference updates in place as the only intended product change in this branch.

* docs(base): fix progress color typo

* docs(base): trim padding in reference docs

1. Remove obviously excessive alignment spaces from base reference examples and operator lists.

2. Shorten a few overlong separator rows in the formula guide to reduce low-value formatting noise.

3. Keep the changes scoped to four lark-base reference files without changing documented behavior.

* docs(base): clarify field description guidance

* test: isolate dry-run e2e config state

* chore: update data-query prompt

* docs(base): simplify formula filter guidance

* docs(base): drop stage field mention from data query

* revert: keep e2e changes scoped to base docs

* docs(base): clarify dashboard field type wording

* docs(base): trim number filter operators
2026-04-29 15:30:11 +08:00
Yuxuan Zhao
6bb988a655 test: align e2e yes flags with risk metadata (#701) 2026-04-28 23:06:43 +08:00
liangshuo-1
4422265d5f test(im): drop --yes from chats link e2e (not high-risk-write) (#700)
`im chats link` is registered as a regular service method (no
`risk: high-risk-write` annotation), so the framework does not register
the `--yes` flag on it. Setting `Yes: true` on the e2e Request makes
the runner append `--yes`, which cobra rejects with `unknown flag:
--yes` before the request is ever issued — the rest of the assertions
then fall through with empty stdout.

The flag was added in #633 alongside the risk-tiering rollout that
covered other workflows that genuinely flipped to high-risk-write.
For chats link the API call (creating a chat share link with a
configurable validity period) is not destructive and was never
re-classified, so the line is just leftover from that pass. Drop it
to restore the e2e green; if we ever decide to gate share-link
creation behind confirmation we can re-add it together with the
metadata flip.

Change-Id: Ieb094407a7f0fa18cd130a9d80c7146274b5ecc7
2026-04-28 22:06:13 +08:00
134 changed files with 81639 additions and 1346 deletions

View File

@@ -2,6 +2,27 @@
All notable changes to this project will be documented in this file.
## [v1.0.22] - 2026-04-29
### Features
- **task**: Add resource agent & `agent_task_step_info` (#693)
- **task**: Support app task members by id (#712)
- **contact**: Add `--queries` multi-name fanout to `+search-user` (#707)
- **slides**: Add slide templates with template-first skill guidance (#684)
- **mail**: Support calendar events in emails (#646)
- **install**: Honor `npm_config_registry` for binary URL resolution with npmmirror fallback (#690)
### Bug Fixes
- **install**: Make Windows zip extraction resilient (#713)
- **config/init**: Respect `--brand` flag in `--new` mode (#711)
### Documentation
- **base**: Clarify base search routing (#708)
- **base**: Align base skills and view config contracts (#653)
## [v1.0.21] - 2026-04-28
### Features
@@ -539,6 +560,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.22]: https://github.com/larksuite/cli/releases/tag/v1.0.22
[v1.0.21]: https://github.com/larksuite/cli/releases/tag/v1.0.21
[v1.0.20]: https://github.com/larksuite/cli/releases/tag/v1.0.20
[v1.0.19]: https://github.com/larksuite/cli/releases/tag/v1.0.19

View File

@@ -269,7 +269,7 @@ func configInitRun(opts *ConfigInitOptions) error {
// Mode 3: Create new app directly (--new)
if opts.New {
result, err := runCreateAppFlow(opts.Ctx, f, core.BrandFeishu, msg)
result, err := runCreateAppFlow(opts.Ctx, f, parseBrand(opts.Brand), msg)
if err != nil {
return err
}

View File

@@ -25,10 +25,26 @@ type Stub struct {
Headers http.Header // optional full response headers (takes precedence over ContentType)
matched bool
// BodyFilter (optional): match only when the captured request body satisfies
// this predicate. Used to disambiguate multiple stubs that share a URL.
BodyFilter func([]byte) bool
// OnMatch (optional): runs synchronously after the stub matches but before
// the response is composed. Used in tests to inject panics or count
// in-flight goroutines.
OnMatch func(req *http.Request)
// Reusable (optional): when true, the stub stays available for further
// matches after the first hit. Each match appends to CapturedBodies.
Reusable bool
// CapturedHeaders records the request headers of the matched request.
// Populated after RoundTrip matches this stub.
CapturedHeaders http.Header
CapturedBody []byte
// CapturedBodies records every captured request body when Reusable is set.
// (CapturedBody continues to record the most recent capture for back-compat.)
CapturedBodies [][]byte
}
// Registry records stubs and implements http.RoundTripper.
@@ -51,8 +67,43 @@ func (r *Registry) Register(s *Stub) {
func (r *Registry) RoundTrip(req *http.Request) (*http.Response, error) {
urlStr := req.URL.String()
// Read body once up-front so BodyFilter can inspect it without consuming
// the original reader; restore for downstream consumers afterwards.
// http.RoundTripper requires us to close the original body.
var capturedBody []byte
if req.Body != nil {
var err error
capturedBody, err = io.ReadAll(req.Body)
_ = req.Body.Close()
if err != nil {
return nil, fmt.Errorf("httpmock: read request body: %w", err)
}
req.Body = io.NopCloser(bytes.NewReader(capturedBody))
}
matched := r.match(req, urlStr, capturedBody)
if matched != nil {
// Restore body again in case OnMatch wants to read it.
req.Body = io.NopCloser(bytes.NewReader(capturedBody))
if matched.OnMatch != nil {
matched.OnMatch(req)
}
resp, err := stubResponse(matched)
if err != nil {
return nil, fmt.Errorf("httpmock: stub %s %s: %w", matched.Method, matched.URL, err)
}
return resp, nil
}
return nil, fmt.Errorf("httpmock: no stub for %s %s", req.Method, req.URL)
}
// match selects the first stub whose Method/URL/BodyFilter all match the
// request, mutates its capture state, and returns it. defer-Unlock guarantees
// a panicking user-supplied BodyFilter cannot leak the mutex.
func (r *Registry) match(req *http.Request, urlStr string, capturedBody []byte) *Stub {
r.mu.Lock()
var matched *Stub
defer r.mu.Unlock()
for _, s := range r.stubs {
if s.matched {
continue
@@ -63,25 +114,18 @@ func (r *Registry) RoundTrip(req *http.Request) (*http.Response, error) {
if s.URL != "" && !strings.Contains(urlStr, s.URL) {
continue
}
s.matched = true
if s.BodyFilter != nil && !s.BodyFilter(capturedBody) {
continue
}
if !s.Reusable {
s.matched = true
}
s.CapturedHeaders = req.Header.Clone()
if req.Body != nil {
s.CapturedBody, _ = io.ReadAll(req.Body)
req.Body = io.NopCloser(bytes.NewReader(s.CapturedBody))
}
matched = s
break
s.CapturedBody = capturedBody
s.CapturedBodies = append(s.CapturedBodies, capturedBody)
return s
}
r.mu.Unlock()
if matched != nil {
resp, err := stubResponse(matched)
if err != nil {
return nil, fmt.Errorf("httpmock: stub %s %s: %w", matched.Method, matched.URL, err)
}
return resp, nil
}
return nil, fmt.Errorf("httpmock: no stub for %s %s", req.Method, req.URL)
return nil
}
// Verify asserts all stubs were matched.
@@ -90,9 +134,14 @@ func (r *Registry) Verify(t testing.TB) {
r.mu.Lock()
defer r.mu.Unlock()
for _, s := range r.stubs {
if !s.matched {
t.Errorf("httpmock: unmatched stub: %s %s", s.Method, s.URL)
if s.matched {
continue
}
// Reusable stubs never set s.matched; treat any captured hit as a match.
if s.Reusable && len(s.CapturedBodies) > 0 {
continue
}
t.Errorf("httpmock: unmatched stub: %s %s", s.Method, s.URL)
}
}

View File

@@ -15,6 +15,7 @@ import (
var knownArrayFields = []string{
"items", "files", "events", "rooms", "records", "nodes",
"members", "departments", "calendar_list", "acl_list", "freebusy_list",
"users",
}
// FindArrayField finds the primary array field in a response's data object.

View File

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

View File

@@ -10,15 +10,16 @@ const crypto = require("crypto");
const VERSION = require("../package.json").version.replace(/-.*$/, "");
const REPO = "larksuite/cli";
const NAME = "lark-cli";
const DEFAULT_MIRROR_HOST = "https://registry.npmmirror.com";
// Allowlist gates the *initial* request URL only. curl --location follows
// redirects (capped by --max-redirs 3) without re-checking the target host.
// This is acceptable because checksum verification is the primary integrity
// control; the allowlist is defense-in-depth to reject obviously wrong URLs.
const ALLOWED_HOSTS = [
const ALLOWED_HOSTS = new Set([
"github.com",
"objects.githubusercontent.com",
"registry.npmmirror.com",
];
]);
const PLATFORM_MAP = {
darwin: "darwin",
@@ -38,18 +39,77 @@ const isWindows = process.platform === "win32";
const ext = isWindows ? ".zip" : ".tar.gz";
const archiveName = `${NAME}-${VERSION}-${platform}-${arch}${ext}`;
const GITHUB_URL = `https://github.com/${REPO}/releases/download/v${VERSION}/${archiveName}`;
const MIRROR_URL = `https://registry.npmmirror.com/-/binary/lark-cli/v${VERSION}/${archiveName}`;
const binDir = path.join(__dirname, "..", "bin");
const dest = path.join(binDir, NAME + (isWindows ? ".exe" : ""));
// Build the ordered list of binary mirror URLs to try. Resolution rules:
// 1. npm_config_registry — when the user has set a non-default
// registry (npmmirror clone, corp Verdaccio,
// Artifactory, …), include the derived path
// first. Many of these proxies don't actually
// host /-/binary/<pkg>/..., so we ALWAYS
// append the public npmmirror as a final
// fallback so the install does not regress
// from the previous behavior of "GitHub →
// npmmirror".
// 2. registry.npmmirror.com — public China mirror, always tried last.
// The default public npmjs registry is skipped in step 1 because it does not
// host binaries under /-/binary/...
//
// Non-https / malformed npm_config_registry is silently ignored so npm users
// with http-only internal registries don't have their installs broken.
function resolveMirrorUrls(env, archive, version) {
const binaryPath = `/-/binary/lark-cli/v${version}/${archive}`;
const defaultUrl = joinUrl(DEFAULT_MIRROR_HOST, binaryPath);
const urls = [];
const registry = (env.npm_config_registry || "").trim();
if (registry && !isDefaultNpmjsRegistry(registry) && isValidDownloadBase(registry)) {
const base = new URL(registry);
urls.push(joinUrl(base.origin + base.pathname, binaryPath));
}
if (!urls.includes(defaultUrl)) urls.push(defaultUrl);
return urls;
}
function joinUrl(base, suffix) {
return base.replace(/\/+$/, "") + suffix;
}
function isValidDownloadBase(raw) {
try {
const parsed = new URL(raw);
return parsed.protocol === "https:" && !!parsed.hostname;
} catch (_) {
return false;
}
}
function isDefaultNpmjsRegistry(url) {
try {
const { hostname } = new URL(url);
return hostname === "registry.npmjs.org";
} catch (_) {
return false;
}
}
function assertAllowedHost(url) {
const { hostname } = new URL(url);
if (!ALLOWED_HOSTS.includes(hostname)) {
if (!ALLOWED_HOSTS.has(hostname)) {
throw new Error(`Download host not allowed: ${hostname}`);
}
}
// Resolve the mirror URL chain and admit each host. Called from install() so
// derived hosts only become trusted when actually needed.
function getMirrorUrls(env) {
const urls = resolveMirrorUrls(env, archiveName, VERSION);
for (const u of urls) ALLOWED_HOSTS.add(new URL(u).hostname);
return urls;
}
function download(url, destPath) {
assertAllowedHost(url);
const args = [
@@ -65,27 +125,69 @@ function download(url, destPath) {
execFileSync("curl", args, { stdio: ["ignore", "ignore", "pipe"] });
}
function extractZipWindows(archivePath, destDir) {
const psOpts = ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command"];
const psStdio = ["ignore", "inherit", "inherit"];
const psEnv = {
...process.env,
LARK_CLI_ARCHIVE: archivePath,
LARK_CLI_DEST: destDir,
};
try {
const dotnet =
"$ErrorActionPreference='Stop';" +
"Add-Type -AssemblyName System.IO.Compression.FileSystem;" +
"[System.IO.Compression.ZipFile]::ExtractToDirectory($env:LARK_CLI_ARCHIVE,$env:LARK_CLI_DEST)";
execFileSync("powershell.exe", [...psOpts, dotnet], { stdio: psStdio, env: psEnv });
} catch (primaryErr) {
try {
const cmdlet =
"$ErrorActionPreference='Stop';" +
"Expand-Archive -LiteralPath $env:LARK_CLI_ARCHIVE -DestinationPath $env:LARK_CLI_DEST -Force";
execFileSync("powershell.exe", [...psOpts, cmdlet], { stdio: psStdio, env: psEnv });
} catch (fallbackErr) {
throw new Error(
`Failed to extract ${archivePath}. ` +
`.NET ZipFile attempt: ${primaryErr.message}. ` +
`Expand-Archive fallback: ${fallbackErr.message}`
);
}
}
}
function install() {
const mirrorUrls = getMirrorUrls(process.env);
const downloadUrls = [GITHUB_URL, ...mirrorUrls];
fs.mkdirSync(binDir, { recursive: true });
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lark-cli-"));
const archivePath = path.join(tmpDir, archiveName);
try {
try {
download(GITHUB_URL, archivePath);
} catch (err) {
download(MIRROR_URL, archivePath);
// Walk the chain in order; stop at the first success. Default chain:
// GitHub → derived(npm_config_registry)? → npmmirror. The npmmirror
// tail preserves the pre-PR safety net when a corporate proxy doesn't
// actually host /-/binary/<pkg>/...
let lastErr;
let downloaded = false;
for (const url of downloadUrls) {
try {
download(url, archivePath);
downloaded = true;
break;
} catch (e) {
lastErr = e;
}
}
if (!downloaded) throw lastErr;
const expectedHash = getExpectedChecksum(archiveName);
verifyChecksum(archivePath, expectedHash);
if (isWindows) {
execFileSync("powershell", [
"-Command",
`Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}'`,
], { stdio: "ignore" });
extractZipWindows(archivePath, tmpDir);
} else {
execFileSync("tar", ["-xzf", archivePath, "-C", tmpDir], {
stdio: "ignore",
@@ -176,12 +278,15 @@ if (require.main === module) {
} catch (err) {
console.error(`Failed to install ${NAME}:`, err.message);
console.error(
`\nIf you are behind a firewall or in a restricted network, try setting a proxy:\n` +
`\nIf you are behind a firewall or in a restricted network, try one of:\n` +
` # 1. Use a proxy:\n` +
` export https_proxy=http://your-proxy:port\n` +
` npm install -g @larksuite/cli`
` npm install -g @larksuite/cli\n\n` +
` # 2. Point to a corporate npm mirror that proxies /-/binary/lark-cli/...:\n` +
` npm install -g @larksuite/cli --registry=https://your-corp-mirror/`
);
process.exit(1);
}
}
module.exports = { getExpectedChecksum, verifyChecksum, assertAllowedHost };
module.exports = { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls };

View File

@@ -9,7 +9,7 @@ const os = require("os");
const crypto = require("crypto");
const { getExpectedChecksum, verifyChecksum, assertAllowedHost } = require("./install.js");
const { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls } = require("./install.js");
describe("getExpectedChecksum", () => {
function makeTmpChecksums(content) {
@@ -164,3 +164,117 @@ describe("assertAllowedHost", () => {
);
});
});
describe("resolveMirrorUrls", () => {
const ARCHIVE = "lark-cli-1.0.0-linux-amd64.tar.gz";
const VERSION = "1.0.0";
const DEFAULT = "https://registry.npmmirror.com/-/binary/lark-cli/v1.0.0/lark-cli-1.0.0-linux-amd64.tar.gz";
it("returns only the default mirror when no env vars are set", () => {
assert.deepEqual(resolveMirrorUrls({}, ARCHIVE, VERSION), [DEFAULT]);
});
it("does not derive from the default npmjs registry", () => {
// The public npmjs registry doesn't host /-/binary/<pkg>/..., so we must
// not point downloads at it.
assert.deepEqual(
resolveMirrorUrls(
{ npm_config_registry: "https://registry.npmjs.org/" },
ARCHIVE,
VERSION
),
[DEFAULT]
);
});
it("derives from non-default npm_config_registry AND keeps default as fallback", () => {
// Critical: a corporate npm proxy (Verdaccio/Artifactory/Nexus) often
// doesn't actually serve /-/binary/<pkg>/..., so we must keep the
// public npmmirror as a final fallback or installs regress vs. the
// pre-PR "GitHub → npmmirror" behavior.
assert.deepEqual(
resolveMirrorUrls(
{ npm_config_registry: "https://corp.example.com/repository/npm-public/" },
ARCHIVE,
VERSION
),
[
"https://corp.example.com/repository/npm-public/-/binary/lark-cli/v1.0.0/lark-cli-1.0.0-linux-amd64.tar.gz",
DEFAULT,
]
);
});
it("derived URL appears before the default in the chain", () => {
const urls = resolveMirrorUrls(
{ npm_config_registry: "https://corp.example.com/" },
ARCHIVE,
VERSION
);
assert.equal(urls.length, 2);
assert.match(urls[0], /^https:\/\/corp\.example\.com\//);
assert.equal(urls[1], DEFAULT);
});
it("does not duplicate the default if the registry already points at it", () => {
// If npm_config_registry happens to be the public npmmirror, we still
// want a single entry, not two identical ones.
assert.deepEqual(
resolveMirrorUrls(
{ npm_config_registry: "https://registry.npmmirror.com/" },
ARCHIVE,
VERSION
),
[DEFAULT]
);
});
it("strips trailing slashes from the registry URL", () => {
assert.deepEqual(
resolveMirrorUrls(
{ npm_config_registry: "https://corp.example.com///" },
ARCHIVE,
VERSION
),
[
"https://corp.example.com/-/binary/lark-cli/v1.0.0/lark-cli-1.0.0-linux-amd64.tar.gz",
DEFAULT,
]
);
});
it("ignores empty/whitespace npm_config_registry", () => {
assert.deepEqual(
resolveMirrorUrls(
{ npm_config_registry: "" },
ARCHIVE,
VERSION
),
[DEFAULT]
);
});
it("silently falls back when npm_config_registry is non-https", () => {
// Implicit feature: don't break installs whose npm registry is plain http.
// The user didn't opt into binary-mirror behavior, so just use the default.
assert.deepEqual(
resolveMirrorUrls(
{ npm_config_registry: "http://internal.example.com/" },
ARCHIVE,
VERSION
),
[DEFAULT]
);
});
it("silently falls back when npm_config_registry is file://", () => {
assert.deepEqual(
resolveMirrorUrls(
{ npm_config_registry: "file:///tmp" },
ARCHIVE,
VERSION
),
[DEFAULT]
);
});
});

View File

@@ -123,7 +123,7 @@ type searchUser struct {
P2PChatID string `json:"p2p_chat_id"`
HasChatted bool `json:"has_chatted"`
Department string `json:"department"`
Signature string `json:"signature"`
Signature string `json:"signature,omitempty"`
ChatRecencyHint string `json:"chat_recency_hint"`
MatchSegments []string `json:"match_segments"`
}
@@ -150,18 +150,38 @@ var ContactSearchUser = common.Shortcut{
{Name: "left-organization", Type: "bool", Desc: "restrict to users who have left the organization (omit to disable; =false rejected)"},
{Name: "lang", Desc: "override locale for localized_name (e.g. zh_cn, en_us)"},
{Name: "page-size", Type: "int", Default: "20", Desc: "rows per request, 1-30"},
{Name: "queries", Desc: "comma-separated keywords searched in parallel; output is a flat users[] with matched_query plus a queries[] sidecar"},
},
Tips: []string{
"Keyword search: lark-cli contact +search-user --query 'alice' --format json",
"Look up by ID (or 'me' for self): lark-cli contact +search-user --user-ids 'ou_xxx,me' --format json",
"Filter-only enumeration — users you've chatted with: lark-cli contact +search-user --has-chatted --format json",
"Keyword search: lark-cli contact +search-user --query 'alice'",
"Look up by ID (or 'me' for self): lark-cli contact +search-user --user-ids 'ou_xxx,me'",
"Filter-only enumeration — users you've chatted with: lark-cli contact +search-user --has-chatted",
"Refine same-name hits: lark-cli contact +search-user --query '张三' --has-chatted --exclude-external-users",
"Multi-name fanout: lark-cli contact +search-user --queries 'alice,bob,张三'",
"open_id is the stable identifier for follow-up commands; on has_more=true add filters or tighten --query — there is no auto-pagination.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateSearchUser(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
if raw := strings.TrimSpace(runtime.Str("queries")); raw != "" {
queries := parseAndDedupQueries(raw)
filter, err := buildFanoutFilter(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
api := common.NewDryRunAPI()
for _, q := range queries {
body := &searchUserAPIRequest{Query: q}
if filter != nil {
body.Filter = filter
}
api.POST(searchUserURL).
Params(map[string]interface{}{"page_size": runtime.Int("page-size")}).
Body(body)
}
return api
}
body, err := buildSearchUserBody(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
@@ -175,6 +195,13 @@ var ContactSearchUser = common.Shortcut{
}
func executeSearchUser(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("queries")) != "" {
return executeSearchUserFanout(ctx, runtime)
}
return executeSearchUserSingle(ctx, runtime)
}
func executeSearchUserSingle(ctx context.Context, runtime *common.RuntimeContext) error {
body, err := buildSearchUserBody(runtime)
if err != nil {
return err
@@ -347,10 +374,32 @@ func rowFromItem(item *searchUserAPIItem, lang string, brand core.LarkBrand) sea
func validateSearchUser(runtime *common.RuntimeContext) error {
if !hasAnySearchInput(runtime) {
return common.FlagErrorf(
"specify at least one of --query, --user-ids, --has-chatted, --has-enterprise-email, --exclude-external-users, --left-organization",
"specify at least one of --query, --queries, --user-ids, --has-chatted, --has-enterprise-email, --exclude-external-users, --left-organization",
)
}
queriesRaw := strings.TrimSpace(runtime.Str("queries"))
if queriesRaw != "" {
if strings.TrimSpace(runtime.Str("query")) != "" {
return common.FlagErrorf("--query and --queries are mutually exclusive")
}
if strings.TrimSpace(runtime.Str("user-ids")) != "" {
return common.FlagErrorf("--user-ids and --queries are mutually exclusive")
}
queries := parseAndDedupQueries(queriesRaw)
if len(queries) == 0 {
return common.FlagErrorf("--queries: no valid query parsed from %q (separate entries with ',')", queriesRaw)
}
if len(queries) > maxFanoutQueries {
return common.FlagErrorf("--queries: must be at most %d entries (got %d)", maxFanoutQueries, len(queries))
}
for _, q := range queries {
if utf8.RuneCountInString(q) > maxSearchUserQueryChars {
return common.FlagErrorf("--queries: entry %q exceeds %d characters", q, maxSearchUserQueryChars)
}
}
}
if q := strings.TrimSpace(runtime.Str("query")); q != "" {
if utf8.RuneCountInString(q) > maxSearchUserQueryChars {
return common.FlagErrorf("--query: length must be between 1 and %d characters", maxSearchUserQueryChars)
@@ -399,6 +448,9 @@ func hasAnySearchInput(runtime *common.RuntimeContext) bool {
if strings.TrimSpace(runtime.Str("query")) != "" {
return true
}
if strings.TrimSpace(runtime.Str("queries")) != "" {
return true
}
if strings.TrimSpace(runtime.Str("user-ids")) != "" {
return true
}

View File

@@ -0,0 +1,275 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package contact
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sync"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
const (
maxFanoutQueries = 20
fanoutConcurrency = 5
)
// parseAndDedupQueries splits the raw CSV, trims whitespace, drops empty
// entries, and deduplicates case-sensitively while preserving first-occurrence
// order.
func parseAndDedupQueries(raw string) []string {
parts := common.SplitCSV(raw)
seen := make(map[string]bool, len(parts))
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" || seen[p] {
continue
}
seen[p] = true
out = append(out, p)
}
return out
}
type fanoutResult struct {
Index int
Query string
Users []searchUser
HasMore bool
ErrMsg string // empty = success
ErrCode int // 0 = success or unknown; otherwise an HTTP status or Lark API code corresponding to the first error
}
// isFanoutSummaryFormat gates the per-fanout stderr summary line. Includes csv
// because that summary lives on stderr and never corrupts the csv stream on
// stdout — single-query mode keeps the narrower isHumanReadableFormat predicate
// for its refine hint, so adding csv here doesn't regress that path.
func isFanoutSummaryFormat(format string) bool {
return format == "pretty" || format == "table" || format == "csv"
}
// runOneQuery converts every failure mode (transport, HTTP status, parse,
// API code) into an ErrMsg string instead of returning a Go error. The
// fanout dispatcher (Task 6) relies on this so a single failed query never
// short-circuits the remaining workers.
func runOneQuery(ctx context.Context, runtime *common.RuntimeContext, index int, query string,
filter *searchUserAPIFilter) fanoutResult {
// Pre-check ctx so queued workers see cancellation before issuing a
// request; in-flight workers continue until DoAPI returns.
if err := ctx.Err(); err != nil {
return fanoutResult{Index: index, Query: query, ErrMsg: err.Error()}
}
body := &searchUserAPIRequest{Query: query}
if filter != nil {
body.Filter = filter
}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: searchUserURL,
Body: body,
QueryParams: larkcore.QueryParams{"page_size": []string{strconv.Itoa(runtime.Int("page-size"))}},
})
if err != nil {
return fanoutResult{Index: index, Query: query, ErrMsg: err.Error()}
}
if apiResp.StatusCode != http.StatusOK {
body := strings.TrimSpace(string(apiResp.RawBody))
const maxBody = 200
if len(body) > maxBody {
body = body[:maxBody] + "..."
}
msg := fmt.Sprintf("HTTP %d %s", apiResp.StatusCode, http.StatusText(apiResp.StatusCode))
if body != "" {
msg = fmt.Sprintf("%s: %s", msg, body)
}
return fanoutResult{Index: index, Query: query,
ErrMsg: msg,
ErrCode: apiResp.StatusCode}
}
var resp searchUserAPIEnvelope
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
return fanoutResult{Index: index, Query: query,
ErrMsg: fmt.Sprintf("parse response failed: %v", err)}
}
if resp.Code != 0 {
return fanoutResult{Index: index, Query: query,
ErrMsg: fmt.Sprintf("API %d: %s", resp.Code, resp.Msg),
ErrCode: resp.Code}
}
users, hasMore := projectUsers(resp.Data, runtime.Str("lang"), runtime.Config.Brand)
return fanoutResult{Index: index, Query: query, Users: users, HasMore: hasMore}
}
type fanoutUser struct {
searchUser
MatchedQuery string `json:"matched_query"`
}
type querySummary struct {
Query string `json:"query"`
Error string `json:"error,omitempty"`
HasMore bool `json:"has_more"`
}
type fanoutResponse struct {
Users []fanoutUser `json:"users"`
Queries []querySummary `json:"queries"`
}
// buildFanoutResponse walks results by Index (input order), flattens users[]
// with matched_query, lists every input in queries[] (including successes),
// and returns an error only when every query failed. The error wraps the
// first failing query's ErrMsg so the CLI exits non-zero on full failure.
func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutResponse, error) {
indexed := make([]fanoutResult, len(queries))
for _, r := range results {
indexed[r.Index] = r
}
out := &fanoutResponse{
Users: make([]fanoutUser, 0),
Queries: make([]querySummary, 0, len(queries)),
}
failed := 0
var firstErrMsg, firstErrQuery string
var firstErrCode int
for i, r := range indexed {
out.Queries = append(out.Queries, querySummary{
Query: queries[i],
Error: r.ErrMsg,
HasMore: r.HasMore,
})
if r.ErrMsg != "" {
failed++
if firstErrMsg == "" {
firstErrMsg = r.ErrMsg
firstErrQuery = queries[i]
firstErrCode = r.ErrCode
}
continue
}
for _, u := range r.Users {
out.Users = append(out.Users, fanoutUser{searchUser: u, MatchedQuery: queries[i]})
}
}
if failed == len(queries) && len(queries) > 0 {
msg := fmt.Sprintf("all %d queries failed; first: %s (query=%q)",
len(queries), firstErrMsg, firstErrQuery)
// Only the HTTP-status / Lark-API-code branches in runOneQuery populate
// ErrCode; transport, parse, panic, and ctx-canceled stay at 0. Code 0
// means success in the Lark protocol, so don't pretend it's an API error
// when we have nothing structured to report.
if firstErrCode != 0 {
return nil, output.ErrAPI(firstErrCode, msg, "")
}
return nil, output.ErrWithHint(output.ExitInternal, "fanout", msg, "")
}
return out, nil
}
func executeSearchUserFanout(ctx context.Context, runtime *common.RuntimeContext) error {
queries := parseAndDedupQueries(runtime.Str("queries"))
filter, err := buildFanoutFilter(runtime)
if err != nil {
return err
}
results := make([]fanoutResult, len(queries))
var wg sync.WaitGroup
sem := make(chan struct{}, fanoutConcurrency)
for i, q := range queries {
wg.Add(1)
sem <- struct{}{}
go func(i int, q string) {
defer wg.Done()
defer func() { <-sem }()
defer func() {
if r := recover(); r != nil {
results[i] = fanoutResult{
Index: i,
Query: q,
ErrMsg: fmt.Sprintf("internal error: %v", r),
}
}
}()
results[i] = runOneQuery(ctx, runtime, i, q, filter)
}(i, q)
}
wg.Wait()
resp, err := buildFanoutResponse(queries, results)
if err != nil {
return err
}
failed, hasMoreCount := 0, 0
for _, qs := range resp.Queries {
if qs.Error != "" {
failed++
}
if qs.HasMore {
hasMoreCount++
}
}
runtime.OutFormat(resp, &output.Meta{Count: len(resp.Users)}, func(w io.Writer) {
if len(resp.Users) == 0 {
fmt.Fprintln(w, "No users found.")
return
}
output.PrintTable(w, prettyFanoutUserRows(resp.Users))
})
if isFanoutSummaryFormat(runtime.Format) {
fmt.Fprintf(runtime.IO().ErrOut, "\n%d queries, %d total users; %d failed, %d with has_more\n",
len(queries), len(resp.Users), failed, hasMoreCount)
}
return nil
}
func buildFanoutFilter(runtime *common.RuntimeContext) (*searchUserAPIFilter, error) {
filter := &searchUserAPIFilter{}
hasFilter := false
for _, bf := range searchUserBoolFilters {
if runtime.Cmd.Flags().Changed(bf.Flag) && runtime.Bool(bf.Flag) {
bf.Apply(filter)
hasFilter = true
}
}
if !hasFilter {
return nil, nil
}
return filter, nil
}
func prettyFanoutUserRows(users []fanoutUser) []map[string]interface{} {
rows := make([]map[string]interface{}, 0, len(users))
for _, u := range users {
rows = append(rows, map[string]interface{}{
"matched_query": u.MatchedQuery,
"localized_name": u.LocalizedName,
"department": common.TruncateStr(u.Department, 50),
"enterprise_email": u.EnterpriseEmail,
"has_chatted": u.HasChatted,
"chat_recency_hint": u.ChatRecencyHint,
"open_id": u.OpenID,
})
}
return rows
}

View File

@@ -5,10 +5,14 @@ package contact
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync/atomic"
"testing"
"time"
"unicode/utf8"
"github.com/larksuite/cli/internal/cmdutil"
@@ -620,6 +624,46 @@ func TestSearchUser_Integration_JSONStructuredFields(t *testing.T) {
}
}
// Most users have no signature; the field is omitempty so an empty value
// must not appear at all in the JSON, not as "" — agents shouldn't have to
// distinguish "absent" from "empty string".
func TestSearchUser_Integration_EmptySignatureOmitted(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/contact/v3/users/search",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"id": "ou_a",
"meta_data": map[string]interface{}{
"i18n_names": map[string]interface{}{"zh_cn": "无签名用户"},
"mail_address": "x@example.com",
"description": "",
},
},
},
"has_more": false,
},
},
})
err := mountAndRun(t, ContactSearchUser, []string{"+search-user", "--query", "x", "--format", "json", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v\nstdout=%s", err, stdout.String())
}
users := got["data"].(map[string]interface{})["users"].([]interface{})
u := users[0].(map[string]interface{})
if _, present := u["signature"]; present {
t.Errorf(`signature must be absent (not "") when empty; got %v`, u["signature"])
}
}
func TestSearchUser_Integration_NDJSONHasNoRefineHint(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
@@ -808,6 +852,345 @@ func TestSearchUser_Integration_PageSizeFlowsToQuery(t *testing.T) {
reg.Verify(t)
}
func newSearchUserTestCommandWithQueries() *cobra.Command {
cmd := newSearchUserTestCommand()
cmd.Flags().String("queries", "", "")
return cmd
}
func TestValidateQueries_QueryAndQueriesMutex(t *testing.T) {
cmd := newSearchUserTestCommandWithQueries()
_ = cmd.Flags().Set("query", "alice")
_ = cmd.Flags().Set("queries", "bob,carol")
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
err := validateSearchUser(rt)
if err == nil || !strings.Contains(err.Error(), "--query and --queries are mutually exclusive") {
t.Fatalf("expected mutex error, got %v", err)
}
}
func TestValidateQueries_UserIDsAndQueriesMutex(t *testing.T) {
cmd := newSearchUserTestCommandWithQueries()
_ = cmd.Flags().Set("user-ids", "ou_a")
_ = cmd.Flags().Set("queries", "bob")
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
err := validateSearchUser(rt)
if err == nil || !strings.Contains(err.Error(), "--user-ids and --queries are mutually exclusive") {
t.Fatalf("expected mutex error, got %v", err)
}
}
func TestValidateQueries_AllSeparators_Errors(t *testing.T) {
for _, raw := range []string{",,,", " , , ", ","} {
cmd := newSearchUserTestCommandWithQueries()
_ = cmd.Flags().Set("queries", raw)
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
err := validateSearchUser(rt)
if err == nil || !strings.Contains(err.Error(), "no valid query parsed") {
t.Fatalf("raw=%q: expected 'no valid query parsed' error, got %v", raw, err)
}
}
}
func TestValidateQueries_OverLength_Errors(t *testing.T) {
cmd := newSearchUserTestCommandWithQueries()
long := strings.Repeat("a", 51)
_ = cmd.Flags().Set("queries", "short,"+long)
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
err := validateSearchUser(rt)
if err == nil || !strings.Contains(err.Error(), "exceeds 50 characters") {
t.Fatalf("expected length error mentioning 50, got %v", err)
}
}
func TestValidateQueries_Over20_Errors(t *testing.T) {
cmd := newSearchUserTestCommandWithQueries()
parts := make([]string, 21)
for i := range parts {
parts[i] = fmt.Sprintf("q%02d", i)
}
_ = cmd.Flags().Set("queries", strings.Join(parts, ","))
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
err := validateSearchUser(rt)
if err == nil || !strings.Contains(err.Error(), "must be at most 20 entries") {
t.Fatalf("expected 20-cap error, got %v", err)
}
}
func TestParseQueries_TrimAndSkipEmpty(t *testing.T) {
got := parseAndDedupQueries("a, ,b ,")
want := []string{"a", "b"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Errorf("parseAndDedupQueries: got %v, want %v", got, want)
}
}
func TestParseQueries_DedupCaseSensitive(t *testing.T) {
got := parseAndDedupQueries("alice,Alice,alice")
want := []string{"alice", "Alice"}
if len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
t.Errorf("got %v, want %v (case-sensitive dedup keeps first-occurrence order)", got, want)
}
}
func TestExecuteSingleQuery_OutputUnchanged(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(searchUserStub())
err := mountAndRun(t, ContactSearchUser, []string{"+search-user", "--query", "张三", "--format", "json", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v", err)
}
data, _ := got["data"].(map[string]interface{})
if _, hasQueries := data["queries"]; hasQueries {
t.Errorf("single-query mode must NOT emit data.queries; got=%v", data)
}
users, _ := data["users"].([]interface{})
if len(users) != 1 {
t.Fatalf("users len = %d, want 1", len(users))
}
u, _ := users[0].(map[string]interface{})
if _, hasMatched := u["matched_query"]; hasMatched {
t.Errorf("single-query mode users[] must NOT carry matched_query; got=%v", u)
}
if _, hasTopHasMore := data["has_more"]; !hasTopHasMore {
t.Errorf("single-query mode must keep top-level data.has_more; data=%v", data)
}
}
// runOneQueryRuntime wires a Factory-backed RuntimeContext bound to the test
// command's flag set, so runOneQuery can be exercised directly without going
// through the cobra dispatcher. Mirrors what mountAndRun would build, minus
// the parent-command plumbing the worker doesn't need.
func runOneQueryRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
t.Helper()
f, _, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
cmd := newSearchUserTestCommand()
rt := common.TestNewRuntimeContextForAPI(context.Background(), cmd, searchUserDefaultConfig(), f, core.AsUser)
return rt, reg
}
func TestRunOneQuery_Success(t *testing.T) {
rt, reg := runOneQueryRuntime(t)
reg.Register(searchUserStub())
got := runOneQuery(context.Background(), rt, 0, "张三", nil)
if got.ErrMsg != "" {
t.Fatalf("unexpected ErrMsg: %q", got.ErrMsg)
}
if got.Index != 0 || got.Query != "张三" {
t.Errorf("Index/Query mismatch: %+v", got)
}
if len(got.Users) != 1 || got.Users[0].OpenID != "ou_a" {
t.Errorf("Users mismatch: %+v", got.Users)
}
if got.HasMore {
t.Errorf("HasMore should be false")
}
}
func TestRunOneQuery_APINonZeroCode(t *testing.T) {
rt, reg := runOneQueryRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: searchUserURL,
Body: map[string]interface{}{"code": 99991663, "msg": "rate limited"},
})
got := runOneQuery(context.Background(), rt, 3, "alice", nil)
if got.Index != 3 || got.Query != "alice" {
t.Errorf("Index/Query mismatch: %+v", got)
}
if got.ErrMsg != "API 99991663: rate limited" {
t.Errorf("ErrMsg = %q, want 'API 99991663: rate limited'", got.ErrMsg)
}
if got.Users != nil || got.HasMore {
t.Errorf("on error, Users/HasMore must be zero values; got %+v", got)
}
}
func TestRunOneQuery_HTTPNon200(t *testing.T) {
rt, reg := runOneQueryRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: searchUserURL,
Status: 503,
Body: map[string]interface{}{"reason": "upstream_unavailable"},
})
got := runOneQuery(context.Background(), rt, 1, "bob", nil)
if !strings.HasPrefix(got.ErrMsg, "HTTP 503 Service Unavailable: ") {
t.Errorf("ErrMsg should start with status line; got %q", got.ErrMsg)
}
if !strings.Contains(got.ErrMsg, "upstream_unavailable") {
t.Errorf("ErrMsg should include response body for diagnosis; got %q", got.ErrMsg)
}
if got.ErrCode != 503 {
t.Errorf("ErrCode = %d, want 503", got.ErrCode)
}
}
func TestRunOneQuery_HTTPNon200_BodyTruncated(t *testing.T) {
rt, reg := runOneQueryRuntime(t)
long := strings.Repeat("x", 1000)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: searchUserURL,
Status: 500,
Body: map[string]interface{}{"detail": long},
})
got := runOneQuery(context.Background(), rt, 0, "alice", nil)
if !strings.HasSuffix(got.ErrMsg, "...") {
t.Errorf("oversized body should be truncated with '...' suffix; got %q", got.ErrMsg)
}
if len(got.ErrMsg) > 300 {
t.Errorf("ErrMsg %d chars exceeds reasonable budget; got %q", len(got.ErrMsg), got.ErrMsg)
}
}
// SDK-level transport / envelope-unmarshal failures arrive as Go errors from
// runtime.DoAPI; the worker converts them by calling err.Error() rather than
// adding its own prefix, so the assertion here is "ErrMsg is non-empty and
// preserves the underlying message" — the exact text comes from the SDK.
func TestRunOneQuery_TransportError(t *testing.T) {
rt, reg := runOneQueryRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: searchUserURL,
RawBody: []byte("{not-json"),
})
got := runOneQuery(context.Background(), rt, 2, "carol", nil)
if got.ErrMsg == "" {
t.Fatalf("expected non-empty ErrMsg for malformed body")
}
if got.Index != 2 || got.Query != "carol" {
t.Errorf("Index/Query mismatch: %+v", got)
}
if got.Users != nil || got.HasMore {
t.Errorf("on error, Users/HasMore must be zero values; got %+v", got)
}
}
func TestFanoutAssemble_OrderAndShape(t *testing.T) {
results := []fanoutResult{
{Index: 1, Query: "bob", Users: []searchUser{{OpenID: "ou_b"}}, HasMore: true},
{Index: 0, Query: "alice", Users: []searchUser{{OpenID: "ou_a1"}, {OpenID: "ou_a2"}}, HasMore: false},
{Index: 2, Query: "carol", ErrMsg: "API 1: nope"},
}
resp, err := buildFanoutResponse([]string{"alice", "bob", "carol"}, results)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(resp.Users) != 3 {
t.Fatalf("Users length: got %d, want 3 (carol failed → 0 users)", len(resp.Users))
}
if resp.Users[0].OpenID != "ou_a1" || resp.Users[0].MatchedQuery != "alice" {
t.Errorf("Users[0]: got %+v", resp.Users[0])
}
if resp.Users[1].OpenID != "ou_a2" || resp.Users[1].MatchedQuery != "alice" {
t.Errorf("Users[1]: got %+v", resp.Users[1])
}
if resp.Users[2].OpenID != "ou_b" || resp.Users[2].MatchedQuery != "bob" {
t.Errorf("Users[2]: got %+v", resp.Users[2])
}
if len(resp.Queries) != 3 {
t.Fatalf("Queries length: got %d, want 3 (full enumeration)", len(resp.Queries))
}
want := []querySummary{
{Query: "alice", Error: "", HasMore: false},
{Query: "bob", Error: "", HasMore: true},
{Query: "carol", Error: "API 1: nope", HasMore: false},
}
for i, w := range want {
if resp.Queries[i] != w {
t.Errorf("Queries[%d]: got %+v, want %+v", i, resp.Queries[i], w)
}
}
}
func TestFanoutAssemble_AllFailed_ReturnsError(t *testing.T) {
results := []fanoutResult{
{Index: 0, Query: "alice", ErrMsg: "API 99991663: rate limit"},
{Index: 1, Query: "bob", ErrMsg: "HTTP 500 Internal Server Error"},
}
_, err := buildFanoutResponse([]string{"alice", "bob"}, results)
if err == nil {
t.Fatalf("expected error when all queries failed")
}
if !strings.Contains(err.Error(), "rate limit") {
t.Errorf("expected first error (rate limit) to be returned; got %v", err)
}
// Document the count is part of the message — agents grep for it.
if !strings.Contains(err.Error(), "all 2 queries failed") {
t.Errorf("expected 'all 2 queries failed' substring; got %v", err)
}
}
// Codes from the first failure must propagate through output.ErrAPI so the
// CLI's exit-code classifier sees the real signal (e.g., 99991663 rate limit)
// instead of 0, which would mean "success" in the Lark protocol.
func TestFanoutAssemble_AllFailed_PropagatesFirstCode(t *testing.T) {
results := []fanoutResult{
{Index: 0, Query: "alice", ErrMsg: "API 99991663: rate limit", ErrCode: 99991663},
{Index: 1, Query: "bob", ErrMsg: "HTTP 500", ErrCode: 500},
}
_, err := buildFanoutResponse([]string{"alice", "bob"}, results)
if err == nil {
t.Fatalf("expected error")
}
if !strings.Contains(err.Error(), "rate limit") {
t.Errorf("error should contain first ErrMsg; got %v", err)
}
}
func TestFanoutAssemble_PartialFailureOK(t *testing.T) {
results := []fanoutResult{
{Index: 0, Query: "alice", Users: []searchUser{{OpenID: "ou_a"}}},
{Index: 1, Query: "bob", ErrMsg: "API 5: not found"},
}
resp, err := buildFanoutResponse([]string{"alice", "bob"}, results)
if err != nil {
t.Fatalf("partial failure must NOT be a hard error; got %v", err)
}
if len(resp.Users) != 1 {
t.Errorf("Users: got %d, want 1", len(resp.Users))
}
if resp.Queries[1].Error != "API 5: not found" {
t.Errorf("Queries[1].Error: got %q", resp.Queries[1].Error)
}
}
func TestFanoutAssemble_NoTopLevelHasMore(t *testing.T) {
results := []fanoutResult{
{Index: 0, Query: "alice", HasMore: true},
}
resp, err := buildFanoutResponse([]string{"alice"}, results)
if err != nil {
t.Fatalf("unexpected: %v", err)
}
raw, _ := json.Marshal(resp)
var asMap map[string]interface{}
if err := json.Unmarshal(raw, &asMap); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if _, ok := asMap["has_more"]; ok {
t.Errorf("fanoutResponse must not have top-level has_more; got %v", asMap)
}
if _, ok := asMap["users"]; !ok {
t.Errorf("fanoutResponse missing users")
}
if _, ok := asMap["queries"]; !ok {
t.Errorf("fanoutResponse missing queries")
}
}
// Verifies that with the auto-pagination flags removed, --page-all / --page-limit
// are no longer accepted. cobra must reject the unknown flag at parse time —
// no stub is registered because the command should never reach the API.
@@ -827,3 +1210,341 @@ func TestSearchUser_Integration_NoAutoPaginationFlags(t *testing.T) {
})
}
}
func TestFanout_FilterAppliedToEachQuery(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/contact/v3/users/search",
Reusable: true,
Body: map[string]interface{}{"code": 0, "msg": "ok",
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false}},
}
reg.Register(stub)
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", "alice,bob", "--has-chatted",
"--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(stub.CapturedBodies) < 2 {
t.Fatalf("expected ≥2 captured request bodies, got %d", len(stub.CapturedBodies))
}
bodyByQuery := map[string]map[string]interface{}{}
for i, raw := range stub.CapturedBodies {
var body map[string]interface{}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("unmarshal req %d: %v", i, err)
}
bodyByQuery[body["query"].(string)] = body
filter, _ := body["filter"].(map[string]interface{})
if filter == nil || filter["has_contact"] != true {
t.Errorf("req %d (query=%v): expected filter.has_contact=true; got body=%v", i, body["query"], body)
}
}
if _, ok := bodyByQuery["alice"]; !ok {
t.Errorf("missing request for query=alice; captured=%v", bodyByQuery)
}
if _, ok := bodyByQuery["bob"]; !ok {
t.Errorf("missing request for query=bob; captured=%v", bodyByQuery)
}
}
func TestFanout_PartialFailure_ExitZero(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/contact/v3/users/search",
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), `"alice"`) },
Body: map[string]interface{}{"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
"has_more": false,
}},
})
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/contact/v3/users/search",
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), `"bob"`) },
Status: 500,
Body: map[string]interface{}{},
})
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", "alice,bob", "--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("partial failure should NOT propagate as error; got %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v\nstdout=%s", err, stdout.String())
}
data := got["data"].(map[string]interface{})
users := data["users"].([]interface{})
if len(users) != 1 {
t.Errorf("users: expected 1 (alice), got %d; stdout=%s", len(users), stdout.String())
}
queries := data["queries"].([]interface{})
if len(queries) != 2 {
t.Fatalf("queries: expected 2, got %d", len(queries))
}
q1 := queries[1].(map[string]interface{})
if !strings.HasPrefix(q1["error"].(string), "HTTP 500") {
t.Errorf("queries[1].error: got %q", q1["error"])
}
}
func TestFanout_AllFailed_ExitNonZero(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/contact/v3/users/search",
Reusable: true,
Status: 500, Body: map[string]interface{}{"reason": "boom"},
})
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", "alice,bob", "--format", "json", "--as", "user",
}, f, stdout)
if err == nil {
t.Fatalf("expected error when all queries failed")
}
// First failure's HTTP code (500) and a digestible reason must propagate
// so agents can classify (vs. a generic ExitInternal masking the upstream).
msg := err.Error()
if !strings.Contains(msg, "500") {
t.Errorf("error must propagate first failure's HTTP 500 code; got %q", msg)
}
if !strings.Contains(msg, "all 2 queries failed") {
t.Errorf("error must indicate the all-failed mode; got %q", msg)
}
}
func TestFanout_ConcurrencyLimitFive(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
var inFlight, peak int32
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/contact/v3/users/search",
Reusable: true,
OnMatch: func(req *http.Request) {
cur := atomic.AddInt32(&inFlight, 1)
defer atomic.AddInt32(&inFlight, -1)
for {
p := atomic.LoadInt32(&peak)
if cur <= p || atomic.CompareAndSwapInt32(&peak, p, cur) {
break
}
}
time.Sleep(50 * time.Millisecond)
},
Body: map[string]interface{}{"code": 0, "msg": "ok",
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false}},
})
queries := []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"}
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", strings.Join(queries, ","),
"--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if peak > 5 {
t.Errorf("concurrency peak = %d, want ≤ 5", peak)
}
if peak < 2 {
t.Errorf("concurrency peak = %d, want ≥ 2 (test should observe parallelism)", peak)
}
}
func TestFanout_PanicRecovery(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/contact/v3/users/search",
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), `"boom"`) },
OnMatch: func(req *http.Request) {
panic("synthetic test panic")
},
Body: map[string]interface{}{},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/contact/v3/users/search",
Reusable: true,
Body: map[string]interface{}{"code": 0, "msg": "ok",
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false}},
})
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", "ok,boom,fine", "--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("partial panic must not bubble; got %v", err)
}
var got map[string]interface{}
_ = json.Unmarshal(stdout.Bytes(), &got)
queries := got["data"].(map[string]interface{})["queries"].([]interface{})
q1 := queries[1].(map[string]interface{})
if !strings.HasPrefix(q1["error"].(string), "internal error:") {
t.Errorf("queries[1].error: expected 'internal error:' prefix, got %q", q1["error"])
}
for _, marker := range []string{"goroutine ", ".go:", "runtime."} {
if strings.Contains(stderr.String(), marker) {
t.Errorf("stderr leaked stack-trace marker %q; got=%s", marker, stderr.String())
}
}
}
func TestFanout_MatchedQueryFidelity(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/contact/v3/users/search",
Reusable: true,
Body: map[string]interface{}{"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "ou_x"}},
"has_more": false,
}},
})
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", "张三,Alice 王", "--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
_ = json.Unmarshal(stdout.Bytes(), &got)
users := got["data"].(map[string]interface{})["users"].([]interface{})
if len(users) != 2 {
t.Fatalf("users: got %d, want 2", len(users))
}
want := []string{"张三", "Alice 王"}
for i, w := range want {
mq := users[i].(map[string]interface{})["matched_query"]
if mq != w {
t.Errorf("users[%d].matched_query: got %v, want %q (must be original input verbatim)", i, mq, w)
}
}
}
func TestFanout_NDJSONStdoutClean(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/contact/v3/users/search",
Reusable: true,
Body: map[string]interface{}{"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
"has_more": false,
}},
})
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", "a,a,b", "--format", "ndjson", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
for _, marker := range []string{"queries,", "total users", "with has_more"} {
if strings.Contains(stdout.String(), marker) {
t.Errorf("ndjson stdout must not contain %q; got=%q", marker, stdout.String())
}
}
_ = stderr
}
func TestFanout_CSVHasMatchedQueryColumn(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/contact/v3/users/search",
Reusable: true,
Body: map[string]interface{}{"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
"has_more": false,
}},
})
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", "alice,bob", "--format", "csv", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if !strings.Contains(stdout.String(), "matched_query") {
t.Errorf("csv stdout must include matched_query column; got=%q", stdout.String())
}
if !strings.Contains(stderr.String(), "queries") || !strings.Contains(stderr.String(), "total users") {
t.Errorf("csv summary should land on stderr; got=%q", stderr.String())
}
}
func TestFanout_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, searchUserDefaultConfig())
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", "alice,bob", "--has-chatted", "--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
out := stdout.String()
for _, want := range []string{"alice", "bob", "POST", "/contact/v3/users/search", "has_contact"} {
if !strings.Contains(out, want) {
t.Errorf("dry-run output missing %q; got=%q", want, out)
}
}
// One DryRunAPI description per query.
if strings.Count(out, "/contact/v3/users/search") < 2 {
t.Errorf("dry-run should describe ≥2 API calls (one per query); got=%q", out)
}
}
// Spec §7 promises single-query --query mode is "零变化". The fanout summary
// hint was broadened to csv (good — stderr can carry it without corrupting
// the csv stream on stdout); the single-query refine hint must NOT inherit
// that broadening, since pre-fanout it only fired on pretty/table.
func TestSearchUser_Integration_CSVSingleQueryNoRefineHint(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/contact/v3/users/search",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
"has_more": true,
"page_token": "tok_next",
},
},
})
err := mountAndRun(t, ContactSearchUser, []string{"+search-user", "--query", "x", "--format", "csv", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if strings.Contains(stderr.String(), "refine") {
t.Errorf("single-query --format csv must NOT emit the refine hint; got stderr=%q", stderr.String())
}
}
// A pre-canceled ctx must be observed by runOneQuery before it dispatches the
// HTTP call. The error string is exactly "context canceled" because that's
// what context.Context.Err().Error() returns — agents may grep for it.
func TestRunOneQuery_CtxCanceledEarly(t *testing.T) {
rt, _ := runOneQueryRuntime(t)
// Deliberately register no stub: runOneQuery must short-circuit before
// touching the transport, so the absence of a stub is the assertion.
ctx, cancel := context.WithCancel(context.Background())
cancel()
got := runOneQuery(ctx, rt, 0, "alice", nil)
if got.ErrMsg != "context canceled" {
t.Errorf("ErrMsg: got %q, want %q", got.ErrMsg, "context canceled")
}
if got.Index != 0 || got.Query != "alice" {
t.Errorf("Index/Query mismatch: %+v", got)
}
}

View File

@@ -9,6 +9,7 @@ import (
"mime"
"net/mail"
"strings"
"time"
"github.com/larksuite/cli/extension/fileio"
)
@@ -215,11 +216,24 @@ type PatchOp struct {
Target AttachmentTarget `json:"target,omitempty"`
SignatureID string `json:"signature_id,omitempty"`
// Calendar event fields, used by set_calendar. The raw ISO 8601 strings
// are shown in dry-run output; the shortcut layer pre-builds the ICS
// blob into CalendarICS below before Apply runs.
EventSummary string `json:"event_summary,omitempty"`
EventStart string `json:"event_start,omitempty"`
EventEnd string `json:"event_end,omitempty"`
EventLocation string `json:"event_location,omitempty"`
// RenderedSignatureHTML is set by the shortcut layer (not from JSON) after
// fetching and interpolating the signature. The patch layer uses this
// pre-rendered content for insert_signature ops.
RenderedSignatureHTML string `json:"-"`
SignatureImages []SignatureImage `json:"-"`
// CalendarICS holds the pre-built RFC 5545 ICS blob for a set_calendar
// op. Populated by the shortcut layer after the snapshot is parsed and
// organizer/attendee addresses can be resolved. Not serialised.
CalendarICS []byte `json:"-"`
}
// SignatureImage holds pre-downloaded image data for signature inline images.
@@ -327,6 +341,26 @@ func (op PatchOp) Validate() error {
}
case "remove_signature":
// No required fields.
case "set_calendar":
if strings.TrimSpace(op.EventSummary) == "" {
return fmt.Errorf("set_calendar requires event_summary")
}
if strings.TrimSpace(op.EventStart) == "" || strings.TrimSpace(op.EventEnd) == "" {
return fmt.Errorf("set_calendar requires event_start and event_end")
}
start, err := parseISO8601(op.EventStart)
if err != nil {
return fmt.Errorf("set_calendar: event_start must be a valid ISO 8601 timestamp")
}
end, err := parseISO8601(op.EventEnd)
if err != nil {
return fmt.Errorf("set_calendar: event_end must be a valid ISO 8601 timestamp")
}
if !end.After(start) {
return fmt.Errorf("set_calendar: event_end must be after event_start")
}
case "remove_calendar":
// No required fields.
default:
return fmt.Errorf("unsupported op %q", op.Op)
}
@@ -400,3 +434,19 @@ func MustJSON(v interface{}) string {
}
return string(data)
}
// parseISO8601 tries common ISO 8601 timestamp layouts, accepting both
// with-seconds (RFC 3339) and without-seconds variants.
func parseISO8601(s string) (time.Time, error) {
for _, layout := range []string{
time.RFC3339,
"2006-01-02T15:04Z07:00",
"2006-01-02T15:04:05",
"2006-01-02T15:04",
} {
if t, err := time.Parse(layout, s); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("cannot parse %q as ISO 8601", s)
}

View File

@@ -136,6 +136,10 @@ func applyOp(dctx *DraftCtx, snapshot *DraftSnapshot, op PatchOp, options PatchO
return insertSignatureOp(snapshot, op)
case "remove_signature":
return removeSignatureOp(snapshot)
case "set_calendar":
return applyCalendarSet(snapshot, op.CalendarICS)
case "remove_calendar":
return applyCalendarRemove(snapshot)
default:
return fmt.Errorf("unsupported patch op %q", op.Op)
}

View File

@@ -0,0 +1,188 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package draft
import (
"fmt"
"strings"
)
const calendarMediaType = "text/calendar"
// applyCalendarSet installs or replaces the text/calendar MIME part in the
// snapshot. The caller is expected to have pre-built icsData using the
// snapshot's From/To/Cc addresses.
func applyCalendarSet(snapshot *DraftSnapshot, icsData []byte) error {
if len(icsData) == 0 {
return fmt.Errorf("set_calendar: ICS data is empty (shortcut layer must pre-build it)")
}
setCalendarPart(snapshot, icsData)
return nil
}
// applyCalendarRemove strips the text/calendar part from the snapshot.
// No-op if no calendar part exists.
func applyCalendarRemove(snapshot *DraftSnapshot) error {
removeCalendarPart(snapshot)
return nil
}
// setCalendarPart places exactly one text/calendar part inside
// multipart/alternative, matching the Feishu client behavior. Any existing
// text/calendar parts elsewhere in the tree are removed first.
func setCalendarPart(snapshot *DraftSnapshot, icsData []byte) {
newPart := &Part{
MediaType: calendarMediaType,
MediaParams: map[string]string{"charset": "UTF-8", "method": "REQUEST"},
Body: icsData,
Dirty: true,
}
if snapshot.Body == nil {
snapshot.Body = newPart
return
}
// Remove all existing text/calendar parts from everywhere in the tree.
if strings.EqualFold(snapshot.Body.MediaType, calendarMediaType) {
snapshot.Body = newPart
return
}
removeAllPartsByMediaType(snapshot.Body, calendarMediaType)
// Place inside the existing multipart/alternative.
if alt := FindPartByMediaType(snapshot.Body, "multipart/alternative"); alt != nil {
alt.Children = append(alt.Children, newPart)
alt.Dirty = true
return
}
// No multipart/alternative exists. If the body is a single leaf,
// wrap it in multipart/alternative together with the calendar.
if !snapshot.Body.IsMultipart() {
original := *snapshot.Body
// Reset all header-carrying fields so the serializer constructs a fresh
// Content-Type from MediaType instead of reusing the stale leaf headers.
snapshot.Body.Headers = nil
snapshot.Body.MediaType = "multipart/alternative"
snapshot.Body.MediaParams = nil
snapshot.Body.ContentDisposition = ""
snapshot.Body.ContentDispositionArg = nil
snapshot.Body.ContentID = ""
snapshot.Body.PartID = ""
snapshot.Body.Body = nil
snapshot.Body.TransferEncoding = ""
snapshot.Body.RawEntity = nil
snapshot.Body.Preamble = nil
snapshot.Body.Epilogue = nil
snapshot.Body.EncodingProblem = false
snapshot.Body.Children = []*Part{&original, newPart}
snapshot.Body.Dirty = true
return
}
// Multipart body without an alternative sub-part (e.g. multipart/mixed
// with a text/html child). Find the first text/* child and wrap it in
// a new multipart/alternative that also contains the calendar.
for i, child := range snapshot.Body.Children {
if child != nil && strings.HasPrefix(strings.ToLower(child.MediaType), "text/") {
alt := &Part{
MediaType: "multipart/alternative",
Children: []*Part{child, newPart},
Dirty: true,
}
snapshot.Body.Children[i] = alt
snapshot.Body.Dirty = true
return
}
}
// Fallback: append to the root multipart container.
snapshot.Body.Children = append(snapshot.Body.Children, newPart)
snapshot.Body.Dirty = true
}
func removeCalendarPart(snapshot *DraftSnapshot) {
if snapshot.Body == nil {
return
}
if strings.EqualFold(snapshot.Body.MediaType, calendarMediaType) {
snapshot.Body = nil
return
}
removeAllPartsByMediaType(snapshot.Body, calendarMediaType)
}
// FindPartByMediaType walks the MIME tree and returns the first part with
// the given media type, or nil when not found.
func FindPartByMediaType(root *Part, mediaType string) *Part {
if root == nil {
return nil
}
if strings.EqualFold(root.MediaType, mediaType) {
return root
}
for _, child := range root.Children {
if found := FindPartByMediaType(child, mediaType); found != nil {
return found
}
}
return nil
}
// findAllPartsByMediaType walks the MIME tree and returns every part with
// the given media type. Used in tests to assert tree contents.
func findAllPartsByMediaType(root *Part, mediaType string) []*Part {
if root == nil {
return nil
}
var result []*Part
if strings.EqualFold(root.MediaType, mediaType) {
result = append(result, root)
}
for _, child := range root.Children {
result = append(result, findAllPartsByMediaType(child, mediaType)...)
}
return result
}
// removePartByMediaType removes the first part with the given media type from
// the MIME tree. The parent is marked dirty when a removal happens.
func removePartByMediaType(root *Part, mediaType string) {
if root == nil {
return
}
for i, child := range root.Children {
if child != nil && strings.EqualFold(child.MediaType, mediaType) {
root.Children = append(root.Children[:i], root.Children[i+1:]...)
root.Dirty = true
return
}
removePartByMediaType(child, mediaType)
}
}
// removeAllPartsByMediaType removes every part with the given media type from
// the MIME tree, at all nesting levels.
func removeAllPartsByMediaType(root *Part, mediaType string) {
if root == nil {
return
}
var kept []*Part
removed := false
for _, child := range root.Children {
if child != nil && strings.EqualFold(child.MediaType, mediaType) {
removed = true
continue
}
kept = append(kept, child)
}
if removed {
root.Children = kept
root.Dirty = true
}
for _, child := range root.Children {
removeAllPartsByMediaType(child, mediaType)
}
}

View File

@@ -0,0 +1,429 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package draft
import (
"strings"
"testing"
)
const fixtureCalData = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n"
// ---------------------------------------------------------------------------
// set_calendar — validate
// ---------------------------------------------------------------------------
func TestSetCalendar_ValidateRequiresSummary(t *testing.T) {
err := PatchOp{Op: "set_calendar", EventStart: "2026-04-25T10:00+08:00", EventEnd: "2026-04-25T11:00+08:00"}.Validate()
if err == nil || !strings.Contains(err.Error(), "event_summary") {
t.Errorf("expected event_summary error, got %v", err)
}
}
func TestSetCalendar_ValidateRequiresStartAndEnd(t *testing.T) {
err := PatchOp{Op: "set_calendar", EventSummary: "Meeting", EventStart: "2026-04-25T10:00+08:00"}.Validate()
if err == nil || !strings.Contains(err.Error(), "event_start and event_end") {
t.Errorf("expected start/end error, got %v", err)
}
}
func TestSetCalendar_ValidateInvalidStartFormat(t *testing.T) {
err := PatchOp{Op: "set_calendar", EventSummary: "M", EventStart: "not-a-date", EventEnd: "2026-04-25T11:00+08:00"}.Validate()
if err == nil || !strings.Contains(err.Error(), "event_start") {
t.Errorf("expected event_start error for bad format, got %v", err)
}
}
func TestSetCalendar_ValidateInvalidEndFormat(t *testing.T) {
err := PatchOp{Op: "set_calendar", EventSummary: "M", EventStart: "2026-04-25T10:00+08:00", EventEnd: "not-a-date"}.Validate()
if err == nil || !strings.Contains(err.Error(), "event_end") {
t.Errorf("expected event_end error for bad format, got %v", err)
}
}
func TestSetCalendar_ValidateEndNotAfterStart(t *testing.T) {
err := PatchOp{Op: "set_calendar", EventSummary: "M", EventStart: "2026-04-25T11:00+08:00", EventEnd: "2026-04-25T10:00+08:00"}.Validate()
if err == nil || !strings.Contains(err.Error(), "after") {
t.Errorf("expected end-after-start error, got %v", err)
}
}
func TestSetCalendar_ValidateOK(t *testing.T) {
err := PatchOp{
Op: "set_calendar",
EventSummary: "Meeting",
EventStart: "2026-04-25T10:00+08:00",
EventEnd: "2026-04-25T11:00+08:00",
}.Validate()
if err != nil {
t.Errorf("expected no error, got %v", err)
}
}
// ---------------------------------------------------------------------------
// set_calendar — Apply adds text/calendar part when none exists
// ---------------------------------------------------------------------------
func TestSetCalendar_AddsCalendarPartToHTMLDraft(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Meeting
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<p>Hello</p>`)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "set_calendar",
EventSummary: "Meeting",
EventStart: "2026-04-25T10:00+08:00",
EventEnd: "2026-04-25T11:00+08:00",
CalendarICS: []byte(fixtureCalData),
}},
})
if err != nil {
t.Fatalf("Apply: %v", err)
}
part := FindPartByMediaType(snapshot.Body, calendarMediaType)
if part == nil {
t.Fatal("text/calendar part not added to draft")
}
if string(part.Body) != fixtureCalData {
t.Errorf("calendar part body mismatch: got %q", part.Body)
}
if part.MediaParams["method"] != "REQUEST" {
t.Errorf("calendar part missing method=REQUEST in MediaParams: %v", part.MediaParams)
}
}
// ---------------------------------------------------------------------------
// set_calendar — Apply replaces existing text/calendar part
// ---------------------------------------------------------------------------
func TestSetCalendar_ReplacesExistingCalendarPart(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Meeting
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="b1"
--b1
Content-Type: text/html; charset=UTF-8
<p>Hello</p>
--b1
Content-Type: text/calendar; charset=UTF-8
BEGIN:VCALENDAR
VERSION:2.0
SUMMARY:OLD
END:VCALENDAR
--b1--`)
newICS := []byte("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nSUMMARY:NEW\r\nEND:VCALENDAR\r\n")
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "set_calendar",
EventSummary: "NEW",
EventStart: "2026-04-25T10:00+08:00",
EventEnd: "2026-04-25T11:00+08:00",
CalendarICS: newICS,
}},
})
if err != nil {
t.Fatalf("Apply: %v", err)
}
part := FindPartByMediaType(snapshot.Body, calendarMediaType)
if part == nil {
t.Fatal("text/calendar part missing")
}
if !strings.Contains(string(part.Body), "SUMMARY:NEW") {
t.Errorf("expected new SUMMARY, got %q", part.Body)
}
if strings.Contains(string(part.Body), "SUMMARY:OLD") {
t.Errorf("old SUMMARY not replaced")
}
}
// ---------------------------------------------------------------------------
// set_calendar — Apply requires pre-built ICS
// ---------------------------------------------------------------------------
func TestSetCalendar_EmptyICSIsError(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Meeting
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<p>Hello</p>`)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "set_calendar",
EventSummary: "Meeting",
EventStart: "2026-04-25T10:00+08:00",
EventEnd: "2026-04-25T11:00+08:00",
// CalendarICS intentionally nil — simulates missing pre-process.
}},
})
if err == nil {
t.Fatal("expected error for missing CalendarICS")
}
if !strings.Contains(err.Error(), "ICS data is empty") {
t.Errorf("unexpected error: %v", err)
}
}
// ---------------------------------------------------------------------------
// remove_calendar
// ---------------------------------------------------------------------------
func TestRemoveCalendar_StripsCalendarPart(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Meeting
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="b1"
--b1
Content-Type: text/html; charset=UTF-8
<p>Hello</p>
--b1
Content-Type: text/calendar; charset=UTF-8
BEGIN:VCALENDAR
END:VCALENDAR
--b1--`)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "remove_calendar"}},
})
if err != nil {
t.Fatalf("Apply: %v", err)
}
if part := FindPartByMediaType(snapshot.Body, calendarMediaType); part != nil {
t.Errorf("text/calendar part should be removed, but still found")
}
}
func TestRemoveCalendar_NoOpWhenAbsent(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Plain
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<p>Hello</p>`)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "remove_calendar"}},
})
if err != nil {
t.Fatalf("Apply: %v", err)
}
// Body remains intact.
if snapshot.Body == nil {
t.Fatal("body unexpectedly nil")
}
}
// ---------------------------------------------------------------------------
// Internal MIME helpers (coverage)
// ---------------------------------------------------------------------------
func TestFindPartByMediaType_CaseInsensitive(t *testing.T) {
root := &Part{
MediaType: "multipart/mixed",
Children: []*Part{
{MediaType: "TEXT/Calendar"},
},
}
got := FindPartByMediaType(root, "text/calendar")
if got == nil {
t.Fatal("expected to find part despite case mismatch")
}
}
func TestRemovePartByMediaType_MarksParentDirty(t *testing.T) {
root := &Part{
MediaType: "multipart/mixed",
Children: []*Part{
{MediaType: "text/calendar"},
{MediaType: "text/html"},
},
}
removePartByMediaType(root, "text/calendar")
if len(root.Children) != 1 {
t.Fatalf("expected 1 remaining child, got %d", len(root.Children))
}
if !root.Dirty {
t.Error("parent not marked dirty after removal")
}
}
func TestSetCalendar_CollapsesToOneInsideAlternative(t *testing.T) {
// Feishu client creates two text/calendar copies: one inside
// multipart/alternative and one as an inline attachment in
// multipart/mixed. set_calendar must collapse them to a single
// copy inside multipart/alternative.
snapshot := mustParseFixtureDraft(t, `Subject: Meeting
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="outer"
--outer
Content-Type: multipart/alternative; boundary="inner"
--inner
Content-Type: text/html; charset=UTF-8
<p>Hello</p>
--inner
Content-Type: text/calendar; charset=UTF-8
BEGIN:VCALENDAR
SUMMARY:OLD
END:VCALENDAR
--inner--
--outer
Content-Type: text/calendar; charset=UTF-8; name="invite.ics"
Content-Id: <invite.ics>
BEGIN:VCALENDAR
SUMMARY:OLD
END:VCALENDAR
--outer--`)
newICS := []byte("BEGIN:VCALENDAR\r\nSUMMARY:NEW\r\nEND:VCALENDAR\r\n")
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "set_calendar",
EventSummary: "NEW",
EventStart: "2026-04-25T10:00+08:00",
EventEnd: "2026-04-25T11:00+08:00",
CalendarICS: newICS,
}},
})
if err != nil {
t.Fatalf("Apply: %v", err)
}
// Exactly one text/calendar part should remain, inside alternative.
parts := findAllPartsByMediaType(snapshot.Body, calendarMediaType)
if len(parts) != 1 {
t.Fatalf("expected 1 text/calendar part, got %d", len(parts))
}
if !strings.Contains(string(parts[0].Body), "SUMMARY:NEW") {
t.Errorf("expected SUMMARY:NEW, got %q", parts[0].Body)
}
// The calendar part must be a child of multipart/alternative.
alt := FindPartByMediaType(snapshot.Body, "multipart/alternative")
if alt == nil {
t.Fatal("multipart/alternative not found")
}
found := false
for _, child := range alt.Children {
if strings.EqualFold(child.MediaType, calendarMediaType) {
found = true
}
}
if !found {
t.Error("text/calendar part not inside multipart/alternative")
}
}
func TestRemoveCalendar_RootLevelCalendarBody(t *testing.T) {
// When the snapshot body is itself a text/calendar leaf (no multipart
// wrapper), removeCalendarPart must nil out snapshot.Body rather than
// trying to remove it from a parent's children slice.
snapshot := &DraftSnapshot{
Body: &Part{
MediaType: "text/calendar",
Body: []byte(fixtureCalData),
},
}
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "remove_calendar"}},
})
if err != nil {
t.Fatalf("Apply: %v", err)
}
if snapshot.Body != nil {
t.Errorf("snapshot.Body should be nil after removing root-level text/calendar, got %+v", snapshot.Body)
}
}
func TestSetCalendarPart_OnNilBodyCreatesLeaf(t *testing.T) {
snapshot := &DraftSnapshot{}
setCalendarPart(snapshot, []byte(fixtureCalData))
if snapshot.Body == nil {
t.Fatal("body should be created")
}
if !strings.EqualFold(snapshot.Body.MediaType, calendarMediaType) {
t.Errorf("expected %s leaf, got %s", calendarMediaType, snapshot.Body.MediaType)
}
}
func TestSetCalendarPart_MixedWithoutAlternativeWrapsTextChild(t *testing.T) {
// multipart/mixed with a text/html child but no alternative sub-part.
// setCalendarPart should wrap the text/html in a new alternative.
snapshot := &DraftSnapshot{
Body: &Part{
MediaType: "multipart/mixed",
Children: []*Part{
{MediaType: "text/html", Body: []byte("<p>Hi</p>")},
{MediaType: "application/pdf", Body: []byte("pdf-data")},
},
},
}
setCalendarPart(snapshot, []byte(fixtureCalData))
if snapshot.Body.MediaType != "multipart/mixed" {
t.Fatalf("root should stay multipart/mixed, got %s", snapshot.Body.MediaType)
}
alt := FindPartByMediaType(snapshot.Body, "multipart/alternative")
if alt == nil {
t.Fatal("expected a multipart/alternative child to be created")
}
if len(alt.Children) != 2 {
t.Fatalf("alternative should have 2 children, got %d", len(alt.Children))
}
if !strings.EqualFold(alt.Children[0].MediaType, "text/html") {
t.Errorf("first alternative child should be text/html, got %s", alt.Children[0].MediaType)
}
if !strings.EqualFold(alt.Children[1].MediaType, calendarMediaType) {
t.Errorf("second alternative child should be text/calendar, got %s", alt.Children[1].MediaType)
}
}
func TestSetCalendarPart_FallbackAppendsToMultipart(t *testing.T) {
// multipart/mixed with only non-text children (no text/* to wrap).
snapshot := &DraftSnapshot{
Body: &Part{
MediaType: "multipart/mixed",
Children: []*Part{
{MediaType: "application/pdf", Body: []byte("pdf-data")},
},
},
}
setCalendarPart(snapshot, []byte(fixtureCalData))
found := false
for _, child := range snapshot.Body.Children {
if strings.EqualFold(child.MediaType, calendarMediaType) {
found = true
}
}
if !found {
t.Error("text/calendar should be appended as fallback child")
}
}

View File

@@ -420,8 +420,9 @@ func (b Builder) HTMLBody(body []byte) Builder {
}
// CalendarBody sets the text/calendar body (e.g. for meeting invitations).
// May be combined with TextBody and/or HTMLBody; the resulting parts are wrapped
// in multipart/alternative.
// When combined with TextBody or HTMLBody, the calendar part is placed inside
// multipart/alternative alongside the body parts, matching the Feishu client
// convention for calendar invitation emails.
func (b Builder) CalendarBody(body []byte) Builder {
b.calendarBody = body
return b
@@ -731,6 +732,9 @@ func (b Builder) Build() ([]byte, error) {
// ── Body ───────────────────────────────────────────────────────────────────
// Full MIME hierarchy (outer layers only present when needed):
// multipart/mixed → multipart/related → multipart/alternative → body parts
//
// text/calendar lives inside multipart/alternative as an alternative
// representation of the message body, matching the Feishu client behavior.
if len(b.attachments) > 0 {
outerB := newBoundary()
writeHeader(&buf, "Content-Type", "multipart/mixed; boundary="+outerB)
@@ -809,27 +813,27 @@ func writePrimaryBody(buf *bytes.Buffer, b Builder) {
}
}
// writeAlternativeOrSingleBody writes the text body block.
// If multiple body types (text/plain, text/html, text/calendar) are present,
// they are wrapped in multipart/alternative. Otherwise a single part is written.
// writeAlternativeOrSingleBody writes the body block. When multiple content
// types coexist (text/plain, text/html, text/calendar), they are wrapped in
// multipart/alternative. text/calendar lives inside alternative as an
// alternative representation, matching the Feishu client behavior.
func writeAlternativeOrSingleBody(buf *bytes.Buffer, b Builder) {
hasText := len(b.textBody) > 0
hasHTML := len(b.htmlBody) > 0
hasCal := len(b.calendarBody) > 0
bodyCount := 0
partCount := 0
if hasText {
bodyCount++
partCount++
}
if hasHTML {
bodyCount++
partCount++
}
if hasCal {
bodyCount++
partCount++
}
switch {
case bodyCount > 1:
if partCount > 1 {
boundary := newBoundary()
writeHeader(buf, "Content-Type", "multipart/alternative; boundary="+boundary)
buf.WriteByte('\n')
@@ -840,15 +844,15 @@ func writeAlternativeOrSingleBody(buf *bytes.Buffer, b Builder) {
writeBodyPart(buf, boundary, "text/html", b.htmlBody)
}
if hasCal {
writeBodyPart(buf, boundary, "text/calendar", b.calendarBody)
fmt.Fprintf(buf, "--%s\n", boundary)
writeCalendarPart(buf, b.calendarBody)
}
fmt.Fprintf(buf, "--%s--\n", boundary)
case hasHTML:
} else if hasHTML {
writeSingleBodyPartHeaders(buf, "text/html", b.htmlBody)
case hasCal:
writeSingleBodyPartHeaders(buf, "text/calendar", b.calendarBody)
default:
// text/plain (also handles empty body)
} else if hasCal {
writeCalendarPart(buf, b.calendarBody)
} else {
writeSingleBodyPartHeaders(buf, "text/plain", b.textBody)
}
}
@@ -992,6 +996,35 @@ func writeSingleBodyPartHeaders(buf *bytes.Buffer, ct string, body []byte) {
writeFoldedBody(buf, encodeBodyContent(body, cte), lineWidthForCTE(cte))
}
// writeCalendarPart writes the text/calendar MIME part. The method= parameter
// is derived from the METHOD property in the ICS body (defaulting to REQUEST
// when absent) so that passthrough ICS with METHOD:CANCEL or METHOD:REPLY
// produce a Content-Type that matches the body.
func writeCalendarPart(buf *bytes.Buffer, body []byte) {
method := extractICSMethod(body)
if method == "" {
method = "REQUEST"
}
cte := selectCTE(body)
fmt.Fprintf(buf, "Content-Type: text/calendar; method=%s; charset=UTF-8\n", method)
fmt.Fprintf(buf, "Content-Transfer-Encoding: %s\n\n", cte)
writeFoldedBody(buf, encodeBodyContent(body, cte), lineWidthForCTE(cte))
buf.WriteByte('\n')
}
// extractICSMethod scans the ICS body for the top-level METHOD property and
// returns its value (e.g. "REQUEST", "CANCEL", "REPLY"). Returns "" when the
// property is absent so callers can apply their own default.
func extractICSMethod(body []byte) string {
for _, line := range strings.Split(string(body), "\n") {
line = strings.TrimRight(line, "\r")
if strings.HasPrefix(strings.ToUpper(line), "METHOD:") {
return strings.TrimSpace(line[7:])
}
}
return ""
}
// writeAttachmentPart writes a MIME attachment part.
// Body is always base64 (StdEncoding), written in 76-character lines per RFC 2045.
func writeAttachmentPart(buf *bytes.Buffer, att attachment) {

View File

@@ -678,6 +678,8 @@ func TestBuild_CalendarWithText(t *testing.T) {
}
eml := string(raw)
// text/calendar lives inside multipart/alternative as an alternative
// representation of the body, matching Feishu client behavior.
if !strings.Contains(eml, "multipart/alternative") {
t.Errorf("expected multipart/alternative for text+calendar:\n%s", eml)
}
@@ -1359,3 +1361,35 @@ func TestHeaderValueTabAllowed(t *testing.T) {
t.Errorf("Header with tab in value: expected no error, got %v", err)
}
}
func TestWriteCalendarPart_MethodFromBody(t *testing.T) {
cases := []struct {
name string
ics string
wantCT string
}{
{"request", "BEGIN:VCALENDAR\r\nMETHOD:REQUEST\r\nEND:VCALENDAR\r\n", "method=REQUEST"},
{"cancel", "BEGIN:VCALENDAR\r\nMETHOD:CANCEL\r\nEND:VCALENDAR\r\n", "method=CANCEL"},
{"reply", "BEGIN:VCALENDAR\r\nMETHOD:REPLY\r\nEND:VCALENDAR\r\n", "method=REPLY"},
{"no method defaults to REQUEST", "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n", "method=REQUEST"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
eml, err := New().
From("", "sender@example.com").
To("", "recipient@example.com").
Subject("Test").
Date(fixedDate).
MessageID("test-method@x").
HTMLBody([]byte("<p>hi</p>")).
CalendarBody([]byte(tc.ics)).
Build()
if err != nil {
t.Fatalf("Build: %v", err)
}
if !strings.Contains(string(eml), tc.wantCT) {
t.Errorf("expected Content-Type to contain %q\n%s", tc.wantCT, eml)
}
})
}
}

View File

@@ -25,6 +25,7 @@ import (
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
"github.com/larksuite/cli/shortcuts/mail/ics"
)
// hintIdentityFirst prints a one-line tip to stderr for read-only mail shortcuts
@@ -184,6 +185,15 @@ func printMessageOutputSchema(runtime *common.RuntimeContext) {
"attachments[].attachment_type": "Attachment type. Values: 1 = normal, 2 = large attachment",
"attachments[].is_inline": "true = inline image, false = regular attachment",
"attachments[].cid": "Content-ID for inline images (maps to <img src='cid:...'>)",
"calendar_event": "Parsed calendar invitation; present when the email contains a text/calendar part",
"calendar_event.method": "iTIP method, e.g. REQUEST, CANCEL, REPLY",
"calendar_event.uid": "Globally unique event identifier (UID property)",
"calendar_event.summary": "Event title (SUMMARY property)",
"calendar_event.start": "Event start time in RFC 3339 / ISO 8601 format (UTC)",
"calendar_event.end": "Event end time in RFC 3339 / ISO 8601 format (UTC)",
"calendar_event.location": "Event location string; omitted when not set",
"calendar_event.organizer": "Organizer email address",
"calendar_event.attendees": "List of attendee email addresses",
},
"thread_extra_fields": map[string]string{
"thread_id": "Thread ID",
@@ -1199,11 +1209,23 @@ type normalizedMessageForCompose struct {
BodyPlainText string `json:"body_plain_text"`
BodyPreview string `json:"body_preview"`
BodyHTML string `json:"body_html,omitempty"`
CalendarEvent *calendarEventOutput `json:"calendar_event,omitempty"`
Attachments []mailAttachmentOutput `json:"attachments"`
Images []mailImageOutput `json:"images"`
Warnings []warningEntry `json:"warnings,omitempty"`
}
type calendarEventOutput struct {
Method string `json:"method,omitempty"`
UID string `json:"uid,omitempty"`
Summary string `json:"summary,omitempty"`
Start string `json:"start,omitempty"`
End string `json:"end,omitempty"`
Location string `json:"location,omitempty"`
Organizer string `json:"organizer,omitempty"`
Attendees []string `json:"attendees,omitempty"`
}
// fetchAttachmentURLs fetches download URLs for the given attachment IDs in batches of 20.
// List params are embedded directly in the URL (SDK workaround for repeated query params).
// It never returns an error: failed batches/IDs are converted to structured warnings so caller can continue.
@@ -1349,6 +1371,9 @@ func buildMessageOutput(msg map[string]interface{}, html bool) map[string]interf
if html && normalized.BodyHTML != "" {
out["body_html"] = normalized.BodyHTML
}
if normalized.CalendarEvent != nil {
out["calendar_event"] = normalized.CalendarEvent
}
out["attachments"] = buildPublicAttachments(msg)
return out
@@ -1458,6 +1483,29 @@ func buildMessageForCompose(msg map[string]interface{}, urlMap map[string]string
out.BodyHTML = decodeBase64URL(strVal(msg["body_html"]))
}
// Calendar event
if bodyCalendar := strVal(msg["body_calendar"]); bodyCalendar != "" {
if decoded := decodeBase64URL(bodyCalendar); decoded != "" {
if parsed := ics.ParseEvent(decoded); parsed != nil {
ce := &calendarEventOutput{
Method: parsed.Method,
UID: parsed.UID,
Summary: parsed.Summary,
Location: parsed.Location,
Organizer: parsed.Organizer,
Attendees: parsed.Attendees,
}
if !parsed.Start.IsZero() {
ce.Start = parsed.Start.UTC().Format(time.RFC3339)
}
if !parsed.End.IsZero() {
ce.End = parsed.End.UTC().Format(time.RFC3339)
}
out.CalendarEvent = ce
}
}
}
// Attachments
attachments := make([]mailAttachmentOutput, 0)
images := make([]mailImageOutput, 0)
@@ -1568,6 +1616,7 @@ type composeSourceMessage struct {
ForwardAttachments []forwardSourceAttachment
InlineImages []inlineSourcePart
FailedAttachmentIDs map[string]bool
OriginalCalendarICS []byte // raw ICS bytes from body_calendar (for forward passthrough)
}
// fetchComposeSourceMessage loads a message via the +message pipeline and converts it
@@ -1577,6 +1626,12 @@ func fetchComposeSourceMessage(runtime *common.RuntimeContext, mailboxID, messag
if err != nil {
return composeSourceMessage{}, err
}
var originalCalICS []byte
if bodyCalendar := strVal(msg["body_calendar"]); bodyCalendar != "" {
if decoded := decodeBase64URL(bodyCalendar); decoded != "" {
originalCalICS = []byte(decoded)
}
}
attIDs := extractAttachmentIDs(msg)
urlMap, warnings := fetchAttachmentURLs(runtime, mailboxID, messageID, attIDs)
failedIDs := make(map[string]bool)
@@ -1592,6 +1647,7 @@ func fetchComposeSourceMessage(runtime *common.RuntimeContext, mailboxID, messag
ForwardAttachments: toForwardSourceAttachments(out),
InlineImages: toInlineSourceParts(out),
FailedAttachmentIDs: failedIDs,
OriginalCalendarICS: originalCalICS,
}, nil
}
@@ -2252,6 +2308,21 @@ func inlineSpecFilePaths(specs []InlineSpec) []string {
return paths
}
// validateEventSendTimeExclusion checks that --send-time and --event-* are not
// used together. This is enforced here (in Validate, before Execute) because the
// Shortcut framework does not expose a cobra-level hook for MarkFlagsMutuallyExclusive.
func validateEventSendTimeExclusion(runtime *common.RuntimeContext) error {
if runtime.Str("send-time") == "" {
return nil
}
for _, f := range []string{"event-summary", "event-start", "event-end", "event-location"} {
if runtime.Str(f) != "" {
return common.FlagErrorf("--send-time and --event-* are mutually exclusive: a calendar invitation must be sent immediately so recipients can respond before the event")
}
}
return nil
}
// validateSendTime checks that --send-time, if provided, requires --confirm-send,
// is a valid Unix timestamp in seconds, and is at least 5 minutes in the future.
func validateSendTime(runtime *common.RuntimeContext) error {
@@ -2391,3 +2462,143 @@ func validateComposeInlineAndAttachments(fio fileio.FileIO, attachFlag, inlineFl
}
return nil
}
// buildCalendarBodyFromArgs builds ICS from explicit string arguments (for draft-edit).
// Callers are expected to have pre-validated startStr/endStr via parseEventTimeRange;
// parse errors are silently ignored here and produce a zero-time DTSTART/DTEND.
func buildCalendarBodyFromArgs(summary, startStr, endStr, location, senderEmail, toAddrs, ccAddrs string) []byte {
if summary == "" {
return nil
}
start, _ := parseISO8601(startStr)
end, _ := parseISO8601(endStr)
var attendees []ics.Address
for _, addr := range parseNetAddrs(toAddrs) {
if addr.Address != "" {
attendees = append(attendees, ics.Address{Name: addr.Name, Email: addr.Address})
}
}
for _, addr := range parseNetAddrs(ccAddrs) {
if addr.Address != "" {
attendees = append(attendees, ics.Address{Name: addr.Name, Email: addr.Address})
}
}
return ics.Build(ics.Event{
Summary: summary,
Location: location,
Start: start,
End: end,
Organizer: ics.Address{Email: senderEmail},
Attendees: attendees,
})
}
// joinAddresses joins draft Address list into comma-separated string.
func joinAddresses(addrs []draftpkg.Address) string {
if len(addrs) == 0 {
return ""
}
parts := make([]string, len(addrs))
for i, a := range addrs {
parts[i] = a.Address
}
return strings.Join(parts, ",")
}
// Calendar event flag definitions, shared by all compose shortcuts.
// Declared as individual vars (like priorityFlag and signatureFlag) so
// callers can list them explicitly in their Flags slice without relying
// on slice-index access.
var (
eventSummaryFlag = common.Flag{Name: "event-summary", Desc: "Calendar event title. Setting this enables calendar invitation mode."}
eventStartFlag = common.Flag{Name: "event-start", Desc: "Event start time (ISO 8601, e.g. 2026-04-20T14:00+08:00). Required when --event-summary is set."}
eventEndFlag = common.Flag{Name: "event-end", Desc: "Event end time (ISO 8601). Required when --event-summary is set."}
eventLocationFlag = common.Flag{Name: "event-location", Desc: "Event location (optional)."}
)
// validateEventFlags checks that --event-summary, --event-start, --event-end are either all set or all empty.
func validateEventFlags(runtime *common.RuntimeContext) error {
summary := runtime.Str("event-summary")
start := runtime.Str("event-start")
end := runtime.Str("event-end")
location := runtime.Str("event-location")
hasAny := summary != "" || start != "" || end != "" || location != ""
hasAll := summary != "" && start != "" && end != ""
if hasAny && !hasAll {
return fmt.Errorf("--event-summary, --event-start, and --event-end must all be provided together")
}
if summary == "" {
return nil
}
if _, _, err := parseEventTimeRange(start, end); err != nil {
return prefixEventRangeError("--event-", err)
}
return nil
}
// parseEventTimeRange parses start/end ISO 8601 strings and verifies that
// end is strictly after start. Shared by validateEventFlags (compose path)
// and buildDraftEditPatch (draft-edit path) so the rules stay in one place.
func parseEventTimeRange(start, end string) (time.Time, time.Time, error) {
startT, err := parseISO8601(start)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("start: invalid ISO 8601 time %q", start)
}
endT, err := parseISO8601(end)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("end: invalid ISO 8601 time %q", end)
}
if !endT.After(startT) {
return time.Time{}, time.Time{}, fmt.Errorf("end time must be after start time")
}
return startT, endT, nil
}
// prefixEventRangeError rewrites parseEventTimeRange's "start:" / "end:"
// error with the caller's flag-name prefix so users see the exact flag
// that caused the failure.
func prefixEventRangeError(flagPrefix string, err error) error {
msg := err.Error()
switch {
case strings.HasPrefix(msg, "start: "):
return fmt.Errorf("%sstart: %s", flagPrefix, strings.TrimPrefix(msg, "start: "))
case strings.HasPrefix(msg, "end: "):
return fmt.Errorf("%send: %s", flagPrefix, strings.TrimPrefix(msg, "end: "))
default:
return err
}
}
// parseISO8601 parses common ISO 8601 time formats.
func parseISO8601(s string) (time.Time, error) {
formats := []string{
time.RFC3339,
"2006-01-02T15:04:05Z07:00",
"2006-01-02T15:04Z07:00",
"2006-01-02T15:04:05",
"2006-01-02T15:04",
"2006-01-02",
}
for _, f := range formats {
if t, err := time.Parse(f, s); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("cannot parse %q as ISO 8601", s)
}
// buildCalendarBody generates an ICS VCALENDAR from compose flags and returns the bytes.
// Returns nil if --event-summary is not set.
func buildCalendarBody(runtime *common.RuntimeContext, senderEmail string, toAddrs, ccAddrs string) []byte {
return buildCalendarBodyFromArgs(
runtime.Str("event-summary"),
runtime.Str("event-start"),
runtime.Str("event-end"),
runtime.Str("event-location"),
senderEmail, toAddrs, ccAddrs,
)
}

View File

@@ -1085,7 +1085,39 @@ func TestValidateSendTime_Valid(t *testing.T) {
}
}
// TestParsePriority verifies parse priority.
func TestValidateEventSendTimeExclusion(t *testing.T) {
future := strconv.FormatInt(time.Now().Unix()+10*60, 10)
cases := []struct {
name string
eventFlag string
eventVal string
}{
{"event-summary triggers exclusion", "event-summary", "Team meeting"},
{"event-start triggers exclusion", "event-start", "2026-05-01T10:00+08:00"},
{"event-end triggers exclusion", "event-end", "2026-05-01T11:00+08:00"},
{"event-location triggers exclusion", "event-location", "Room 5F"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("send-time", "", "")
for _, f := range []string{"event-summary", "event-start", "event-end", "event-location"} {
cmd.Flags().String(f, "", "")
}
_ = cmd.Flags().Set("send-time", future)
_ = cmd.Flags().Set(tc.eventFlag, tc.eventVal)
rt := &common.RuntimeContext{Cmd: cmd}
err := validateEventSendTimeExclusion(rt)
if err == nil {
t.Fatalf("expected error when --send-time and --%s are both set", tc.eventFlag)
}
if !strings.Contains(err.Error(), "--event-*") {
t.Errorf("expected error to mention --event-*, got: %v", err)
}
})
}
}
func TestParsePriority(t *testing.T) {
cases := []struct {
name string
@@ -1334,7 +1366,6 @@ func newRequestReceiptRuntime(t *testing.T, requestReceipt bool) *common.Runtime
return &common.RuntimeContext{Cmd: cmd}
}
// TestRequireSenderForRequestReceipt verifies require sender for request receipt.
func TestRequireSenderForRequestReceipt(t *testing.T) {
cases := []struct {
name string
@@ -1365,7 +1396,6 @@ func TestRequireSenderForRequestReceipt(t *testing.T) {
}
}
// TestShellQuoteForHint verifies shell quote for hint.
func TestShellQuoteForHint(t *testing.T) {
cases := []struct {
name string
@@ -1391,7 +1421,6 @@ func TestShellQuoteForHint(t *testing.T) {
}
}
// TestSanitizeForSingleLine verifies sanitize for single line.
func TestSanitizeForSingleLine(t *testing.T) {
cases := []struct {
name string
@@ -1415,7 +1444,6 @@ func TestSanitizeForSingleLine(t *testing.T) {
}
}
// TestValidateHeaderAddress verifies validate header address.
func TestValidateHeaderAddress(t *testing.T) {
cases := []struct {
name string
@@ -1447,3 +1475,199 @@ func TestValidateHeaderAddress(t *testing.T) {
})
}
}
// ---------------------------------------------------------------------------
// parseEventTimeRange
// ---------------------------------------------------------------------------
func TestParseEventTimeRange_OK(t *testing.T) {
s, e, err := parseEventTimeRange("2026-04-25T14:00+08:00", "2026-04-25T15:00+08:00")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !e.After(s) {
t.Errorf("end should be after start; got start=%v end=%v", s, e)
}
}
func TestParseEventTimeRange_EndBeforeStart(t *testing.T) {
_, _, err := parseEventTimeRange("2026-04-25T15:00+08:00", "2026-04-25T14:00+08:00")
if err == nil {
t.Fatal("expected error when end < start")
}
if !strings.Contains(err.Error(), "end time must be after start time") {
t.Errorf("unexpected error message: %v", err)
}
}
func TestParseEventTimeRange_EndEqualsStart(t *testing.T) {
_, _, err := parseEventTimeRange("2026-04-25T14:00+08:00", "2026-04-25T14:00+08:00")
if err == nil {
t.Fatal("expected error when end == start (zero duration)")
}
}
func TestParseEventTimeRange_InvalidStart(t *testing.T) {
_, _, err := parseEventTimeRange("not-a-time", "2026-04-25T15:00+08:00")
if err == nil || !strings.Contains(err.Error(), "start: invalid ISO 8601") {
t.Errorf("expected start parse error, got: %v", err)
}
}
func TestParseEventTimeRange_InvalidEnd(t *testing.T) {
_, _, err := parseEventTimeRange("2026-04-25T14:00+08:00", "not-a-time")
if err == nil || !strings.Contains(err.Error(), "end: invalid ISO 8601") {
t.Errorf("expected end parse error, got: %v", err)
}
}
func TestPrefixEventRangeError(t *testing.T) {
start := fmt.Errorf("start: invalid ISO 8601 time %q", "x")
if got := prefixEventRangeError("--event-", start).Error(); got != `--event-start: invalid ISO 8601 time "x"` {
t.Errorf("got %q", got)
}
end := fmt.Errorf("end: invalid ISO 8601 time %q", "x")
if got := prefixEventRangeError("--set-event-", end).Error(); got != `--set-event-end: invalid ISO 8601 time "x"` {
t.Errorf("got %q", got)
}
// Non-prefixed error passes through unchanged.
other := fmt.Errorf("end time must be after start time")
if got := prefixEventRangeError("--event-", other).Error(); got != "end time must be after start time" {
t.Errorf("got %q", got)
}
}
// ---------------------------------------------------------------------------
// validateEventFlags (runtime-backed)
// ---------------------------------------------------------------------------
func newEventFlagsRuntime(t *testing.T, summary, start, end string) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("event-summary", "", "")
cmd.Flags().String("event-start", "", "")
cmd.Flags().String("event-end", "", "")
cmd.Flags().String("event-location", "", "")
if summary != "" {
_ = cmd.Flags().Set("event-summary", summary)
}
if start != "" {
_ = cmd.Flags().Set("event-start", start)
}
if end != "" {
_ = cmd.Flags().Set("event-end", end)
}
return &common.RuntimeContext{Cmd: cmd}
}
func TestValidateEventFlags_AllEmptyOK(t *testing.T) {
rt := newEventFlagsRuntime(t, "", "", "")
if err := validateEventFlags(rt); err != nil {
t.Errorf("expected no error, got %v", err)
}
}
func TestValidateEventFlags_AllSetOK(t *testing.T) {
rt := newEventFlagsRuntime(t, "Meeting", "2026-04-25T10:00+08:00", "2026-04-25T11:00+08:00")
if err := validateEventFlags(rt); err != nil {
t.Errorf("expected no error, got %v", err)
}
}
func TestValidateEventFlags_PartialRejected(t *testing.T) {
cases := []struct {
name string
summary string
start string
end string
}{
{"only_summary", "Meeting", "", ""},
{"only_start", "", "2026-04-25T10:00+08:00", ""},
{"only_end", "", "", "2026-04-25T11:00+08:00"},
{"missing_end", "Meeting", "2026-04-25T10:00+08:00", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rt := newEventFlagsRuntime(t, tc.summary, tc.start, tc.end)
err := validateEventFlags(rt)
if err == nil || !strings.Contains(err.Error(), "must all be provided together") {
t.Errorf("expected 'all together' error, got %v", err)
}
})
}
}
func TestValidateEventFlags_EndBeforeStartRejected(t *testing.T) {
rt := newEventFlagsRuntime(t, "Meeting", "2026-04-25T11:00+08:00", "2026-04-25T10:00+08:00")
err := validateEventFlags(rt)
if err == nil || !strings.Contains(err.Error(), "after start") {
t.Errorf("expected end-after-start error, got %v", err)
}
}
func TestValidateEventFlags_InvalidTimeFormatRejected(t *testing.T) {
rt := newEventFlagsRuntime(t, "Meeting", "not-a-time", "2026-04-25T11:00+08:00")
err := validateEventFlags(rt)
if err == nil || !strings.Contains(err.Error(), "--event-start") {
t.Errorf("expected --event-start error, got %v", err)
}
}
// ---------------------------------------------------------------------------
// buildCalendarBodyFromArgs
// ---------------------------------------------------------------------------
func TestBuildCalendarBodyFromArgs_EmptySummaryReturnsNil(t *testing.T) {
got := buildCalendarBodyFromArgs("", "2026-04-25T10:00+08:00", "2026-04-25T11:00+08:00", "", "sender@example.com", "to@example.com", "")
if got != nil {
t.Errorf("expected nil for empty summary, got %d bytes", len(got))
}
}
func TestBuildCalendarBodyFromArgs_IncludesSummaryAndAddresses(t *testing.T) {
got := buildCalendarBodyFromArgs(
"Product Review",
"2026-04-25T14:00+08:00",
"2026-04-25T15:00+08:00",
"5F Room",
"sender@example.com",
"a@example.com,b@example.com",
"c@example.com",
)
if got == nil {
t.Fatal("expected non-nil ICS bytes")
}
s := string(got)
checks := []string{
"BEGIN:VCALENDAR",
"SUMMARY:Product Review",
"LOCATION:5F Room",
"sender@example.com",
"a@example.com",
"b@example.com",
"c@example.com",
}
for _, want := range checks {
if !strings.Contains(s, want) {
t.Errorf("missing %q in generated ICS:\n%s", want, s)
}
}
}
func TestBuildCalendarBodyFromArgs_NoCcWorks(t *testing.T) {
got := buildCalendarBodyFromArgs(
"Meeting",
"2026-04-25T10:00+08:00",
"2026-04-25T11:00+08:00",
"",
"sender@example.com",
"to@example.com",
"",
)
if got == nil {
t.Fatal("expected non-nil ICS bytes")
}
if !strings.Contains(string(got), "to@example.com") {
t.Error("attendee missing")
}
}

View File

@@ -0,0 +1,180 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package ics provides RFC 5545 iCalendar generation and parsing for mail calendar invitations.
package ics
import (
"fmt"
"strings"
"time"
"unicode/utf8"
"github.com/google/uuid"
)
// Event holds the data needed to generate an ICS VCALENDAR invitation.
type Event struct {
UID string // auto-generated if empty
Summary string // SUMMARY (required)
Location string // LOCATION (optional)
Start time.Time // DTSTART (required)
End time.Time // DTEND (required)
Organizer Address // ORGANIZER
Attendees []Address // ATTENDEE list (To + Cc, excluding Bcc)
}
// Address represents a name + email pair for ORGANIZER / ATTENDEE.
type Address struct {
Name string
Email string
}
// Build generates a RFC 5545 VCALENDAR byte slice with METHOD:REQUEST.
// The output is suitable for use as a text/calendar MIME part.
func Build(event Event) []byte {
uid := event.UID
if uid == "" {
uid = uuid.New().String()
}
now := time.Now().UTC()
nowICS := formatICSTime(now)
var b strings.Builder
b.WriteString("BEGIN:VCALENDAR\r\n")
b.WriteString("CALSCALE:GREGORIAN\r\n")
b.WriteString("VERSION:2.0\r\n")
b.WriteString("PRODID:-//Lark CLI//EN\r\n")
b.WriteString("METHOD:REQUEST\r\n")
b.WriteString("X-LARK-MAIL-DRAFT:TRUE\r\n")
b.WriteString("BEGIN:VEVENT\r\n")
writeFolded(&b, "UID", uid)
writeFolded(&b, "DTSTAMP", nowICS)
writeFolded(&b, "CREATED", nowICS)
writeFolded(&b, "LAST-MODIFIED", nowICS)
writeFolded(&b, "DTSTART", formatICSTime(event.Start.UTC()))
writeFolded(&b, "DTEND", formatICSTime(event.End.UTC()))
writeFolded(&b, "SUMMARY", escapeTextValue(event.Summary))
if event.Location != "" {
writeFolded(&b, "LOCATION", escapeTextValue(event.Location))
}
b.WriteString("STATUS:CONFIRMED\r\n")
b.WriteString("TRANSP:OPAQUE\r\n")
b.WriteString("SEQUENCE:0\r\n")
if event.Organizer.Email != "" {
organizer := "ORGANIZER;ROLE=CHAIR"
if event.Organizer.Name != "" {
organizer += ";CN=" + quoteCNParam(event.Organizer.Name)
} else {
organizer += ";CN=" + quoteCNParam(event.Organizer.Email)
}
writeFolded(&b, organizer, mailtoScheme+sanitizeMailtoAddress(event.Organizer.Email))
}
for _, a := range event.Attendees {
attendee := "ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CUTYPE=INDIVIDUAL"
if a.Name != "" {
attendee += ";CN=" + quoteCNParam(a.Name)
} else {
attendee += ";CN=" + quoteCNParam(a.Email)
}
attendee += ";PARTSTAT=NEEDS-ACTION"
writeFolded(&b, attendee, mailtoScheme+sanitizeMailtoAddress(a.Email))
}
b.WriteString("END:VEVENT\r\n")
b.WriteString("END:VCALENDAR\r\n")
return []byte(b.String())
}
// formatICSTime formats a time.Time as ICS UTC: YYYYMMDDTHHMMSSZ.
func formatICSTime(t time.Time) string {
return t.Format("20060102T150405Z")
}
// escapeTextValue escapes a string for use as an ICS TEXT value per RFC 5545
// §3.3.11: backslash, newline, semicolon, and comma carry structural meaning
// and must be escaped. Applied to SUMMARY, LOCATION, DESCRIPTION etc. — not
// to identifiers (UID), date-times (DTSTART/DTEND), or URIs.
//
// Without this, a user-supplied summary containing a newline or colon would
// let the payload inject a fake property line, e.g.
//
// --event-summary "foo\nDTSTART:20000101T000000Z"
//
// would turn into a second DTSTART line after folding.
func escapeTextValue(s string) string {
// Normalise CR / CRLF so downstream only sees LF.
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
// Order matters: escape backslash first so its own replacement is not
// picked up by later rules.
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, "\n", `\n`)
s = strings.ReplaceAll(s, ";", `\;`)
s = strings.ReplaceAll(s, ",", `\,`)
return s
}
// quoteCNParam wraps a CN parameter value in double-quotes per RFC 5545 §3.2
// when the value contains characters that are not allowed in an unquoted
// paramtext (, ; :). Characters that are illegal inside a quoted-string are
// stripped: DQUOTE (%x22) is excluded by QSAFE-CHAR, and control characters
// (%x00%x08, %x0A%x1F, %x7F) would break the property line structure.
func quoteCNParam(s string) string {
s = strings.Map(func(r rune) rune {
if r == '"' || r < 0x09 || (r >= 0x0A && r <= 0x1F) || r == 0x7F {
return -1
}
return r
}, s)
if strings.ContainsAny(s, ",:;") {
return `"` + s + `"`
}
return s
}
// writeFolded writes a property line with RFC 5545 line folding (75-octet limit).
// Long lines are folded by inserting CRLF + space at UTF-8 character boundaries.
// Continuation lines begin with a single SPACE (1 octet), so their content is
// limited to 74 octets to keep the total physical line at ≤ 75 octets.
func writeFolded(b *strings.Builder, name, value string) {
line := fmt.Sprintf("%s:%s", name, value)
const maxLineOctets = 75 // RFC 5545 §3.1: lines SHOULD NOT be longer than 75 octets
limit := maxLineOctets
for len(line) > limit {
// Find the last complete UTF-8 character that fits within the limit.
cut := 0
for i := 0; i < len(line); {
_, size := utf8.DecodeRuneInString(line[i:])
if i+size > limit {
break
}
i += size
cut = i
}
if cut == 0 {
// Single character exceeds limit (shouldn't happen in practice).
cut = limit
}
b.WriteString(line[:cut])
b.WriteString("\r\n ")
line = line[cut:]
limit = maxLineOctets - 1 // continuation lines: 1-octet SPACE + 74 content = 75
}
b.WriteString(line)
b.WriteString("\r\n")
}
// sanitizeMailtoAddress strips control characters (CR, LF, and other chars
// below 0x20 or equal to 0x7F) from an email address before embedding it in a
// MAILTO: URI value. Prevents property-injection attacks analogous to the CN
// parameter protection in quoteCNParam.
func sanitizeMailtoAddress(s string) string {
return strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7F {
return -1
}
return r
}, s)
}

View File

@@ -0,0 +1,719 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package ics
import (
"strings"
"testing"
"time"
)
func TestBuild_Basic(t *testing.T) {
event := Event{
UID: "test-uid-123",
Summary: "Product Review",
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
Organizer: Address{Name: "Sender", Email: "sender@example.com"},
Attendees: []Address{
{Name: "Alice", Email: "alice@example.com"},
{Name: "Bob", Email: "bob@example.com"},
},
}
// Unfold before assertion so long property lines (which exceed 75 octets and
// are folded per RFC 5545) can be matched as a single contiguous string.
ics := unfoldLines(string(Build(event)))
checks := []string{
"BEGIN:VCALENDAR",
"CALSCALE:GREGORIAN",
"VERSION:2.0",
"METHOD:REQUEST",
"X-LARK-MAIL-DRAFT:TRUE",
"BEGIN:VEVENT",
"UID:test-uid-123",
"DTSTAMP:",
"CREATED:",
"LAST-MODIFIED:",
"DTSTART:20260420T060000Z",
"DTEND:20260420T070000Z",
"SUMMARY:Product Review",
"STATUS:CONFIRMED",
"TRANSP:OPAQUE",
"SEQUENCE:0",
"ORGANIZER;ROLE=CHAIR;CN=Sender:MAILTO:sender@example.com",
"ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CUTYPE=INDIVIDUAL;CN=Alice;PARTSTAT=NEEDS-ACTION:MAILTO:alice@example.com",
"ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CUTYPE=INDIVIDUAL;CN=Bob;PARTSTAT=NEEDS-ACTION:MAILTO:bob@example.com",
"END:VEVENT",
"END:VCALENDAR",
}
for _, want := range checks {
if !strings.Contains(ics, want) {
t.Errorf("missing %q in ICS:\n%s", want, ics)
}
}
}
func TestBuild_OrganizerFallsBackToEmailWhenNoName(t *testing.T) {
event := Event{
Summary: "Meeting",
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
Organizer: Address{Email: "o@e.com"},
Attendees: []Address{{Email: "a@e.com"}},
}
ics := unfoldLines(string(Build(event)))
if !strings.Contains(ics, "ORGANIZER;ROLE=CHAIR;CN=o@e.com:MAILTO:o@e.com") {
t.Errorf("ORGANIZER without name should fall back to email as CN:\n%s", ics)
}
if !strings.Contains(ics, "ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CUTYPE=INDIVIDUAL;CN=a@e.com;PARTSTAT=NEEDS-ACTION:MAILTO:a@e.com") {
t.Errorf("ATTENDEE without name should fall back to email as CN:\n%s", ics)
}
}
func TestBuild_WithLocation(t *testing.T) {
event := Event{
Summary: "Meeting",
Location: "5F Conference Room",
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
}
ics := string(Build(event))
if !strings.Contains(ics, "LOCATION:5F Conference Room") {
t.Errorf("missing LOCATION in ICS:\n%s", ics)
}
}
func TestBuild_NoLocation(t *testing.T) {
event := Event{
Summary: "Meeting",
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
}
ics := string(Build(event))
if strings.Contains(ics, "LOCATION") {
t.Errorf("should not have LOCATION when empty:\n%s", ics)
}
}
func TestBuild_AutoUIDIsPureUUID(t *testing.T) {
event := Event{
Summary: "Test",
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
}
ics := string(Build(event))
if !strings.Contains(ics, "UID:") {
t.Fatal("missing UID")
}
// Extract the UID line to assert on its format.
var uid string
for _, line := range strings.Split(ics, "\r\n") {
if strings.HasPrefix(line, "UID:") {
uid = strings.TrimPrefix(line, "UID:")
break
}
}
if strings.Contains(uid, "@") {
t.Errorf("auto-generated UID should be pure UUID (no @host suffix), got %q", uid)
}
// UUID v4 has 36 chars (8-4-4-4-12 plus 4 dashes).
if len(uid) != 36 {
t.Errorf("auto-generated UID should be 36-char UUID, got %d chars: %q", len(uid), uid)
}
}
func TestBuild_EscapesTextValues(t *testing.T) {
cases := []struct {
name string
input string
want string
}{
{"semicolon", "a;b", `a\;b`},
{"comma", "a,b", `a\,b`},
{"backslash", `a\b`, `a\\b`},
{"newline", "a\nb", `a\nb`},
{"crlf", "a\r\nb", `a\nb`},
{"mixed", `a;\,b` + "\n", `a\;\\\,b\n`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := escapeTextValue(tc.input); got != tc.want {
t.Errorf("escapeTextValue(%q) = %q, want %q", tc.input, got, tc.want)
}
})
}
}
// TestBuild_RejectsInjectionViaSummary proves that a malicious SUMMARY
// containing a newline plus a fake property line cannot inject a second
// DTSTART into the rendered ICS — the newline is escaped into a literal
// "\n" sequence inside the SUMMARY value.
func TestBuild_RejectsInjectionViaSummary(t *testing.T) {
event := Event{
Summary: "harmless\nDTSTART:19700101T000000Z",
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
}
ics := unfoldLines(string(Build(event)))
// Count occurrences of DTSTART at the start of a line (i.e., as an
// actual property), ignoring the literal "DTSTART:" substring that
// now appears inside the escaped SUMMARY value.
dtstartPropertyLines := 0
for _, line := range strings.Split(ics, "\r\n") {
if strings.HasPrefix(line, "DTSTART:") {
dtstartPropertyLines++
}
}
if dtstartPropertyLines != 1 {
t.Errorf("expected exactly one DTSTART: property line, got %d in:\n%s", dtstartPropertyLines, ics)
}
if !strings.Contains(ics, `SUMMARY:harmless\nDTSTART:19700101T000000Z`) {
t.Errorf("expected escaped SUMMARY to contain literal \\n, got:\n%s", ics)
}
}
func TestBuild_CNWithSpecialCharsIsQuoted(t *testing.T) {
event := Event{
Summary: "Meeting",
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
Organizer: Address{Name: "Smith, Alice", Email: "alice@example.com"},
Attendees: []Address{
{Name: "Doe; Bob", Email: "bob@example.com"},
{Name: "Plain Name", Email: "plain@example.com"},
},
}
ics := unfoldLines(string(Build(event)))
if !strings.Contains(ics, `CN="Smith, Alice"`) {
t.Errorf("expected quoted CN for organizer name with comma:\n%s", ics)
}
if !strings.Contains(ics, `CN="Doe; Bob"`) {
t.Errorf("expected quoted CN for attendee name with semicolon:\n%s", ics)
}
// Names without special chars must NOT be double-quoted.
if !strings.Contains(ics, "CN=Plain Name") || strings.Contains(ics, `CN="Plain Name"`) {
t.Errorf("plain name should be unquoted:\n%s", ics)
}
}
func TestBuild_EmailAddressSanitized(t *testing.T) {
// CR/LF inside an email address must not produce injected property lines.
event := Event{
Summary: "Meeting",
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
Organizer: Address{Name: "Alice", Email: "alice@example.com\r\nX-INJECTED:bad"},
Attendees: []Address{{Name: "Bob", Email: "bob@example.com\nY-INJECTED:bad"}},
}
output := string(Build(event))
if strings.Contains(output, "\r\nX-INJECTED") {
t.Error("organizer email CR/LF injection not sanitized")
}
if strings.Contains(output, "\r\nY-INJECTED") {
t.Error("attendee email CR/LF injection not sanitized")
}
}
func TestBuild_CNStripsControlChars(t *testing.T) {
// A display name containing CR, LF, or other control characters must not
// produce extra ICS property lines (injection via CN parameter).
event := Event{
Summary: "Meeting",
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
Organizer: Address{Name: "Alice\r\nDTSTART:99999999", Email: "alice@example.com"},
Attendees: []Address{
{Name: "Bob\nX-INJECTED:bad", Email: "bob@example.com"},
},
}
output := string(Build(event))
// Check that control chars don't produce injected property lines.
// A standalone ICS property line starts at the beginning of a CRLF-delimited line.
if strings.Contains(output, "\r\nDTSTART:99999999") {
t.Error("ICS output contains injected DTSTART property line via organizer CN")
}
if strings.Contains(output, "\r\nX-INJECTED") {
t.Error("ICS output contains injected X-INJECTED property line via attendee CN")
}
}
func TestBuild_LineFolding(t *testing.T) {
event := Event{
Summary: strings.Repeat("A", 100), // long summary triggers folding
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
}
ics := string(Build(event))
// Every physical line (first line and continuation lines alike) must be
// ≤ 75 octets excluding the CRLF terminator per RFC 5545 §3.1.
for _, line := range strings.Split(ics, "\r\n") {
if len(line) > 75 {
t.Errorf("line exceeds 75 octets: %q (len=%d)", line, len(line))
}
}
}
func TestParseEvent_Basic(t *testing.T) {
ics := "BEGIN:VCALENDAR\r\n" +
"VERSION:2.0\r\n" +
"METHOD:REQUEST\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:abc123@larksuite.com\r\n" +
"DTSTART:20260420T060000Z\r\n" +
"DTEND:20260420T070000Z\r\n" +
"SUMMARY:Product Review\r\n" +
"LOCATION:5F Room\r\n" +
"ORGANIZER;CN=Sender:mailto:sender@example.com\r\n" +
"ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CN=Alice:mailto:alice@example.com\r\n" +
"ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CN=Bob:mailto:bob@example.com\r\n" +
"END:VEVENT\r\n" +
"END:VCALENDAR\r\n"
event := ParseEvent(ics)
if event == nil {
t.Fatal("ParseEvent returned nil")
}
if event.Method != "REQUEST" {
t.Errorf("Method = %q, want REQUEST", event.Method)
}
if event.IsLarkDraft {
t.Error("IsLarkDraft = true, want false (no X-LARK-MAIL-DRAFT in input)")
}
if event.UID != "abc123@larksuite.com" {
t.Errorf("UID = %q, want abc123@larksuite.com", event.UID)
}
if event.Summary != "Product Review" {
t.Errorf("Summary = %q, want Product Review", event.Summary)
}
if event.Location != "5F Room" {
t.Errorf("Location = %q, want 5F Room", event.Location)
}
if event.Organizer != "sender@example.com" {
t.Errorf("Organizer = %q, want sender@example.com", event.Organizer)
}
if len(event.Attendees) != 2 {
t.Fatalf("Attendees count = %d, want 2", len(event.Attendees))
}
if event.Attendees[0] != "alice@example.com" {
t.Errorf("Attendees[0] = %q, want alice@example.com", event.Attendees[0])
}
if event.Attendees[1] != "bob@example.com" {
t.Errorf("Attendees[1] = %q, want bob@example.com", event.Attendees[1])
}
wantStart := time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC)
if !event.Start.Equal(wantStart) {
t.Errorf("Start = %v, want %v", event.Start, wantStart)
}
wantEnd := time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC)
if !event.End.Equal(wantEnd) {
t.Errorf("End = %v, want %v", event.End, wantEnd)
}
}
func TestParseEvent_IsLarkDraft(t *testing.T) {
icsWithMarker := "BEGIN:VCALENDAR\r\n" +
"METHOD:REQUEST\r\n" +
"X-LARK-MAIL-DRAFT:TRUE\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:draft-test\r\n" +
"DTSTART:20260420T060000Z\r\n" +
"DTEND:20260420T070000Z\r\n" +
"SUMMARY:Draft Event\r\n" +
"END:VEVENT\r\n" +
"END:VCALENDAR\r\n"
event := ParseEvent(icsWithMarker)
if event == nil {
t.Fatal("ParseEvent returned nil")
}
if !event.IsLarkDraft {
t.Error("IsLarkDraft = false, want true")
}
icsWithoutMarker := "BEGIN:VCALENDAR\r\n" +
"METHOD:REQUEST\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:external-test\r\n" +
"DTSTART:20260420T060000Z\r\n" +
"DTEND:20260420T070000Z\r\n" +
"SUMMARY:External Event\r\n" +
"END:VEVENT\r\n" +
"END:VCALENDAR\r\n"
event2 := ParseEvent(icsWithoutMarker)
if event2 == nil {
t.Fatal("ParseEvent returned nil")
}
if event2.IsLarkDraft {
t.Error("IsLarkDraft = true, want false")
}
}
func TestParseEvent_WithTZID(t *testing.T) {
ics := "BEGIN:VCALENDAR\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:tz-test\r\n" +
"DTSTART;TZID=Asia/Shanghai:20260420T140000\r\n" +
"DTEND;TZID=Asia/Shanghai:20260420T150000\r\n" +
"SUMMARY:TZ Test\r\n" +
"END:VEVENT\r\n" +
"END:VCALENDAR\r\n"
event := ParseEvent(ics)
if event == nil {
t.Fatal("ParseEvent returned nil")
}
// 14:00 Asia/Shanghai = 06:00 UTC
wantStart := time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC)
if !event.Start.Equal(wantStart) {
t.Errorf("Start = %v, want %v", event.Start, wantStart)
}
}
func TestParseEvent_FoldedLines(t *testing.T) {
ics := "BEGIN:VCALENDAR\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:fold-test\r\n" +
"DTSTART:20260420T060000Z\r\n" +
"DTEND:20260420T070000Z\r\n" +
"SUMMARY:This is a very long summary that should be unfolded correctly by th\r\n" +
" e parser when processing\r\n" +
"END:VEVENT\r\n" +
"END:VCALENDAR\r\n"
event := ParseEvent(ics)
if event == nil {
t.Fatal("ParseEvent returned nil")
}
want := "This is a very long summary that should be unfolded correctly by the parser when processing"
if event.Summary != want {
t.Errorf("Summary = %q, want %q", event.Summary, want)
}
}
func TestParseEvent_FoldedLines_LFOnly(t *testing.T) {
// Some mail servers strip \r before storage, producing LF-only ICS.
ics := "BEGIN:VCALENDAR\n" +
"BEGIN:VEVENT\n" +
"UID:lf-fold-test\n" +
"DTSTART:20260420T060000Z\n" +
"DTEND:20260420T070000Z\n" +
"SUMMARY:This is a very long summary that should be unfolded correctly by th\n" +
" e parser when LF-only folding is used\n" +
"END:VEVENT\n" +
"END:VCALENDAR\n"
event := ParseEvent(ics)
if event == nil {
t.Fatal("ParseEvent returned nil for LF-only ICS")
}
want := "This is a very long summary that should be unfolded correctly by the parser when LF-only folding is used"
if event.Summary != want {
t.Errorf("Summary = %q, want %q", event.Summary, want)
}
}
func TestParseEvent_NoVEvent(t *testing.T) {
ics := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n"
event := ParseEvent(ics)
if event != nil {
t.Error("expected nil for ICS without VEVENT")
}
}
// TestParseEvent_OrganizerWithoutMailto covers the case where the backend
// re-serializes our ICS and drops the "MAILTO:" scheme prefix. Observed in
// practice on drafts returned by user_mailboxes/me/drafts.get.
func TestParseEvent_OrganizerWithoutMailto(t *testing.T) {
ics := "BEGIN:VCALENDAR\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:no-mailto-test\r\n" +
"DTSTART:20260420T060000Z\r\n" +
"DTEND:20260420T070000Z\r\n" +
"SUMMARY:Test\r\n" +
"ORGANIZER;CN=org@example.com:org@example.com\r\n" +
"ATTENDEE;PARTSTAT=NEEDS-ACTION;CN=att@example.com:att@example.com\r\n" +
"END:VEVENT\r\n" +
"END:VCALENDAR\r\n"
event := ParseEvent(ics)
if event == nil {
t.Fatal("ParseEvent returned nil")
}
if event.Organizer != "org@example.com" {
t.Errorf("Organizer = %q, want org@example.com (parser must accept bare email when mailto: is absent)", event.Organizer)
}
if len(event.Attendees) != 1 || event.Attendees[0] != "att@example.com" {
t.Errorf("Attendees = %v, want [att@example.com]", event.Attendees)
}
}
func TestParseEvent_MailtoCaseInsensitive(t *testing.T) {
ics := "BEGIN:VCALENDAR\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:case-test\r\n" +
"DTSTART:20260420T060000Z\r\n" +
"DTEND:20260420T070000Z\r\n" +
"SUMMARY:Test\r\n" +
"ORGANIZER;CN=Sender:MAILTO:sender@example.com\r\n" +
"END:VEVENT\r\n" +
"END:VCALENDAR\r\n"
event := ParseEvent(ics)
if event == nil {
t.Fatal("ParseEvent returned nil")
}
if event.Organizer != "sender@example.com" {
t.Errorf("Organizer = %q, want sender@example.com (uppercase MAILTO: should be accepted)", event.Organizer)
}
}
func TestParseEvent_RecurrenceIDPopulatesOriginalTime(t *testing.T) {
ics := "BEGIN:VCALENDAR\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:recurring-exception\r\n" +
"DTSTART:20260501T020000Z\r\n" +
"DTEND:20260501T030000Z\r\n" +
"RECURRENCE-ID:20260501T020000Z\r\n" +
"SUMMARY:Exception instance\r\n" +
"END:VEVENT\r\n" +
"END:VCALENDAR\r\n"
event := ParseEvent(ics)
if event == nil {
t.Fatal("ParseEvent returned nil")
}
// 2026-05-01 02:00:00 UTC = 1777600800
if event.OriginalTime != 1777600800 {
t.Errorf("OriginalTime = %d, want 1777600800", event.OriginalTime)
}
}
func TestParseEvent_NoRecurrenceIDYieldsZero(t *testing.T) {
ics := "BEGIN:VCALENDAR\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:single-event\r\n" +
"DTSTART:20260420T060000Z\r\n" +
"DTEND:20260420T070000Z\r\n" +
"SUMMARY:Single\r\n" +
"END:VEVENT\r\n" +
"END:VCALENDAR\r\n"
event := ParseEvent(ics)
if event == nil {
t.Fatal("ParseEvent returned nil")
}
if event.OriginalTime != 0 {
t.Errorf("OriginalTime = %d, want 0 for non-recurring event", event.OriginalTime)
}
}
func TestUnescapeTextValue(t *testing.T) {
cases := []struct {
name string
input string
want string
}{
{"plain", "hello", "hello"},
{"semicolon", `a\;b`, "a;b"},
{"comma", `a\,b`, "a,b"},
{"backslash", `a\\b`, `a\b`},
{"newline_lower", `a\nb`, "a\nb"},
{"newline_upper", `a\Nb`, "a\nb"},
{"mixed", `a\;\\\,b\n`, "a;\\,b\n"},
{"dangling_backslash_kept", `ends\`, `ends\`},
{"unknown_escape_kept", `\x`, `\x`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := unescapeTextValue(tc.input); got != tc.want {
t.Errorf("unescapeTextValue(%q) = %q, want %q", tc.input, got, tc.want)
}
})
}
}
func TestRoundTrip_SpecialCharsInSummaryAndLocation(t *testing.T) {
event := Event{
UID: "rt-special",
Summary: `Review;with,special\chars` + "\n" + `and newline`,
Location: `B1,Room 3;floor 2`,
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
}
parsed := ParseEvent(string(Build(event)))
if parsed == nil {
t.Fatal("ParseEvent returned nil")
}
if !parsed.IsLarkDraft {
t.Error("IsLarkDraft = false after roundtrip, want true (Build should write X-LARK-MAIL-DRAFT)")
}
if parsed.Summary != event.Summary {
t.Errorf("Summary roundtrip: got %q, want %q", parsed.Summary, event.Summary)
}
if parsed.Location != event.Location {
t.Errorf("Location roundtrip: got %q, want %q", parsed.Location, event.Location)
}
}
func TestBuild_WriteFolded_SingleCharExceeds75Bytes(t *testing.T) {
// A single multibyte rune that is > 75 bytes is not reachable in practice,
// but we exercise the cut==0 fallback by constructing a fake line via a
// 75-octet name followed by a multi-octet rune that crosses the boundary.
// The simplest way: a name of exactly 74 chars + ':' = 75, then a multi-byte
// rune — the first iteration has cut==0, triggering the fallback.
var b strings.Builder
longName := strings.Repeat("A", 74)
// value starts with a 3-byte UTF-8 rune (€ = 0xE2 0x82 0xAC)
writeFolded(&b, longName, "€remainder")
result := b.String()
if !strings.Contains(result, "\r\n ") {
t.Errorf("expected line folding CRLF+SP in output:\n%q", result)
}
}
func TestSplitProperty_NoColon(t *testing.T) {
name, value := splitProperty("NOCOLON")
if name != "NOCOLON" || value != "" {
t.Errorf("splitProperty(no colon): got name=%q value=%q, want NOCOLON/\"\"", name, value)
}
}
func TestSplitProperty_QuotedColon(t *testing.T) {
// A colon inside a quoted CN param must not be treated as the separator.
name, value := splitProperty(`ORGANIZER;CN="Doe: Jane":mailto:alice@example.com`)
if name != `ORGANIZER;CN="Doe: Jane"` {
t.Errorf("name = %q, want ORGANIZER;CN=\"Doe: Jane\"", name)
}
if value != "mailto:alice@example.com" {
t.Errorf("value = %q, want mailto:alice@example.com", value)
}
}
func TestParseICSTime_TZIDCaseInsensitive(t *testing.T) {
// TZID parameter name is case-insensitive per RFC 5545 §3.2.
result := parseICSTime("20260420T140000", "DTSTART;tzid=Asia/Shanghai")
want := time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC)
if !result.Equal(want) {
t.Errorf("parseICSTime with lowercase tzid= = %v, want %v", result, want)
}
}
func TestParseICSTime_TZIDWithTrailingParam(t *testing.T) {
// Trailing parameters after TZID (e.g. ;VALUE=DATE-TIME) must not be
// included in the timezone name passed to time.LoadLocation.
result := parseICSTime("20260420T140000", "DTSTART;TZID=Asia/Shanghai;VALUE=DATE-TIME")
want := time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC)
if !result.Equal(want) {
t.Errorf("parseICSTime with trailing ;VALUE= = %v, want %v", result, want)
}
}
func TestParseICSTime_DateOnly(t *testing.T) {
// All-day event: YYYYMMDD format
result := parseICSTime("20260420", "DTSTART;VALUE=DATE")
want := time.Date(2026, 4, 20, 0, 0, 0, 0, time.UTC)
if !result.Equal(want) {
t.Errorf("parseICSTime date-only = %v, want %v", result, want)
}
}
func TestParseICSTime_LocalWithoutTZ(t *testing.T) {
// Local time without timezone suffix (no Z, no TZID) — treated as UTC
result := parseICSTime("20260420T140000", "DTSTART")
want := time.Date(2026, 4, 20, 14, 0, 0, 0, time.UTC)
if !result.Equal(want) {
t.Errorf("parseICSTime local = %v, want %v", result, want)
}
}
func TestParseICSTime_InvalidReturnsZero(t *testing.T) {
result := parseICSTime("not-a-date", "DTSTART")
if !result.IsZero() {
t.Errorf("parseICSTime invalid = %v, want zero", result)
}
}
func TestExtractMailto_NoAt(t *testing.T) {
result := extractMailto("notanemail")
if result != "" {
t.Errorf("extractMailto(no @) = %q, want empty", result)
}
}
func TestRoundTrip(t *testing.T) {
original := Event{
UID: "roundtrip-test",
Summary: "Roundtrip Meeting",
Location: "Room 301",
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
Organizer: Address{Name: "Sender", Email: "sender@example.com"},
Attendees: []Address{
{Name: "Alice", Email: "alice@example.com"},
},
}
icsBytes := Build(original)
parsed := ParseEvent(string(icsBytes))
if parsed == nil {
t.Fatal("ParseEvent returned nil on Build output")
}
if parsed.UID != original.UID {
t.Errorf("UID roundtrip: %q != %q", parsed.UID, original.UID)
}
if parsed.Summary != original.Summary {
t.Errorf("Summary roundtrip: %q != %q", parsed.Summary, original.Summary)
}
if parsed.Location != original.Location {
t.Errorf("Location roundtrip: %q != %q", parsed.Location, original.Location)
}
if !parsed.Start.Equal(original.Start) {
t.Errorf("Start roundtrip: %v != %v", parsed.Start, original.Start)
}
if parsed.Organizer != original.Organizer.Email {
t.Errorf("Organizer roundtrip: %q != %q", parsed.Organizer, original.Organizer.Email)
}
if len(parsed.Attendees) != 1 || parsed.Attendees[0] != "alice@example.com" {
t.Errorf("Attendees roundtrip: %v", parsed.Attendees)
}
}
func TestParseEvent_LowercaseAndParameterizedProps(t *testing.T) {
ics := "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\n" +
"uid:lowercased-uid-value\r\n" +
"SUMMARY;LANGUAGE=en-US:Team Sync\r\n" +
"location;ALTREP=\"cid:part1\":Room 301\r\n" +
"DTSTART:20260501T100000Z\r\n" +
"DTEND:20260501T110000Z\r\n" +
"END:VEVENT\r\nEND:VCALENDAR\r\n"
ev := ParseEvent(ics)
if ev == nil {
t.Fatal("ParseEvent returned nil")
}
if ev.UID != "lowercased-uid-value" {
t.Errorf("UID: got %q", ev.UID)
}
if ev.Summary != "Team Sync" {
t.Errorf("Summary: got %q", ev.Summary)
}
if ev.Location != "Room 301" {
t.Errorf("Location: got %q", ev.Location)
}
}
func TestParseEvent_StartEndUTCInOutput(t *testing.T) {
// Verify that times with TZID are parsed with correct offset
// (UTC normalization in output is done by the helpers layer; parser
// returns time.Time which callers can call .UTC() on).
ics := "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\n" +
"DTSTART;TZID=Asia/Shanghai:20260501T180000\r\n" +
"DTEND;TZID=Asia/Shanghai:20260501T190000\r\n" +
"END:VEVENT\r\nEND:VCALENDAR\r\n"
ev := ParseEvent(ics)
if ev == nil {
t.Fatal("ParseEvent returned nil")
}
wantStart := "2026-05-01T10:00:00Z"
if got := ev.Start.UTC().Format(time.RFC3339); got != wantStart {
t.Errorf("Start UTC: got %q, want %q", got, wantStart)
}
}

View File

@@ -0,0 +1,222 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package ics
import (
"strings"
"time"
)
// mailtoScheme is the canonical case for the RFC 5545 ORGANIZER / ATTENDEE
// CAL-ADDRESS URI scheme. Emitted by the builder in upper-case to match
// Feishu client output; matched case-insensitively by the parser.
const mailtoScheme = "MAILTO:"
// ParsedEvent holds key fields extracted from an ICS VCALENDAR.
type ParsedEvent struct {
Method string // VCALENDAR-level METHOD (REQUEST/REPLY/CANCEL)
IsLarkDraft bool // true when VCALENDAR contains X-LARK-MAIL-DRAFT (Feishu private property indicating the event is editable)
UID string // VEVENT UID
Summary string // VEVENT SUMMARY, RFC 5545 TEXT unescaped
Location string // VEVENT LOCATION, RFC 5545 TEXT unescaped
Start time.Time // VEVENT DTSTART
End time.Time // VEVENT DTEND
Organizer string // ORGANIZER email (from MAILTO: URI or bare email)
Attendees []string // ATTENDEE emails (from MAILTO: URIs or bare emails)
OriginalTime int64 // RECURRENCE-ID as Unix seconds, 0 if not present. Used together with UID to derive the Feishu calendar event_id = UID + "_" + OriginalTime.
}
// ParseEvent extracts key fields from an ICS VCALENDAR string.
// Returns nil if no VEVENT is found.
func ParseEvent(icsText string) *ParsedEvent {
// Step 1: line unfolding (RFC 5545 §3.1)
unfolded := unfoldLines(icsText)
lines := strings.Split(unfolded, "\n")
var event ParsedEvent
inVEvent := false
foundVEvent := false
for _, line := range lines {
line = strings.TrimRight(line, "\r")
if line == "" {
continue
}
upper := strings.ToUpper(line)
// VCALENDAR-level properties
if !inVEvent && strings.HasPrefix(upper, "METHOD:") {
event.Method = strings.TrimSpace(line[len("METHOD:"):])
continue
}
if !inVEvent && strings.HasPrefix(upper, "X-LARK-MAIL-DRAFT:") {
event.IsLarkDraft = true
continue
}
if upper == "BEGIN:VEVENT" {
inVEvent = true
continue
}
if upper == "END:VEVENT" {
inVEvent = false
foundVEvent = true
continue
}
if !inVEvent {
continue
}
// VEVENT properties — RFC 5545 §3.1: property names are
// case-insensitive and may carry parameters (NAME;PARAM=v:value).
name, value := splitProperty(line)
propUpper := strings.ToUpper(name)
switch {
case propUpper == "UID" || strings.HasPrefix(propUpper, "UID;"):
event.UID = value
case propUpper == "SUMMARY" || strings.HasPrefix(propUpper, "SUMMARY;"):
event.Summary = unescapeTextValue(value)
case propUpper == "LOCATION" || strings.HasPrefix(propUpper, "LOCATION;"):
event.Location = unescapeTextValue(value)
case propUpper == "DTSTART" || strings.HasPrefix(propUpper, "DTSTART;"):
event.Start = parseICSTime(value, name)
case propUpper == "DTEND" || strings.HasPrefix(propUpper, "DTEND;"):
event.End = parseICSTime(value, name)
case propUpper == "RECURRENCE-ID" || strings.HasPrefix(propUpper, "RECURRENCE-ID;"):
if t := parseICSTime(value, name); !t.IsZero() {
event.OriginalTime = t.Unix()
}
case propUpper == "ORGANIZER" || strings.HasPrefix(propUpper, "ORGANIZER;"):
if email := extractMailto(value); email != "" {
event.Organizer = email
}
case propUpper == "ATTENDEE" || strings.HasPrefix(propUpper, "ATTENDEE;"):
if email := extractMailto(value); email != "" {
event.Attendees = append(event.Attendees, email)
}
}
}
if !foundVEvent {
return nil
}
return &event
}
// unfoldLines reverses RFC 5545 line folding: CRLF (or bare LF) followed by
// a single whitespace character is merged back into the preceding line.
// CRLF forms are handled first so that "\r\n " is consumed as a unit and does
// not leave a stray "\r" for the LF-only pass to mis-process.
func unfoldLines(s string) string {
s = strings.ReplaceAll(s, "\r\n ", "")
s = strings.ReplaceAll(s, "\r\n\t", "")
// LF-only folding — produced by some mail servers that strip \r.
s = strings.ReplaceAll(s, "\n ", "")
s = strings.ReplaceAll(s, "\n\t", "")
return s
}
// splitProperty splits "NAME;PARAMS:VALUE" into (name-with-params, value).
// It scans for the first colon that is not inside a double-quoted parameter
// value (e.g. CN="Doe: Jane"), per RFC 5545 §3.1.
func splitProperty(line string) (string, string) {
inQuote := false
for i := 0; i < len(line); i++ {
switch line[i] {
case '"':
inQuote = !inQuote
case ':':
if !inQuote {
return line[:i], line[i+1:]
}
}
}
return line, ""
}
// parseICSTime parses ICS datetime formats:
// - 20260420T060000Z (UTC)
// - TZID=Asia/Shanghai:20260420T140000 (with timezone in property params)
// - 20260420T140000 (local, treated as UTC)
func parseICSTime(value, propName string) time.Time {
value = strings.TrimSpace(value)
// Check for TZID in property params: DTSTART;TZID=Asia/Shanghai
// Case-insensitive search (RFC 5545 §3.2 param names are case-insensitive).
// Stop at the next ';' so trailing params like ;VALUE=DATE-TIME are excluded.
if idx := strings.Index(strings.ToUpper(propName), "TZID="); idx >= 0 {
tzPart := propName[idx+5:] // skip past "TZID="
if end := strings.IndexByte(tzPart, ';'); end >= 0 {
tzPart = tzPart[:end]
}
if loc, err := time.LoadLocation(tzPart); err == nil {
if t, err := time.ParseInLocation("20060102T150405", value, loc); err == nil {
return t
}
}
}
// UTC format: YYYYMMDDTHHMMSSZ
if t, err := time.Parse("20060102T150405Z", value); err == nil {
return t
}
// Date-only: YYYYMMDD (all-day events)
if t, err := time.Parse("20060102", value); err == nil {
return t
}
// Local time without timezone (treat as UTC)
if t, err := time.Parse("20060102T150405", value); err == nil {
return t
}
return time.Time{}
}
// unescapeTextValue reverses escapeTextValue per RFC 5545 §3.3.11, turning
// the ICS on-wire representation back into a plain Go string. Only applied
// to TEXT-typed properties (SUMMARY, LOCATION, DESCRIPTION, etc.) —
// identifiers, date-times, and URIs are parsed as-is.
func unescapeTextValue(s string) string {
if !strings.Contains(s, `\`) {
return s
}
var b strings.Builder
b.Grow(len(s))
for i := 0; i < len(s); i++ {
if s[i] == '\\' && i+1 < len(s) {
switch s[i+1] {
case 'n', 'N':
b.WriteByte('\n')
i++
continue
case '\\', ';', ',':
b.WriteByte(s[i+1])
i++
continue
}
}
b.WriteByte(s[i])
}
return b.String()
}
// extractMailto extracts the email address from an ICS ORGANIZER/ATTENDEE value.
// Accepts both "mailto:user@example.com" (RFC 5545 standard, case-insensitive per
// RFC 3986 §3.1) and a bare "user@example.com" value (observed in backend-regenerated
// ICS where the mailto: scheme prefix is dropped).
func extractMailto(value string) string {
value = strings.TrimSpace(value)
lower := strings.ToLower(value)
if idx := strings.Index(lower, strings.ToLower(mailtoScheme)); idx >= 0 {
return strings.TrimSpace(value[idx+len(mailtoScheme):])
}
if strings.Contains(value, "@") && !strings.ContainsAny(value, " \t") {
return value
}
return ""
}

View File

@@ -0,0 +1,182 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"encoding/base64"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
// calendarEventArgs are CLI flags that embed a calendar event in a compose command.
var calendarEventArgs = []string{
"--event-summary", "Team Sync",
"--event-start", "2026-05-10T10:00+08:00",
"--event-end", "2026-05-10T11:00+08:00",
}
// extractEMLFromDraftsStub decodes the base64url EML from the captured request body.
func extractEMLFromDraftsStub(t *testing.T, stub *httpmock.Stub) string {
t.Helper()
var reqBody map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &reqBody); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
raw, _ := reqBody["raw"].(string)
decoded, err := base64.URLEncoding.DecodeString(raw)
if err != nil {
t.Fatalf("base64url decode raw: %v", err)
}
return string(decoded)
}
// assertCalendarInEML checks that the decoded EML contains a text/calendar part.
func assertCalendarInEML(t *testing.T, eml string) {
t.Helper()
if !strings.Contains(eml, "text/calendar") {
t.Errorf("expected text/calendar part in EML:\n%s", eml)
}
if !strings.Contains(eml, "method=REQUEST") {
t.Errorf("expected method=REQUEST in Content-Type:\n%s", eml)
}
}
// stubSourceMessage registers the minimum stubs to fetch a simple source message
// (used by reply/forward/reply-all).
func stubSourceMessage(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
URL: "/user_mailboxes/me/messages/msg_001",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"message": map[string]interface{}{
"message_id": "msg_001",
"thread_id": "thread_001",
"smtp_message_id": "<msg_001@example.com>",
"subject": "Re: Original",
"head_from": map[string]interface{}{"mail_address": "sender@example.com", "name": "Sender"},
"to": []map[string]interface{}{{"mail_address": "me@example.com", "name": "Me"}},
"cc": []interface{}{},
"bcc": []interface{}{},
"body_html": base64.URLEncoding.EncodeToString([]byte("<p>Original</p>")),
"body_plain_text": base64.URLEncoding.EncodeToString([]byte("Original")),
"internal_date": "1704067200000",
"attachments": []interface{}{},
},
},
},
})
}
// ---------------------------------------------------------------------------
// +reply with calendar event
// ---------------------------------------------------------------------------
func TestReply_WithCalendarEvent(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubSourceMessage(reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/user_mailboxes/me/profile",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"primary_email_address": "me@example.com"},
},
})
draftsStub := &httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/drafts",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"draft_id": "draft_001"},
},
}
reg.Register(draftsStub)
args := append([]string{
"+reply",
"--message-id", "msg_001",
"--body", "<p>Let us meet</p>",
}, calendarEventArgs...)
if err := runMountedMailShortcut(t, MailReply, args, f, stdout); err != nil {
t.Fatalf("+reply with calendar failed: %v", err)
}
assertCalendarInEML(t, extractEMLFromDraftsStub(t, draftsStub))
}
// ---------------------------------------------------------------------------
// +reply-all with calendar event
// ---------------------------------------------------------------------------
func TestReplyAll_WithCalendarEvent(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubSourceMessage(reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/user_mailboxes/me/profile",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"primary_email_address": "me@example.com"},
},
})
draftsStub := &httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/drafts",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"draft_id": "draft_001"},
},
}
reg.Register(draftsStub)
args := append([]string{
"+reply-all",
"--message-id", "msg_001",
"--body", "<p>Let us meet</p>",
}, calendarEventArgs...)
if err := runMountedMailShortcut(t, MailReplyAll, args, f, stdout); err != nil {
t.Fatalf("+reply-all with calendar failed: %v", err)
}
assertCalendarInEML(t, extractEMLFromDraftsStub(t, draftsStub))
}
// ---------------------------------------------------------------------------
// +forward with calendar event
// ---------------------------------------------------------------------------
func TestForward_WithCalendarEvent(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubSourceMessage(reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/user_mailboxes/me/profile",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"primary_email_address": "me@example.com"},
},
})
draftsStub := &httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/drafts",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"draft_id": "draft_001"},
},
}
reg.Register(draftsStub)
args := append([]string{
"+forward",
"--message-id", "msg_001",
"--to", "carol@example.com",
"--body", "<p>FYI</p>",
}, calendarEventArgs...)
if err := runMountedMailShortcut(t, MailForward, args, f, stdout); err != nil {
t.Fatalf("+forward with calendar failed: %v", err)
}
assertCalendarInEML(t, extractEMLFromDraftsStub(t, draftsStub))
}

View File

@@ -56,6 +56,7 @@ var MailDraftCreate = common.Shortcut{
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's subject/body/to/cc/bcc/attachments are merged with user-supplied flags (user flags win). Requires --as user."},
signatureFlag,
priorityFlag,
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
mailboxID := resolveComposeMailboxID(runtime)
@@ -90,6 +91,9 @@ var MailDraftCreate = common.Shortcut{
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
return err
}
if err := validateEventFlags(runtime); err != nil {
return err
}
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body")); err != nil {
return err
}
@@ -300,6 +304,9 @@ func buildRawEMLForDraftCreate(
return "", err
}
bld = applyPriority(bld, priority)
if calData := buildCalendarBody(runtime, senderEmail, input.To, input.CC); calData != nil {
bld = bld.CalendarBody(calData)
}
allInlinePaths := append(inlineSpecFilePaths(inlineSpecs), autoResolvedPaths...)
composedBodySize := int64(len(composedHTMLBody) + len(composedTextBody))
emlBase := estimateEMLBaseSize(runtime.FileIO(), composedBodySize, allInlinePaths, 0) + templateSmallBytes

View File

@@ -5,6 +5,8 @@ package mail
import (
"context"
"encoding/base64"
"encoding/json"
"os"
"strings"
"testing"
@@ -14,6 +16,30 @@ import (
"github.com/spf13/cobra"
)
// newRuntimeWithEventFlags creates a RuntimeContext with --from and calendar event flags.
func newRuntimeWithEventFlags(from, summary, start, end, location string) *common.RuntimeContext {
cmd := &cobra.Command{Use: "test"}
for _, name := range []string{"from", "mailbox", "event-summary", "event-start", "event-end", "event-location"} {
cmd.Flags().String(name, "", "")
}
if from != "" {
_ = cmd.Flags().Set("from", from)
}
if summary != "" {
_ = cmd.Flags().Set("event-summary", summary)
}
if start != "" {
_ = cmd.Flags().Set("event-start", start)
}
if end != "" {
_ = cmd.Flags().Set("event-end", end)
}
if location != "" {
_ = cmd.Flags().Set("event-location", location)
}
return &common.RuntimeContext{Cmd: cmd}
}
// newRuntimeWithFrom creates a minimal RuntimeContext with --from flag set.
func newRuntimeWithFrom(from string) *common.RuntimeContext {
cmd := &cobra.Command{Use: "test"}
@@ -269,6 +295,31 @@ func TestBuildRawEMLForDraftCreate_PlainTextSkipsResolve(t *testing.T) {
}
}
func TestBuildRawEMLForDraftCreate_WithCalendarEvent(t *testing.T) {
rt := newRuntimeWithEventFlags("sender@example.com", "Team Sync", "2026-05-10T10:00+08:00", "2026-05-10T11:00+08:00", "Room 301")
input := draftCreateInput{
From: "sender@example.com",
To: "alice@example.com",
Subject: "Team Sync",
Body: "<p>Please join us</p>",
}
rawEML, err := buildRawEMLForDraftCreate(context.Background(), rt, input, nil, "", nil, "", "", nil, nil)
if err != nil {
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
}
eml := decodeBase64URL(rawEML)
if !strings.Contains(eml, "text/calendar") {
t.Errorf("expected text/calendar part in EML:\n%s", eml)
}
if !strings.Contains(eml, "method=REQUEST") {
t.Errorf("expected method=REQUEST in Content-Type:\n%s", eml)
}
if !strings.Contains(eml, "multipart/alternative") {
t.Errorf("expected calendar inside multipart/alternative:\n%s", eml)
}
}
// TestMailDraftCreatePrettyOutputsReference verifies mail draft create pretty outputs reference.
func TestMailDraftCreatePrettyOutputsReference(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
@@ -316,3 +367,56 @@ func TestMailDraftCreatePrettyOutputsReference(t *testing.T) {
t.Fatalf("expected reference in pretty output, got: %s", out)
}
}
func TestMailDraftCreate_WithCalendarEventFlags(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
draftsStub := &httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/drafts",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"draft_id": "draft_cal_001"},
},
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/user_mailboxes/me/profile",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"primary_email_address": "me@example.com"},
},
})
reg.Register(draftsStub)
err := runMountedMailShortcut(t, MailDraftCreate, []string{
"+draft-create",
"--to", "alice@example.com",
"--subject", "Team Sync",
"--body", "<p>Please join us</p>",
"--event-summary", "Team Sync",
"--event-start", "2026-05-10T10:00+08:00",
"--event-end", "2026-05-10T11:00+08:00",
"--event-location", "Room 301",
}, f, stdout)
if err != nil {
t.Fatalf("draft create with calendar failed: %v", err)
}
var reqBody map[string]interface{}
if err := json.Unmarshal(draftsStub.CapturedBody, &reqBody); err != nil {
t.Fatalf("unmarshal captured request body: %v", err)
}
raw, _ := reqBody["raw"].(string)
decoded, decErr := base64.URLEncoding.DecodeString(raw)
if decErr != nil {
t.Fatalf("base64url decode raw: %v", decErr)
}
eml := string(decoded)
if !strings.Contains(eml, "text/calendar") {
t.Errorf("expected text/calendar in EML:\n%s", eml)
}
if !strings.Contains(eml, "Team Sync") {
t.Errorf("expected event summary in ICS:\n%s", eml)
}
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
"github.com/larksuite/cli/shortcuts/mail/ics"
)
// MailDraftEdit is the `+draft-edit` shortcut: update an existing draft
@@ -37,6 +38,11 @@ var MailDraftEdit = common.Shortcut{
{Name: "patch-file", Desc: "Edit entry point for body edits, incremental recipient changes, header edits, attachment changes, or inline-image changes. All body edits MUST go through --patch-file. Two body ops: set_body (full replacement including quote) and set_reply_body (replaces only user-authored content, auto-preserves quote block). Run --inspect first to check has_quoted_content, then --print-patch-template for the JSON structure. Relative path only."},
{Name: "print-patch-template", Type: "bool", Desc: "Print the JSON template and supported operations for the --patch-file flag. Recommended first step before generating a patch file. No draft read or write is performed."},
{Name: "set-priority", Desc: "Set email priority: high, normal, low. Setting 'normal' removes any existing priority header."},
{Name: "set-event-summary", Desc: "Set calendar event title. Must be used together with --set-event-start and --set-event-end."},
{Name: "set-event-start", Desc: "Set calendar event start time (ISO 8601)."},
{Name: "set-event-end", Desc: "Set calendar event end time (ISO 8601)."},
{Name: "set-event-location", Desc: "Set calendar event location."},
{Name: "remove-event", Type: "bool", Desc: "Remove the calendar event from the draft."},
{Name: "inspect", Type: "bool", Desc: "Inspect the draft without modifying it. Returns the draft projection including subject, recipients, body summary, has_quoted_content (whether the draft contains a reply/forward quote block), attachments_summary (with part_id and cid for each attachment), and inline_summary. Run this BEFORE editing body to check has_quoted_content: if true, use set_reply_body in --patch-file to preserve the quote; if false, use set_body."},
{Name: "request-receipt", Type: "bool", Desc: "Request a read receipt (Message Disposition Notification, RFC 3798) addressed to the draft's sender. Recipient mail clients may prompt the user, send automatically, or silently ignore — delivery of a receipt is not guaranteed. Adds the Disposition-Notification-To header; existing value is overwritten."},
},
@@ -97,8 +103,9 @@ var MailDraftEdit = common.Shortcut{
if err != nil {
return output.ErrValidation("parse draft raw EML failed: %v", err)
}
// Pre-process insert_signature ops: resolve signature using the draft's
// From address so alias/shared-mailbox senders get correct template vars.
// Pre-process ops that need snapshot context: resolve signature using
// the draft's From address, and build ICS for set_calendar using the
// draft's From/To/Cc so organizer and attendee addresses are correct.
var draftFromEmail string
if len(snapshot.From) > 0 {
draftFromEmail = snapshot.From[0].Address
@@ -123,7 +130,8 @@ var MailDraftEdit = common.Shortcut{
})
}
for i := range patch.Ops {
if patch.Ops[i].Op == "insert_signature" {
switch patch.Ops[i].Op {
case "insert_signature":
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, patch.Ops[i].SignatureID, draftFromEmail)
if sigErr != nil {
return sigErr
@@ -132,6 +140,32 @@ var MailDraftEdit = common.Shortcut{
patch.Ops[i].RenderedSignatureHTML = sigResult.RenderedContent
patch.Ops[i].SignatureImages = sigResult.Images
}
case "set_calendar":
if calPart := draftpkg.FindPartByMediaType(snapshot.Body, "text/calendar"); calPart != nil {
parsed := ics.ParseEvent(string(calPart.Body))
if parsed == nil || !parsed.IsLarkDraft {
return output.ErrValidation("set_calendar: calendar event has already been created and is read-only; use --remove-event to remove it, then --set-event-* to create a new one")
}
}
if _, _, err := parseEventTimeRange(patch.Ops[i].EventStart, patch.Ops[i].EventEnd); err != nil {
return output.ErrValidation("set_calendar: %v", err)
}
// Derive effective To/Cc by replaying all pending recipient ops so
// the ICS ATTENDEE list matches the final post-edit recipients.
toAddrs, ccAddrs := effectiveRecipients(snapshot, patch.Ops)
calData := buildCalendarBodyFromArgs(
patch.Ops[i].EventSummary,
patch.Ops[i].EventStart,
patch.Ops[i].EventEnd,
patch.Ops[i].EventLocation,
draftFromEmail,
joinAddresses(toAddrs),
joinAddresses(ccAddrs),
)
if calData == nil {
return output.ErrValidation("set_calendar: failed to build ICS from event fields")
}
patch.Ops[i].CalendarICS = calData
}
}
// Pre-process add_attachment ops for large attachment support:
@@ -346,6 +380,39 @@ func buildDraftEditPatch(runtime *common.RuntimeContext) (draftpkg.Patch, error)
}
}
// --set-event-* / --remove-event → set_calendar / remove_calendar op.
// The ICS blob itself is pre-built at Execute time once the snapshot's
// organizer/attendee addresses are available; here we only record the
// user-supplied fields and validate the flag combination.
hasEventSet := runtime.Str("set-event-summary") != ""
hasEventRemove := runtime.Bool("remove-event")
if !hasEventSet && (runtime.Str("set-event-start") != "" || runtime.Str("set-event-end") != "" || runtime.Str("set-event-location") != "") {
return patch, output.ErrValidation("--set-event-start, --set-event-end, and --set-event-location require --set-event-summary")
}
if hasEventSet && hasEventRemove {
return patch, output.ErrValidation("--set-event-summary and --remove-event are mutually exclusive")
}
if hasEventSet {
summary := runtime.Str("set-event-summary")
start := runtime.Str("set-event-start")
end := runtime.Str("set-event-end")
if summary == "" || start == "" || end == "" {
return patch, output.ErrValidation("--set-event-summary, --set-event-start, and --set-event-end must all be provided together")
}
if _, _, err := parseEventTimeRange(start, end); err != nil {
return patch, output.ErrValidation("%s", prefixEventRangeError("--set-event-", err).Error())
}
patch.Ops = append(patch.Ops, draftpkg.PatchOp{
Op: "set_calendar",
EventSummary: summary,
EventStart: start,
EventEnd: end,
EventLocation: runtime.Str("set-event-location"),
})
} else if hasEventRemove {
patch.Ops = append(patch.Ops, draftpkg.PatchOp{Op: "remove_calendar"})
}
if len(patch.Ops) == 0 && !runtime.Bool("request-receipt") {
return patch, output.ErrValidation("at least one edit operation is required; use direct flags such as --set-subject/--set-to, or use --patch-file for body edits and other advanced operations (run --print-patch-template first)")
}
@@ -491,3 +558,45 @@ func buildDraftEditPatchTemplate() map[string]interface{} {
"patch_file_example": "lark-cli mail +draft-edit --draft-id d_xxx --patch-file ./patch.json",
}
}
// effectiveRecipients returns the To and Cc address slices that will result
// after all pending set_recipients / add_recipient / remove_recipient ops in
// ops have been applied. Used by the set_calendar pre-processor to build ICS
// with the correct post-edit ATTENDEE list before Apply() runs.
func effectiveRecipients(snapshot *draftpkg.DraftSnapshot, ops []draftpkg.PatchOp) (to, cc []draftpkg.Address) {
to = append([]draftpkg.Address{}, snapshot.To...)
cc = append([]draftpkg.Address{}, snapshot.Cc...)
apply := func(addrs []draftpkg.Address, op draftpkg.PatchOp) []draftpkg.Address {
switch op.Op {
case "set_recipients":
return append([]draftpkg.Address{}, op.Addresses...)
case "add_recipient":
for _, a := range addrs {
if strings.EqualFold(a.Address, op.Address) {
return addrs
}
}
return append(addrs, draftpkg.Address{Name: op.Name, Address: op.Address})
case "remove_recipient":
next := addrs[:0:0]
for _, a := range addrs {
if !strings.EqualFold(a.Address, op.Address) {
next = append(next, a)
}
}
return next
}
return addrs
}
for _, op := range ops {
switch op.Field {
case "to":
to = apply(to, op)
case "cc":
cc = apply(cc, op)
}
}
return to, cc
}

View File

@@ -18,9 +18,11 @@ func newDraftEditRuntime(flags map[string]string) *common.RuntimeContext {
for _, name := range []string{
"set-subject", "set-to", "set-cc", "set-bcc",
"set-priority", "patch-file",
"set-event-summary", "set-event-start", "set-event-end", "set-event-location",
} {
cmd.Flags().String(name, "", "")
}
cmd.Flags().Bool("remove-event", false, "")
for name, val := range flags {
_ = cmd.Flags().Set(name, val)
}
@@ -115,3 +117,115 @@ func TestPrettyDraftAddresses(t *testing.T) {
})
}
}
func TestBuildDraftEditPatch_SetEventEmitsSetCalendarOp(t *testing.T) {
rt := newDraftEditRuntime(map[string]string{
"set-event-summary": "Team Sync",
"set-event-start": "2026-05-10T10:00:00+08:00",
"set-event-end": "2026-05-10T11:00:00+08:00",
"set-event-location": "Room 301",
})
patch, err := buildDraftEditPatch(rt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(patch.Ops) != 1 {
t.Fatalf("expected 1 op, got %d: %+v", len(patch.Ops), patch.Ops)
}
op := patch.Ops[0]
if op.Op != "set_calendar" {
t.Errorf("Op = %q, want set_calendar", op.Op)
}
if op.EventSummary != "Team Sync" {
t.Errorf("EventSummary = %q, want Team Sync", op.EventSummary)
}
if op.EventLocation != "Room 301" {
t.Errorf("EventLocation = %q, want Room 301", op.EventLocation)
}
}
func TestBuildDraftEditPatch_RemoveEventEmitsRemoveCalendarOp(t *testing.T) {
rt := newDraftEditRuntime(map[string]string{
"remove-event": "true",
})
patch, err := buildDraftEditPatch(rt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(patch.Ops) != 1 || patch.Ops[0].Op != "remove_calendar" {
t.Fatalf("expected single remove_calendar op, got %+v", patch.Ops)
}
}
func TestBuildDraftEditPatch_SetAndRemoveEventMutuallyExclusive(t *testing.T) {
rt := newDraftEditRuntime(map[string]string{
"set-event-summary": "Meeting",
"remove-event": "true",
})
_, err := buildDraftEditPatch(rt)
if err == nil {
t.Fatal("expected error for --set-event-summary + --remove-event, got nil")
}
}
func TestBuildDraftEditPatch_SetEventMissingStartEnd(t *testing.T) {
rt := newDraftEditRuntime(map[string]string{
"set-event-summary": "Meeting",
})
_, err := buildDraftEditPatch(rt)
if err == nil {
t.Fatal("expected error when --set-event-summary set without start/end, got nil")
}
}
func TestEffectiveRecipients_SetReplaces(t *testing.T) {
snapshot := &draftpkg.DraftSnapshot{
To: []draftpkg.Address{{Address: "old@example.com"}},
Cc: []draftpkg.Address{{Address: "cc@example.com"}},
}
ops := []draftpkg.PatchOp{
{Op: "set_recipients", Field: "to", Addresses: []draftpkg.Address{{Address: "new@example.com"}}},
}
to, cc := effectiveRecipients(snapshot, ops)
if len(to) != 1 || to[0].Address != "new@example.com" {
t.Errorf("expected to=[new@example.com], got %v", to)
}
if len(cc) != 1 || cc[0].Address != "cc@example.com" {
t.Errorf("expected cc unchanged, got %v", cc)
}
}
func TestEffectiveRecipients_AddAndRemove(t *testing.T) {
snapshot := &draftpkg.DraftSnapshot{
To: []draftpkg.Address{{Address: "alice@example.com"}, {Address: "bob@example.com"}},
}
ops := []draftpkg.PatchOp{
{Op: "add_recipient", Field: "to", Address: "carol@example.com"},
{Op: "remove_recipient", Field: "to", Address: "bob@example.com"},
}
to, _ := effectiveRecipients(snapshot, ops)
if len(to) != 2 {
t.Fatalf("expected 2 recipients, got %v", to)
}
addrs := map[string]bool{}
for _, a := range to {
addrs[a.Address] = true
}
if !addrs["alice@example.com"] || !addrs["carol@example.com"] || addrs["bob@example.com"] {
t.Errorf("unexpected recipient set: %v", to)
}
}
func TestEffectiveRecipients_NoOpsReturnsCopy(t *testing.T) {
snapshot := &draftpkg.DraftSnapshot{
To: []draftpkg.Address{{Address: "alice@example.com"}},
Cc: []draftpkg.Address{{Address: "bob@example.com"}},
}
to, cc := effectiveRecipients(snapshot, nil)
if len(to) != 1 || to[0].Address != "alice@example.com" {
t.Errorf("unexpected to: %v", to)
}
if len(cc) != 1 || cc[0].Address != "bob@example.com" {
t.Errorf("unexpected cc: %v", cc)
}
}

View File

@@ -43,7 +43,8 @@ var MailForward = common.Shortcut{
{Name: "subject", Desc: "Optional. Override the auto-generated Fw: subject. When set, the shortcut uses this value verbatim instead of prefixing the original subject."},
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's body/to/cc/bcc/attachments are merged into the forward draft (template values appended to user flags / forward-derived values; no de-duplication)."},
signatureFlag,
priorityFlag},
priorityFlag,
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
messageId := runtime.Str("message-id")
to := runtime.Str("to")
@@ -74,6 +75,9 @@ var MailForward = common.Shortcut{
if err := validateConfirmSendScope(runtime); err != nil {
return err
}
if err := validateEventSendTimeExclusion(runtime); err != nil {
return err
}
if err := validateSendTime(runtime); err != nil {
return err
}
@@ -87,6 +91,9 @@ var MailForward = common.Shortcut{
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
return err
}
if err := validateEventFlags(runtime); err != nil {
return err
}
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), ""); err != nil {
return err
}
@@ -295,6 +302,11 @@ var MailForward = common.Shortcut{
return err
}
bld = applyPriority(bld, priority)
if calData := buildCalendarBody(runtime, senderEmail, to, ccFlag); calData != nil {
bld = bld.CalendarBody(calData)
} else if len(sourceMsg.OriginalCalendarICS) > 0 {
bld = bld.CalendarBody(sourceMsg.OriginalCalendarICS)
}
// Download original attachments, separating normal from large.
type downloadedAtt struct {
content []byte

View File

@@ -41,7 +41,8 @@ var MailReply = common.Shortcut{
{Name: "subject", Desc: "Optional. Override the auto-generated Re: subject. When set, the shortcut uses this value verbatim instead of prefixing the original subject."},
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's body/to/cc/bcc/attachments are appended to the reply-derived values (no de-duplication; see warning in Execute output)."},
signatureFlag,
priorityFlag},
priorityFlag,
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
messageId := runtime.Str("message-id")
confirmSend := runtime.Bool("confirm-send")
@@ -75,12 +76,18 @@ var MailReply = common.Shortcut{
if err := validateConfirmSendScope(runtime); err != nil {
return err
}
if err := validateEventSendTimeExclusion(runtime); err != nil {
return err
}
if err := validateSendTime(runtime); err != nil {
return err
}
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
return err
}
if err := validateEventFlags(runtime); err != nil {
return err
}
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), ""); err != nil {
return err
}
@@ -287,6 +294,9 @@ var MailReply = common.Shortcut{
return err
}
bld = applyPriority(bld, priority)
if calData := buildCalendarBody(runtime, senderEmail, replyTo, ccFlag); calData != nil {
bld = bld.CalendarBody(calData)
}
allInlinePaths := append(inlineSpecFilePaths(inlineSpecs), autoResolvedPaths...)
composedBodySize := int64(len(composedHTMLBody) + len(composedTextBody))
emlBase := estimateEMLBaseSize(runtime.FileIO(), composedBodySize, allInlinePaths, srcInlineBytes) + templateSmallBytes

View File

@@ -42,7 +42,8 @@ var MailReplyAll = common.Shortcut{
{Name: "subject", Desc: "Optional. Override the auto-generated Re: subject. When set, the shortcut uses this value verbatim instead of prefixing the original subject."},
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's body/to/cc/bcc/attachments are appended to the reply-derived values (no de-duplication; see warning in Execute output)."},
signatureFlag,
priorityFlag},
priorityFlag,
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
messageId := runtime.Str("message-id")
confirmSend := runtime.Bool("confirm-send")
@@ -76,12 +77,18 @@ var MailReplyAll = common.Shortcut{
if err := validateConfirmSendScope(runtime); err != nil {
return err
}
if err := validateEventSendTimeExclusion(runtime); err != nil {
return err
}
if err := validateSendTime(runtime); err != nil {
return err
}
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
return err
}
if err := validateEventFlags(runtime); err != nil {
return err
}
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), ""); err != nil {
return err
}
@@ -296,6 +303,9 @@ var MailReplyAll = common.Shortcut{
return err
}
bld = applyPriority(bld, priority)
if calData := buildCalendarBody(runtime, senderEmail, toList, ccList); calData != nil {
bld = bld.CalendarBody(calData)
}
allInlinePaths := append(inlineSpecFilePaths(inlineSpecs), autoResolvedPaths...)
composedBodySize := int64(len(composedHTMLBody) + len(composedTextBody))
emlBase := estimateEMLBaseSize(runtime.FileIO(), composedBodySize, allInlinePaths, srcInlineBytes) + templateSmallBytes

View File

@@ -39,7 +39,8 @@ var MailSend = common.Shortcut{
{Name: "request-receipt", Type: "bool", Desc: "Request a read receipt (Message Disposition Notification, RFC 3798) addressed to the sender. Recipient mail clients may prompt the user, send automatically, or silently ignore — delivery of a receipt is not guaranteed."},
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's subject/body/to/cc/bcc/attachments are merged with user-supplied flags (user flags win). Requires --as user."},
signatureFlag,
priorityFlag},
priorityFlag,
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
to := runtime.Str("to")
subject := runtime.Str("subject")
@@ -87,6 +88,9 @@ var MailSend = common.Shortcut{
return err
}
}
if err := validateEventSendTimeExclusion(runtime); err != nil {
return err
}
if err := validateSendTime(runtime); err != nil {
return err
}
@@ -96,6 +100,9 @@ var MailSend = common.Shortcut{
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body")); err != nil {
return err
}
if err := validateEventFlags(runtime); err != nil {
return err
}
return validatePriorityFlag(runtime)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -249,6 +256,9 @@ var MailSend = common.Shortcut{
return err
}
bld = applyPriority(bld, priority)
if calData := buildCalendarBody(runtime, senderEmail, to, ccFlag); calData != nil {
bld = bld.CalendarBody(calData)
}
allInlinePaths := append(inlineSpecFilePaths(inlineSpecs), autoResolvedPaths...)
composedBodySize := int64(len(composedHTMLBody) + len(composedTextBody))
emlBase := estimateEMLBaseSize(runtime.FileIO(), composedBodySize, allInlinePaths, 0) + templateSmallBytes

View File

@@ -4,6 +4,9 @@
package mail
import (
"encoding/base64"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
@@ -213,3 +216,55 @@ func TestMailSendSaveDraftOutputsReference(t *testing.T) {
t.Fatalf("reference = %v", data["reference"])
}
}
func TestMailSend_WithCalendarEventEmbedded(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactoryWithSendScope(t)
draftsStub := &httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/drafts",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"draft_id": "draft_cal_001"},
},
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/user_mailboxes/me/profile",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"primary_email_address": "me@example.com"},
},
})
reg.Register(draftsStub)
err := runMountedMailShortcut(t, MailSend, []string{
"+send",
"--to", "alice@example.com",
"--subject", "Team Sync",
"--body", "<p>Please join us</p>",
"--event-summary", "Team Sync",
"--event-start", "2026-05-10T10:00+08:00",
"--event-end", "2026-05-10T11:00+08:00",
}, f, stdout)
if err != nil {
t.Fatalf("mail send with calendar failed: %v", err)
}
var reqBody map[string]interface{}
if err := json.Unmarshal(draftsStub.CapturedBody, &reqBody); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
raw, _ := reqBody["raw"].(string)
decoded, decErr := base64.URLEncoding.DecodeString(raw)
if decErr != nil {
t.Fatalf("base64url decode: %v", decErr)
}
eml := string(decoded)
if !strings.Contains(eml, "text/calendar") {
t.Errorf("expected text/calendar in EML:\n%s", eml)
}
if !strings.Contains(eml, "method=REQUEST") {
t.Errorf("expected method=REQUEST in Content-Type:\n%s", eml)
}
}

View File

@@ -17,6 +17,21 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
func inferTaskMemberType(id string) string {
if strings.HasPrefix(strings.TrimSpace(id), "cli_") {
return "app"
}
return "user"
}
func buildTaskMember(id, role string) map[string]interface{} {
return map[string]interface{}{
"id": id,
"role": role,
"type": inferTaskMemberType(id),
}
}
// parseTaskTime converts a flexible time string into the Task API due/start object format.
func parseTaskTime(timeStr string) (map[string]interface{}, error) {
var msTs string
@@ -96,14 +111,15 @@ func buildTaskCreateBody(runtime *common.RuntimeContext) (map[string]interface{}
body["description"] = desc
}
var members []map[string]interface{}
if assignee := runtime.Str("assignee"); assignee != "" {
body["members"] = []map[string]interface{}{
{
"id": assignee,
"role": "assignee",
"type": "user",
},
}
members = append(members, buildTaskMember(assignee, "assignee"))
}
if follower := runtime.Str("follower"); follower != "" {
members = append(members, buildTaskMember(follower, "follower"))
}
if len(members) > 0 {
body["members"] = members
}
if tasklistId := runtime.Str("tasklist-id"); tasklistId != "" {
@@ -147,7 +163,8 @@ var CreateTask = common.Shortcut{
Flags: []common.Flag{
{Name: "summary", Desc: "task title"},
{Name: "description", Desc: "task description"},
{Name: "assignee", Desc: "assignee open_id"},
{Name: "assignee", Desc: "task assignee id added during create; use open_id (ou_xxx) when assignee is user, use app id (cli_xxx) when assignee is app"},
{Name: "follower", Desc: "task follower id added during create; use open_id (ou_xxx) when follower is user, use app id (cli_xxx) when follower is app"},
{Name: "due", Desc: "due date (ISO 8601 / date:YYYY-MM-DD / relative:+2d / ms timestamp)"},
{Name: "tasklist-id", Desc: "tasklist id or applink URL"},
{Name: "idempotency-key", Desc: "client token for idempotency"},

View File

@@ -28,8 +28,8 @@ var AssignTask = common.Shortcut{
Flags: []common.Flag{
{Name: "task-id", Desc: "task id", Required: true},
{Name: "add", Desc: "comma-separated open_ids to add as assignees"},
{Name: "remove", Desc: "comma-separated open_ids to remove from assignees"},
{Name: "add", Desc: "comma-separated assignee IDs to add; use open_id (ou_xxx) when assignee is user, use app id (cli_xxx) when assignee is app"},
{Name: "remove", Desc: "comma-separated assignee IDs to remove; use open_id (ou_xxx) when assignee is user, use app id (cli_xxx) when assignee is app"},
{Name: "idempotency-key", Desc: "client token for idempotency (used for add_members)"},
},
@@ -43,16 +43,15 @@ var AssignTask = common.Shortcut{
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
d := common.NewDryRunAPI()
taskId := url.PathEscape(runtime.Str("task-id"))
if addStr := runtime.Str("add"); addStr != "" {
body := buildMembersBody(addStr, runtime.Str("idempotency-key"))
body := buildMembersBody(addStr, "assignee", runtime.Str("idempotency-key"))
d.POST("/open-apis/task/v2/tasks/" + taskId + "/add_members").
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(body)
}
if removeStr := runtime.Str("remove"); removeStr != "" {
body := buildMembersBody(removeStr, "")
body := buildMembersBody(removeStr, "assignee", "")
d.POST("/open-apis/task/v2/tasks/" + taskId + "/remove_members").
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(body)
@@ -69,7 +68,7 @@ var AssignTask = common.Shortcut{
var lastData map[string]interface{}
if addStr := runtime.Str("add"); addStr != "" {
body := buildMembersBody(addStr, runtime.Str("idempotency-key"))
body := buildMembersBody(addStr, "assignee", runtime.Str("idempotency-key"))
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasks/" + taskId + "/add_members",
@@ -92,7 +91,7 @@ var AssignTask = common.Shortcut{
}
if removeStr := runtime.Str("remove"); removeStr != "" {
body := buildMembersBody(removeStr, "")
body := buildMembersBody(removeStr, "assignee", "")
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasks/" + taskId + "/remove_members",
@@ -125,21 +124,21 @@ var AssignTask = common.Shortcut{
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintf(w, "✅ Task assignees updated successfully!\n")
fmt.Fprintf(w, "✅ Task assignes updated successfully!\n")
fmt.Fprintf(w, "Task ID: %s\n", taskId)
if urlVal != "" {
fmt.Fprintf(w, "Task URL: %s\n", urlVal)
}
if members, ok := task["members"].([]interface{}); ok {
fmt.Fprintf(w, "Current Assignees: %d\n", len(members))
fmt.Fprintf(w, "Current Assignes: %d\n", len(members))
}
})
return nil
},
}
func buildMembersBody(idsStr string, clientToken string) map[string]interface{} {
func buildMembersBody(idsStr, role, clientToken string) map[string]interface{} {
ids := strings.Split(idsStr, ",")
var members []map[string]interface{}
@@ -148,11 +147,7 @@ func buildMembersBody(idsStr string, clientToken string) map[string]interface{}
if id == "" {
continue
}
members = append(members, map[string]interface{}{
"id": id,
"role": "assignee",
"type": "user",
})
members = append(members, buildTaskMember(id, role))
}
body := map[string]interface{}{

View File

@@ -6,14 +6,74 @@ package task
import (
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/shortcuts/common"
"github.com/smartystreets/goconvey/convey"
)
func TestBuildMembersBody(t *testing.T) {
convey.Convey("Build with ids and token", t, func() {
body := buildMembersBody("u1, u2 , ", "token1")
body := buildMembersBody("u1, u2 , ", "assignee", "token1")
members := body["members"].([]map[string]interface{})
convey.So(len(members), convey.ShouldEqual, 2)
convey.So(body["client_token"], convey.ShouldEqual, "token1")
convey.So(members[0]["role"], convey.ShouldEqual, "assignee")
convey.So(members[0]["type"], convey.ShouldEqual, "user")
})
convey.Convey("Build infers app assignee members from cli prefix", t, func() {
body := buildMembersBody("cli_bot_1", "assignee", "")
members := body["members"].([]map[string]interface{})
convey.So(len(members), convey.ShouldEqual, 1)
convey.So(members[0]["id"], convey.ShouldEqual, "cli_bot_1")
convey.So(members[0]["role"], convey.ShouldEqual, "assignee")
convey.So(members[0]["type"], convey.ShouldEqual, "app")
})
convey.Convey("Build infers mixed member types in one list", t, func() {
body := buildMembersBody("ou_user_1, cli_bot_1", "assignee", "")
members := body["members"].([]map[string]interface{})
convey.So(len(members), convey.ShouldEqual, 2)
convey.So(members[0]["type"], convey.ShouldEqual, "user")
convey.So(members[1]["type"], convey.ShouldEqual, "app")
})
}
func TestBuildTaskCreateBodySupportsAssigneeAndFollower(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("summary", "", "")
cmd.Flags().String("description", "", "")
cmd.Flags().String("assignee", "", "")
cmd.Flags().String("follower", "", "")
cmd.Flags().String("due", "", "")
cmd.Flags().String("tasklist-id", "", "")
cmd.Flags().String("idempotency-key", "", "")
cmd.Flags().String("data", "", "")
_ = cmd.Flags().Set("summary", "bot task")
_ = cmd.Flags().Set("assignee", "cli_bot_xxx")
_ = cmd.Flags().Set("follower", "ou_follower_xxx")
runtime := &common.RuntimeContext{Cmd: cmd}
body, err := buildTaskCreateBody(runtime)
if err != nil {
t.Fatalf("buildTaskCreateBody() error = %v", err)
}
members := body["members"].([]map[string]interface{})
if len(members) != 2 {
t.Fatalf("members len = %d, want 2", len(members))
}
if got := members[0]["type"]; got != "app" {
t.Fatalf("member[0] type = %v, want app", got)
}
if got := members[0]["role"]; got != "assignee" {
t.Fatalf("member[0] role = %v, want assignee", got)
}
if got := members[1]["type"]; got != "user" {
t.Fatalf("member[1] type = %v, want user", got)
}
if got := members[1]["role"]; got != "follower" {
t.Fatalf("member[1] role = %v, want follower", got)
}
}

View File

@@ -28,8 +28,8 @@ var FollowersTask = common.Shortcut{
Flags: []common.Flag{
{Name: "task-id", Desc: "task id", Required: true},
{Name: "add", Desc: "comma-separated open_ids to add as followers"},
{Name: "remove", Desc: "comma-separated open_ids to remove from followers"},
{Name: "add", Desc: "comma-separated follower IDs to add; use open_id (ou_xxx) when follower is user, use app id (cli_xxx) when follower is app"},
{Name: "remove", Desc: "comma-separated follower IDs to remove; use open_id (ou_xxx) when follower is user, use app id (cli_xxx) when follower is app"},
{Name: "idempotency-key", Desc: "client token for idempotency (used for add_members)"},
},
@@ -144,11 +144,7 @@ func buildFollowersBody(idsStr string, clientToken string) map[string]interface{
if id == "" {
continue
}
members = append(members, map[string]interface{}{
"id": id,
"role": "follower",
"type": "user",
})
members = append(members, buildTaskMember(id, "follower"))
}
body := map[string]interface{}{

View File

@@ -15,5 +15,24 @@ func TestBuildFollowersBody(t *testing.T) {
members := body["members"].([]map[string]interface{})
convey.So(len(members), convey.ShouldEqual, 2)
convey.So(body["client_token"], convey.ShouldEqual, "token1")
convey.So(members[0]["role"], convey.ShouldEqual, "follower")
convey.So(members[0]["type"], convey.ShouldEqual, "user")
})
convey.Convey("Build infers app followers", t, func() {
body := buildFollowersBody("cli_bot_1", "")
members := body["members"].([]map[string]interface{})
convey.So(len(members), convey.ShouldEqual, 1)
convey.So(members[0]["id"], convey.ShouldEqual, "cli_bot_1")
convey.So(members[0]["role"], convey.ShouldEqual, "follower")
convey.So(members[0]["type"], convey.ShouldEqual, "app")
})
convey.Convey("Build infers mixed follower types in one list", t, func() {
body := buildFollowersBody("ou_user_1, cli_bot_1", "")
members := body["members"].([]map[string]interface{})
convey.So(len(members), convey.ShouldEqual, 2)
convey.So(members[0]["type"], convey.ShouldEqual, "user")
convey.So(members[1]["type"], convey.ShouldEqual, "app")
})
}

View File

@@ -8,7 +8,7 @@
- `lark-cli base records create`
2. **优先使用 Shortcut** — 有 Shortcut 的操作不要手拼原生 API
3. **写记录前** — 先调用 `table.fields list` 获取字段 `type/ui_type`,再读 [lark-base-cell-value.md](../../skills/lark-base/references/lark-base-cell-value.md);该文档是 CellValue 的 source of truth
4. **写字段前** — 先读 [lark-base-shortcut-field-properties.md](../../skills/lark-base/references/lark-base-shortcut-field-properties.md) 确认字段类型`property` 结构
4. **写字段前** — 先读 [lark-base-shortcut-field-properties.md](../../skills/lark-base/references/lark-base-shortcut-field-properties.md) 确认字段类型 JSON 结构
5. **筛选查询前** — 先读 [lark-base-view-set-filter.md](../../skills/lark-base/references/lark-base-view-set-filter.md),当前 `base/v3` 通过 `view.filter update + table.records list` 组合完成筛选读取
6. **批量上限 200 条/次** — 同一表建议串行写入,并在批次间延迟 0.51 秒
7. **改名和删除按明确意图执行** — 视图重命名这类低风险改名操作,目标和新名称明确时可直接执行;删除记录 / 字段 / 表时,只要用户已经明确要求删除且目标明确,也可直接执行,不需要再补一次确认

View File

@@ -236,6 +236,34 @@ lark-cli mail user_mailbox.sent_messages get_recall_detail --as user \
- 需要同时授权 mail 和 im 两个域的 scope
- 分享的卡片包含邮件摘要信息,收件人可点击查看
### 发送日程邀请邮件
在邮件中嵌入日程邀请(`text/calendar`),收件人收信后可直接接受或拒绝日程。`To`/`Cc` 收件人自动成为参会人ATTENDEE发件人自动成为组织者ORGANIZER
```bash
# 发送带日程邀请的新邮件(先保存草稿,确认后发送)
lark-cli mail +send --as user \
--to alice@example.com --cc bob@example.com \
--subject '产品评审' \
--body '<p>请参加本次产品评审会议。</p>' \
--event-summary '产品评审' \
--event-start '2026-05-10T14:00+08:00' \
--event-end '2026-05-10T15:00+08:00' \
--event-location '5F 大会议室' \
--confirm-send
```
**参数说明:**
- `--event-summary`:日程标题,设置此参数即开启日程邀请模式,需同时设置 `--event-start` 和 `--event-end`
- `--event-start` / `--event-end`ISO 8601 格式时间,如 `2026-05-10T14:00+08:00`
- `--event-location`:可选,日程地点
**约束:**
- `--event-*` 与 `--send-time`(定时发送)互斥,不可同时使用
- `Bcc` 收件人不会成为日程参会人;如果邮件同时包含 Bcc 和日程,后端在发送时会拒绝该请求
读取含日程邀请的邮件时,`calendar_event` 字段包含日程详情(`method`、`summary`、`start`、`end`、`organizer`、`attendees` 等)。
### 正文格式:优先使用 HTML
撰写邮件正文时,**默认使用 HTML 格式**body 内容会被自动检测)。仅当用户明确要求纯文本时,才使用 `--plain-text` 标志强制纯文本模式。

View File

@@ -1,7 +1,7 @@
---
name: lark-base
version: 1.2.0
description: "当需要用 lark-cli 操作飞书多维表格Base时调用适用于建表、字段管理、记录读写、记录分享链接、视图配置、历史查询,以及角色/表单/仪表盘管理/工作流;也适用于把旧的 +table / +field / +record 写法改成当前命令写法。涉及字段设计、公式字段、查找引用、跨表计算、行级派生指标、数据分析需求时也必须使用本 skill。"
description: "当需要用 lark-cli 操作飞书多维表格Base时调用搜索 Base、建表、字段管理、记录读写、记录分享链接、视图配置、历史查询,以及角色/表单/仪表盘管理/工作流;也适用于把旧的 +table / +field / +record 写法改成当前命令写法。涉及字段设计、公式字段、查找引用、跨表计算、行级派生指标、数据分析需求时也必须使用本 skill。"
metadata:
requires:
bins: ["lark-cli"]
@@ -42,6 +42,7 @@ metadata:
3. 定位到命令后,先读该命令对应的 reference再执行命令。
4. 如果用户要把本地 Excel / CSV / `.base` 快照导入成 Base / 多维表格 / bitable第一步不是 `base`,而是 `lark-cli drive +import --type bitable`;导入完成后再回到 `lark-cli base +...` 做表内操作。
5. 不要在 Base 场景改走 `lark-cli api /open-apis/bitable/v1/...`
6. 如果用户只给 Base 名称、关键词,或说“帮我找一个多维表格”,先通过 `lark-cli docs +search --query <keyword> --filter '{"doc_types":["BITABLE"]}'` 搜索 `BITABLE` 资源;拿到 Base URL 后再使用本 skill 的 `base +...` 命令。复杂搜索再读 [`../lark-doc/references/lark-doc-search.md`](../lark-doc/references/lark-doc-search.md):标题精确匹配、限定创建者/群/文件夹/时间范围、只搜标题/评论、分页/全量搜索。
## 2. 模块与命令导航
@@ -67,6 +68,7 @@ metadata:
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|------|------------------|----------------|----------|
| `lark-cli docs +search --query <keyword> --filter '{"doc_types":["BITABLE"]}'` | 按名称、关键词查找 Base / 多维表格 / bitable | 复杂搜索再读 [`../lark-doc/references/lark-doc-search.md`](../lark-doc/references/lark-doc-search.md) | 先定位资源,再回到 `base +...` 操作表内数据 |
| `+base-create` | 创建新的 Base | [`lark-base-base-create.md`](references/lark-base-base-create.md)、[`lark-base-workspace.md`](references/lark-base-workspace.md) | 写入操作;执行前先读 reference`--folder-token``--time-zone` 都是可选项 |
| `+base-get` | 获取 Base 信息 | [`lark-base-base-get.md`](references/lark-base-base-get.md)、[`lark-base-workspace.md`](references/lark-base-workspace.md) | 适合确认 Base 本体信息,不替代表/字段结构读取 |
| `+base-copy` | 复制已有 Base | [`lark-base-base-copy.md`](references/lark-base-base-copy.md)、[`lark-base-workspace.md`](references/lark-base-workspace.md) | 写入操作;执行前先读 reference复制成功后应主动返回新 Base 标识信息 |

View File

@@ -22,16 +22,16 @@ Block 的 `data_config` 字段因 `type` 不同而变化。本文档描述所有
## 字段类型与操作符速查AI 决策用)
> `+field-list` 返回的 `type` 字段映射number数字、text文本、select单选、multi_select多选datetime(日期时间)、checkbox复选框、user人员
> 先用 `+field-list` / `+field-get` 确认字段 `type`;本节使用当前字段接口里的 canonical 类型名:`number`、`text`、`select`、`datetime`、`checkbox`、`user`。
```
文本/电话/URL/邮箱: is, isNot, contains, doesNotContain, isEmpty, isNotEmpty
数字/货币/进度: is, isNot, isGreater, isGreaterEqual, isLess, isLessEqual, isEmpty, isNotEmpty
单选: is, isNot, isEmpty, isNotEmpty
多选: is, isNot, contains, doesNotContain, isEmpty, isNotEmpty
日期/时间: is, isGreater, isGreaterEqual, isLess, isLessEqual, isEmpty, isNotEmpty
复选框: is (value: true/false)
人员/创建人/修改人: is, isNot, isEmpty, isNotEmpty
text: is, isNot, contains, doesNotContain, isEmpty, isNotEmpty
number: is, isNot, isGreater, isGreaterEqual, isLess, isLessEqual, isEmpty, isNotEmpty
selectmultiple=false: is, isNot, isEmpty, isNotEmpty
selectmultiple=true: is, isNot, contains, doesNotContain, isEmpty, isNotEmpty
datetime: is, isGreater, isGreaterEqual, isLess, isLessEqual, isEmpty, isNotEmpty
checkbox: is (value: true/false)
user / created_by / updated_by: is, isNot, isEmpty, isNotEmpty
```
## data_config 通用结构
@@ -148,13 +148,13 @@ Block 的 `data_config` 字段因 `type` 不同而变化。本文档描述所有
| 字段类型 | value 类型 | 适用操作符 | 示例 |
|----------|-----------|-----------|------|
| 文本 / 电话 / URL | string | is, isNot, contains, doesNotContain, isEmpty, isNotEmpty | `{"field_name":"姓名","operator":"contains","value":"张"}` |
| 数字 | number | is, isNot, isGreater, isGreaterEqual, isLess, isLessEqual, isEmpty, isNotEmpty | `{"field_name":"金额","operator":"isGreater","value":0}` |
| 单选 | string选项名 | is, isNot, isEmpty, isNotEmpty | `{"field_name":"状态","operator":"is","value":"已完成"}` |
| 多选 | string[](选多个)/ string选单个 | is, isNot, contains, doesNotContain, isEmpty, isNotEmpty | 多选传数组如 `["标签1","标签2"]`;单选传单个字符串 |
| 日期时间 / 创建时间 / 修改时间 | numberUnix 毫秒时间戳13位 | is, isGreater, isGreaterEqual, isLess, isLessEqual, isEmpty, isNotEmpty | `{"field_name":"创建日期","operator":"isGreater","value":1704038400000}` |
| 复选框 | boolean | is | `{"field_name":"已审核","operator":"is","value":true}` |
| 人员 / 创建人 / 修改人 | string 或 string[](用户 ID格式 `ou_xxx` | is, isNot, isEmpty, isNotEmpty | `{"field_name":"负责人","operator":"is","value":"ou_xxxxxxxxxxxxxxxx"}` |
| `text` | string | is, isNot, contains, doesNotContain, isEmpty, isNotEmpty | `{"field_name":"姓名","operator":"contains","value":"张"}` |
| `number` | number | is, isNot, isGreater, isGreaterEqual, isLess, isLessEqual, isEmpty, isNotEmpty | `{"field_name":"金额","operator":"isGreater","value":0}` |
| `select` (`multiple=false`) | string选项名 | is, isNot, isEmpty, isNotEmpty | `{"field_name":"状态","operator":"is","value":"已完成"}` |
| `select` (`multiple=true`) | string[](选多个)/ string选单个 | is, isNot, contains, doesNotContain, isEmpty, isNotEmpty | 多选传数组如 `["标签1","标签2"]`;单选传单个字符串 |
| `datetime` / `created_at` / `updated_at` | numberUnix 毫秒时间戳13位 | is, isGreater, isGreaterEqual, isLess, isLessEqual, isEmpty, isNotEmpty | `{"field_name":"创建日期","operator":"isGreater","value":1704038400000}` |
| `checkbox` | boolean | is | `{"field_name":"已审核","operator":"is","value":true}` |
| `user` / `created_by` / `updated_by` | string 或 string[](用户 ID格式 `ou_xxx` | is, isNot, isEmpty, isNotEmpty | `{"field_name":"负责人","operator":"is","value":"ou_xxxxxxxxxxxxxxxx"}` |
| 所有类型(为空/不为空) | 不需要 value | isEmpty, isNotEmpty | `{"field_name":"备注","operator":"isEmpty"}` |
> `value` 类型为 `string | number | boolean | string[]`,需根据字段类型匹配正确格式
@@ -268,7 +268,7 @@ Block 的 `data_config` 字段因 `type` 不同而变化。本文档描述所有
{
"table_name": "表名",
"series": [{ "field_name": "数值字段", "rollup": "SUM" }],
"group_by": [{ "field_name": "阶段字段", "mode": "integrated" }]
"group_by": [{ "field_name": "状态字段", "mode": "integrated" }]
}
```

View File

@@ -15,10 +15,10 @@ lark-cli base +table-create \
--base-token bascnXXXXXXXX \
--name "客户管理表" \
--fields '[
{"name":"客户名称","type":"text"},
{"name":"负责人","type":"user","property":{"multiple":false}},
{"name":"客户名称","type":"text","description":"主标题字段"},
{"name":"负责人","type":"user","multiple":false,"description":"用于标记客户跟进的直接负责人"},
{"name":"签约日期","type":"datetime"},
{"name":"状态","type":"single_select","property":{"options":["进行中","已完成"]}}
{"name":"状态","type":"select","multiple":false,"options":[{"name":"进行中"},{"name":"已完成"}]}
]'
```

View File

@@ -43,11 +43,11 @@ This is the foundation of formula logic. You must determine this before writing
- Scalars can be used directly in operations: `[Price] * [Quantity]`
- Lists cannot be used as scalars — they must be processed first: use `SUM()` for sum, `ARRAYJOIN(",")` for joining, `FIRST()`/`LAST()`/`NTH()` for single value extraction
- Link field access `[LinkField].[TargetField]` returns a list (values of the target field for all linked records)
- **LISTCOMBINE flattening rule**: When a FILTER's result column is itself a multi-value field (MultiSelect, Link, etc.), it produces a 2D array and **must** be flattened with `.LISTCOMBINE()`; for single-value fields (Number, Text, etc.) it can be omitted, but adding it is never wrong:
- **LISTCOMBINE flattening rule**: When a FILTER's result column is itself a multi-value field (`select` with `multiple=true`, `link`, etc.), it produces a 2D array and **must** be flattened with `.LISTCOMBINE()`; for single-value fields (`number`, `text`, etc.) it can be omitted, but adding it is never wrong:
```
[Table].FILTER(CurrentValue.[Field] = [Value]).[MultiSelectCol].LISTCOMBINE() ← required for multi-value columns
[Table].FILTER(CurrentValue.[Field] = [Value]).[NumberCol].LISTCOMBINE() ← optional for single-value columns
[Table].FILTER(CurrentValue.[Field] = [Value]).[Tags].LISTCOMBINE() ← required for multi-value columns
[Table].FILTER(CurrentValue.[Field] = [Value]).[NumberCol].LISTCOMBINE() ← optional for single-value columns
```
---
@@ -56,14 +56,14 @@ This is the foundation of formula logic. You must determine this before writing
### Field storage types
| Type | Description | Supported operations |
| ----------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| Number | Stored as numeric value | Math operations, comparisons, auto-converts to string for concatenation |
| Text | Stored as string | String operations; can participate in math if content is numeric, otherwise errors |
| Date | Date object | Date functions, add/subtract with numbers; auto-converts to default format string when using `&` — use TEXT to format first for controlled output |
| MultiSelect | Data list | List functions, CONTAIN checks |
| Link | Links to other table records | Chained access `[LinkField].[Field]`, result is a list |
| Boolean | TRUE/FALSE | Logical operations; auto-converts to number when compared with numbers |
| Type | Description | Supported operations |
|------|-------------|----------------------|
| `number` | Stored as numeric value | Math operations, comparisons, auto-converts to string for concatenation |
| `text` | Stored as string | String operations; can participate in math if content is numeric, otherwise errors |
| `datetime` | Date object | Date functions, add/subtract with numbers; auto-converts to default format string when using `&` — use TEXT to format first for controlled output |
| `select` (`multiple=true`) | Data list | List functions, CONTAIN checks |
| `link` | Links to other table records | Chained access `[LinkField].[Field]`, result is a list |
| `checkbox` | TRUE/FALSE | Logical operations; auto-converts to number when compared with numbers |
### Implicit type conversion
@@ -81,11 +81,11 @@ When using comparison operators (`>`, `>=`, `<`, `<=`, `=`, `!=`), **both sides
**Principle**: When types differ, explicitly convert one side rather than relying on implicit conversion:
- Number vs Text → use `VALUE()` to convert text to number
- Date vs Text → use `TEXT()` to convert date to text
- Date vs Date equality → Dates include time components, so direct `=` comparison may fail due to different hours/minutes/seconds. For day-level equality, convert to text first: `TEXT([DateA], "YYYY/MM/DD") = TEXT([DateB], "YYYY/MM/DD")`
- Select and User fields can be compared with both same-type values and text
- Text fields in numeric aggregation (SUM/AVERAGE/MIN/MAX etc.) → convert to number with `VALUE()` first. For FILTER results, use `.MAP(VALUE(CurrentValue)).SUM()`
- `number` vs `text` → use `VALUE()` to convert text to number
- `datetime` vs `text` → use `TEXT()` to convert date to text
- `datetime` vs `datetime` equality → dates include time components, so direct `=` comparison may fail due to different hours/minutes/seconds. For day-level equality, convert to text first: `TEXT([DateA], "YYYY/MM/DD") = TEXT([DateB], "YYYY/MM/DD")`
- `select` and `user` fields can be compared with both same-type values and text
- `text` fields in numeric aggregation (SUM/AVERAGE/MIN/MAX etc.) → convert to number with `VALUE()` first. For FILTER results, use `.MAP(VALUE(CurrentValue)).SUM()`
---
@@ -99,7 +99,7 @@ When using comparison operators (`>`, `>=`, `<`, `<=`, `=`, `!=`), **both sides
| ---------------------------- | ----------------------- | --------------------------- | --------------------------------------------------------- |
| Entire table `[TableName]` | A row in the table | `CurrentValue.[FieldName]` | `[Orders].FILTER(CurrentValue.[Amount] > 100).[Customer]` |
| Column `[TableName].[Field]` | A single field value | Use `CurrentValue` directly | `[Orders].[Amount].FILTER(CurrentValue > 100)` |
| MultiSelect field `[Tags]` | One option | Use `CurrentValue` directly | `[Tags].FILTER(CurrentValue = "Important")` |
| `select` (`multiple=true`) field `[Tags]` | One option | Use `CurrentValue` directly | `[Tags].FILTER(CurrentValue = "Important")` |
| LIST-generated list | One element | Use `CurrentValue` directly | `LIST(1,2,3).MAP(CurrentValue * 2)` |
### Key rules
@@ -190,7 +190,7 @@ Retrieves the target field values for all linked records as a list. Supports con
```
Correct: [Sales].FILTER(CurrentValue.[Amount] > 100).[Customer]
Correct: [Sales].FILTER(condition).SORTBY([Sales].[SortCol]).[Customer] ← result column at end of chain
Wrong: [Sales].FILTER(CurrentValue.[Amount] > 100) ← missing result column
Wrong: [Sales].FILTER(CurrentValue.[Amount] > 100) ← missing result column
```
- **When data range is a column** `[TableName].[Field]` or a list, FILTER returns the filtered list directly — **no** result column needed:
@@ -244,9 +244,9 @@ After the result column, it's recommended to flatten with `.LISTCOMBINE()` first
| ISNULL | `ISNULL(value)` | Boolean | Tests if NULL (only NULL is true; empty string is not) |
| ISERROR | `ISERROR(expr)` | Boolean | Tests if expression errors |
| ISNUMBER | `ISNUMBER(value)` | Boolean | Tests if value is a number |
| CONTAIN | `CONTAIN(search_range, value, ...)` | Boolean | Tests if list/MultiSelect contains the value; **does NOT do text substring matching** |
| CONTAINSALL | `CONTAINSALL(search_range, value, ...)` | Boolean | Tests if list/MultiSelect contains all specified values |
| CONTAINSONLY | `CONTAINSONLY(search_range, value, ...)` | Boolean | Tests if list/MultiSelect contains only the specified values |
| CONTAIN | `CONTAIN(search_range, value, ...)` | Boolean | Tests if a list or `select` (`multiple=true`) contains the value; **does NOT do text substring matching** |
| CONTAINSALL | `CONTAINSALL(search_range, value, ...)` | Boolean | Tests if a list or `select` (`multiple=true`) contains all specified values |
| CONTAINSONLY | `CONTAINSONLY(search_range, value, ...)` | Boolean | Tests if a list or `select` (`multiple=true`) contains only the specified values |
| TRUE | `TRUE()` | Boolean | Returns TRUE |
| FALSE | `FALSE()` | Boolean | Returns FALSE |
| RECORD_ID | `RECORD_ID()` | Text | Returns the current row's record ID |
@@ -256,7 +256,7 @@ After the result column, it's recommended to flatten with `.LISTCOMBINE()` first
### 8.2 Numeric functions
| Function | Signature | Return type | Description |
| ----------------------------------------------------------------- | ---------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| --- | --- | --- | --- |
| SUM | `SUM(val1, val2, ...)` | Number | Sum; accepts multiple values or a list |
| AVERAGE | `AVERAGE(val1, val2, ...)` | Number | Average |
| MAX | `MAX(val1, val2, ...)` | Number | Maximum |
@@ -336,7 +336,7 @@ After the result column, it's recommended to flatten with `.LISTCOMBINE()` first
### 8.5 List functions
| Function | Signature | Return type | Description |
| ----------- | ---------------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| --- | --- | --- | --- |
| LIST | `LIST(val1, val2, ...)` | List | Create a list |
| FIRST | `FIRST(list)` | Scalar | First element |
| LAST | `LAST(list)` | Scalar | Last element |
@@ -358,7 +358,7 @@ After the result column, it's recommended to flatten with `.LISTCOMBINE()` first
| | CONTAIN | CONTAINTEXT |
| ----------- | -------------------------------------------------------------- | ---------------------------------------------------------- |
| Purpose | Tests if **list/MultiSelect** contains a value | Tests if **text** contains a substring |
| Purpose | Tests if a **list / `select` (`multiple=true`)** contains a value | Tests if **text** contains a substring |
| Example | `[Tags].CONTAIN("Urgent")` | `[Notes].CONTAINTEXT("completed")` |
| Wrong usage | `CONTAIN([Notes], "completed")` — cannot do substring matching | `CONTAINTEXT([Tags], "Urgent")` — Tags is a list, not text |
@@ -494,7 +494,7 @@ DAYS([EndDate], [StartDate])
### Pattern 7: List element mapping
```
[MultiSelectField].MAP(CurrentValue & " tag")
[SelectField(which multiple=true)].MAP(CurrentValue & " tag")
SPLIT([TextField], ",").MAP(TRIM(CurrentValue))
```
@@ -579,13 +579,13 @@ Wrong: CONTAIN([Notes], "urgent")
Correct: CONTAINTEXT([Notes], "urgent")
```
Reason: CONTAIN checks if a list/MultiSelect contains a whole value, not substring matching. Use CONTAINTEXT for text substrings.
Reason: CONTAIN checks if a list or `select` (`multiple=true`) contains a whole value, not substring matching. Use CONTAINTEXT for text substrings.
### Mistake 9: Date concatenation without formatting
```
Not recommended: "Deadline: " & [DateField] ← output format is uncontrolled
Recommended: "Deadline: " & TEXT([DateField], "YYYY-MM-DD")
Not recommended: "Deadline: " & [DateField] ← output format is uncontrolled
Recommended: "Deadline: " & TEXT([DateField], "YYYY-MM-DD")
```
Reason: Concatenating a date with `&` won't error, but uses the default format. Use TEXT to specify the format explicitly.
@@ -649,9 +649,9 @@ IF(
**Table structure**:
- Orders: ID (AutoNumber), OrderItems (Link [target: OrderItems, foreign key: ID])
- OrderItems: ID (AutoNumber), Product (Link [target: Products, foreign key: ID])
- Products: ID (AutoNumber), ProductName (Text)
- Orders: ID (`auto_number`), OrderItems (`link` [target: OrderItems, foreign key: ID])
- OrderItems: ID (`auto_number`), Product (`link` [target: Products, foreign key: ID])
- Products: ID (`auto_number`), ProductName (`text`)
**Current table**: Orders

View File

@@ -72,15 +72,18 @@
}
```
### 2.6 user
### 2.6 user / group_chat
用对象数组,元素至少包含 `id`单选/多选人员字段都使用数组;`id` 必须是可被当前 Base 识别的用户 ID写入前确认字段是否允许多选人员
用对象数组,元素至少包含 `id`人员字段传用户 ID`ou_xxx`),群字段传群 ID`oc_xxx`);单值/多值都统一使用数组
```json
{
"负责人": [
{ "id": "ou_xxx" },
{ "id": "ou_xxx2" }
],
"协作群": [
{ "id": "oc_xxx" }
]
}
```

View File

@@ -7,10 +7,11 @@
## 限制
- **权限要求**:调用者必须是目标多维表格的管理员,它才能拥有目标多维表格的 **FAFull Access / 完全访问权限)**,否则返回权限错误
- **支持的字段类型**(白名单,仅以下类型可用于 dimensions / measures / filters / sort
文本、邮箱、条码、数字、进度、货币、评分、单选、多选、日期、复选框、人员、超链接
- **不支持的字段类型**:公式、查找引用、附件、时长、阶段、创建时间、修改时间、创建人、修改人、群组、电话号码、自动编号、地理位置、关联、双向关联 —— 不可用于 dimensions / measures / filters / sort使用会返回校验错误
- **权限要求**(按文档类型分流):
- **普通多维表格**:调用者拥有文档的**阅读权限**即可
- **高级权限多维表格**:调用者必须是文档管理员,拥有 **FAFull Access / 完全访问权限)**
权限不足时返回权限错误。
## 推荐命令
@@ -119,11 +120,13 @@ POST /open-apis/base/v3/bases/:base_token/data/query
| 聚合函数 | 适用字段类型 |
|----------|-------------|
| `sum` / `avg` | 数字、进度、货币、评分(不含复选框) |
| `min` / `max` | 数字、进度、货币、评分、日期 |
| `count` | 白名单内所有类型,计数非空值 |
| `count_all` | 白名单内所有类型,计数所有行 |
| `distinct_count` | 白名单内所有类型 |
| `sum` / `avg` | `number` |
| `min` / `max` | `number``datetime` |
| `count` | 全字段适用,计数非空值 |
| `count_all` | 全字段适用,计数所有行 |
| `distinct_count` | 全字段适用 |
> `number` 包含 `style.type` 为 `progress` / `currency` / `rating` 等所有子类型。
**FilterGroup**
@@ -172,14 +175,18 @@ POST /open-apis/base/v3/bases/:base_token/data/query
**按各字段类型筛选时 value 格式详解:**
*文本 / 邮箱 / 条码*
*`text`*
| 运算符 | value 格式 | 元素个数 | 示例 |
|--------|-----------|---------|------|
| `is` / `isNot` / `contains` / `doesNotContain` | `["文本内容"]` | 仅 1 个 | `["Hello"]` |
| `isEmpty` / `isNotEmpty` | `[]` | 0 个 | `[]` |
*数字 / 货币*
> **不支持** `isGreater` / `isGreaterEqual` / `isLess` / `isLessEqual`:文本无自然顺序,比较运算无意义。
> `text` 也覆盖电话、超链接、邮箱、条码字段;通过 `style.type` 区分(`plain`(默认)/ `phone` / `url` / `email` / `barcode`),运算符集合一致。
> 当 `style.type=url` 时value 筛选的是链接显示名称,而不是 URL 本身。
*`number`*
| 运算符 | value 格式 | 元素个数 | 示例 |
|--------|-----------|---------|------|
@@ -187,24 +194,19 @@ POST /open-apis/base/v3/bases/:base_token/data/query
| `isEmpty` / `isNotEmpty` | `[]` | 0 个 | `[]` |
> value 必须为合法数字的字符串形式。
> `number` 也覆盖货币、进度、评分字段;通过 `style.type` 区分(`plain`(默认)/ `currency` / `progress` / `rating`),运算符集合一致,仅 value 解释不同:
> - 当 `style.type=progress` 时34% 对应 0.34 而不是 34。
> - 当 `style.type=rating` 时,必须输入整数,代表评分。
*进度*
*`auto_number`*
| 运算符 | value 格式 | 元素个数 | 示例 |
|--------|-----------|---------|------|
| `is` / `isNot` / `isGreater` / `isGreaterEqual` / `isLess` / `isLessEqual` | `["小数字符串"]` | 仅 1 个 | `["0.34"]`= 34% |
| `is` / `isNot` / `contains` / `doesNotContain` | `["编号字符串"]` | 仅 1 个 | `["00001"]` |
| `isGreater` / `isGreaterEqual` / `isLess` / `isLessEqual` | `["编号字符串"]` | 仅 1 个 | `["00010"]` |
| `isEmpty` / `isNotEmpty` | `[]` | 0 个 | `[]` |
> **用小数表示百分比**`["0.34"]` 表示 34%,不是 `["34"]`。
*评分*
| 运算符 | value 格式 | 元素个数 | 示例 |
|--------|-----------|---------|------|
| `is` / `isNot` / `isGreater` / `isGreaterEqual` / `isLess` / `isLessEqual` | `["数字字符串"]` | 仅 1 个 | `["4"]` |
| `isEmpty` / `isNotEmpty` | `[]` | 0 个 | `[]` |
*单选 / 多选*
*`select`*
| 运算符 | value 格式 | 元素个数 | 示例 |
|--------|-----------|---------|------|
@@ -212,26 +214,52 @@ POST /open-apis/base/v3/bases/:base_token/data/query
| `contains` / `doesNotContain` | `["选项A", "选项B"]` | 可多个 | `["选项A", "选项B"]` |
| `isEmpty` / `isNotEmpty` | `[]` | 0 个 | `[]` |
*人员*
> **不支持** `isGreater` / `isGreaterEqual` / `isLess` / `isLessEqual`:选项为枚举值,无自然顺序。
> 通过 `multiple` 区分单选(`multiple=false`,默认)/ 多选(`multiple=true`)。
*`user` / `created_by` / `updated_by`*
| 运算符 | value 格式 | 元素个数 | 示例 |
|--------|-----------|---------|------------------------|
| `is` / `isNot` | `["用户ID"]` | **仅 1 ** | `["ou_aaa"]` |
| `is` / `isNot` | `["用户ID1", "用户ID2"]` | **可多** | `["ou_aaa", "ou_bbb"]` |
| `contains` / `doesNotContain` | `["用户ID1", "用户ID2"]` | 可多个 | `["ou_aaa", "ou_bbb"]` |
| `isEmpty` / `isNotEmpty` | `[]` | 0 个 | `[]` |
> **不支持** `isGreater` / `isGreaterEqual` / `isLess` / `isLessEqual`:人员无法比大小。
> 用户 ID 使用 `open_id``ou_` 前缀),接口层会自动做 ID 转换。
*超链接*
*`group_chat`*
| 运算符 | value 格式 | 元素个数 | 示例 |
|--------|-----------|---------|------|
| `is` / `isNot` / `contains` / `doesNotContain` | `["链接显示名称"]` | 仅 1 个 | `["点击查看"]` |
| `is` / `isNot` | `["群组ID1", "群组ID2"]` | 可多个 | `["oc_aaa", "oc_bbb"]` |
| `contains` / `doesNotContain` | `["群组ID1", "群组ID2"]` | 可多个 | `["oc_aaa", "oc_bbb"]` |
| `isEmpty` / `isNotEmpty` | `[]` | 0 个 | `[]` |
> **按显示名称筛选**,不是按 URL 本身
> **不支持** `isGreater` / `isGreaterEqual` / `isLess` / `isLessEqual`:群组无法比大小
*复选框*
*`link`*
| 运算符 | value 格式 | 元素个数 | 示例 |
|--------|-----------|---------|------|
| `is` / `isNot` | `["recId1", "recId2"]` | 可多个 | `["recAAA", "recBBB"]` |
| `contains` / `doesNotContain` | `["recId1", "recId2"]` | 可多个 | `["recAAA", "recBBB"]` |
| `isEmpty` / `isNotEmpty` | `[]` | 0 个 | `[]` |
> **不支持** `isGreater` / `isGreaterEqual` / `isLess` / `isLessEqual`:关联记录无法比大小。
> value 传关联表记录的 `record_id`。
> 双向关联(创建时设 `bidirectional=true`)也属于 `link` 类型,运算符与单向关联一致。
*`location`*
| 运算符 | value 格式 | 元素个数 | 示例 |
|--------|-----------|---------|------|
| `is` / `isNot` / `contains` / `doesNotContain` | `["地址文本"]` | 仅 1 个 | `["北京市朝阳区..."]` |
| `isEmpty` / `isNotEmpty` | `[]` | 0 个 | `[]` |
> **不支持** `isGreater` / `isGreaterEqual` / `isLess` / `isLessEqual`:地理位置无自然顺序。
*`checkbox`*
| 运算符 | value 格式 | 元素个数 | 示例 |
|--------|-----------|---------|------|
@@ -239,7 +267,7 @@ POST /open-apis/base/v3/bases/:base_token/data/query
> 仅支持 `is` 运算符,不支持其他运算符。
*日期*
*`datetime` / `created_at` / `updated_at`*
日期字段仅支持 `is``isEmpty``isNotEmpty``isGreater``isLess` 五种运算符。
@@ -264,6 +292,22 @@ value 使用预定义关键字机制,第一个元素为字符串常量名称
> - **范围型关键字**`CurrentWeek`、`LastWeek`、`CurrentMonth`、`LastMonth`、`TheLastWeek`、`TheNextWeek`、`TheLastMonth`、`TheNextMonth`)仅支持 `is` 运算符。
> - **关键字大小写敏感**`ExactDate`、`Today`、`CurrentWeek` 等首字母大写,写错大小写会导致校验失败。
*`attachment`*
| 运算符 | value 格式 | 元素个数 | 示例 |
|--------|-----------|---------|------|
| `isEmpty` / `isNotEmpty` | `[]` | 0 个 | `[]` |
> 附件字段仅支持 `isEmpty` 和 `isNotEmpty`,不支持其他运算符。
*`formula` / `lookup`*
公式和查找引用字段的运算符和 value 格式 **取决于其结果数据类型**,按结果类型参照上方对应字段类型的规则。例如:
- 公式结果为数字 → 按 `number` 规则
- 公式结果为日期 → 按 `datetime` 规则
- 公式结果为单选 → 按 `select` 规则
**Sort 字段:**
| 字段 | 类型 | 必填 | 说明 |
@@ -344,9 +388,7 @@ value 使用预定义关键字机制,第一个元素为字符串常量名称
1. 确认 base-token 和 table-id
2. **先查表结构**:执行 `lark-cli base app.table.fields list --params '{"app_token":"<token>","table_id":"<id>"}'`
3. 从返回的字段列表中
- 获取 field_nameDSL 中使用的字段名称)
- 仅选择白名单内的字段类型(见「限制」章节),排除公式、查找引用、附件等不支持的字段
3. 从返回的字段列表中获取 field_nameDSL 中使用的字段名称)
4. 根据字段信息构造 DSL JSON
5. 执行 +data-query
6. 解读返回结果:
@@ -358,7 +400,7 @@ value 使用预定义关键字机制,第一个元素为字符串常量名称
## 坑点
- ⚠️ **必须先查表结构**DSL 的 `field_name` 必须与表中字段名称精确匹配(区分大小写),不能凭猜测构造。先用 `base app.table.fields list` 获取真实字段名
- ⚠️ **权限要求 FA**:调用者必须是目标多维表格的管理员,它才能拥有目标多维表格的 **FAFull Access / 完全访问权限)**,否则返回权限错误
- ⚠️ **权限要求按文档类型分流**:普通多维表格只需文档**阅读权限**;高级权限多维表格必须是文档管理员(**FA / Full Access**,否则返回权限错误
- ⚠️ **alias 不支持中文**dimensions 和 measures 的 alias 必须使用英文(如 `dim_city``total_amount`),中文 alias 会导致错误
- ⚠️ **API 路径是 `base/v3`**:本接口路径为 `/open-apis/base/v3/bases/:base_token/data/query`,不是 `bitable/v1`。两者完全不同,用错版本号会返回 `[2200] Internal Error`
- ⚠️ **`dimensions``measures` 至少填一个**:两个都不填会返回 DSL 校验错误

View File

@@ -16,18 +16,18 @@
```bash
lark-cli base +field-create \
--base-token app_xxx \
--table-id tbl_xxx \
--json '{"name":"预算","type":"number","precision":2}'
--base-token <base_token> \
--table-id <table_id> \
--json '{"name":"预算","type":"number","style":{"type":"plain","precision":2}}'
lark-cli base +field-create \
--base-token app_xxx \
--table-id tbl_xxx \
--base-token <base_token> \
--table-id <table_id> \
--json '{"name":"状态","type":"select","multiple":false,"options":[{"name":"Todo","hue":"Blue","lightness":"Lighter"},{"name":"Done","hue":"Green","lightness":"Light"}]}'
lark-cli base +field-create \
--base-token app_xxx \
--table-id tbl_xxx \
--base-token <base_token> \
--table-id <table_id> \
--json '{"name":"负责人","type":"user","multiple":false,"description":"用于标记记录的直接负责人;协作约定可参考[团队字段约定](https://example.com/field-spec)"}'
```
@@ -50,9 +50,9 @@ POST /open-apis/base/v3/bases/:base_token/tables/:table_id/fields
- `--json` 必须是 **JSON 对象**,顶层直接传字段定义,不要再套一层。
- 顶层最少包含:`name``type`
- 如需字段说明,直接传 `description`;支持纯文本,也支持 Markdown 链接,如 `协作约定可参考[团队字段约定](https://example.com/field-spec)`
- 所有字段类型都支持可选 `description`;支持纯文本,也支持 Markdown 链接,如 `协作约定可参考[团队字段约定](https://example.com/field-spec)`
- `type` 不同,必填子字段不同:
- `select``multiple` + `options``options` 里只传 `name/hue/lightness`,不要传 `id`
- `select``multiple` 控制是否多选,`options` 定义静态选项,`dynamic_options_source` 定义动态选项来源。静态与动态选项配置二选一,不能同时传
- `link`:必须有 `link_table`,可选 `bidirectional``bidirectional_link_field_name`
- `formula`:必须有 `expression`;先读 formula guide再创建。
- `lookup`:必须有 `from``select``where`;先读 lookup guide再创建。

View File

@@ -8,16 +8,16 @@
```bash
lark-cli base +field-update \
--base-token app_xxx \
--table-id tbl_xxx \
--field-id fld_xxx \
--base-token <base_token> \
--table-id <table_id> \
--field-id <field_id> \
--json '{"name":"状态","type":"select","multiple":false,"options":[{"name":"Todo","hue":"Blue","lightness":"Lighter"},{"name":"Doing","hue":"Orange","lightness":"Light"},{"name":"Done","hue":"Green","lightness":"Light"}]}'
lark-cli base +field-update \
--base-token app_xxx \
--table-id tbl_xxx \
--field-id fld_xxx \
--json '{"name":"负责人","type":"user","multiple":false,"description":"用于标记记录的直接负责人;协作约定可参考[团队字段约定](https://example.com/field-spec)"}'
--base-token <base_token> \
--table-id <table_id> \
--field-id <field_id> \
--json '{"name":"负责人","type":"user","multiple":false,"description":"用于标记记录的直接负责人"}'
```
## 参数
@@ -40,7 +40,7 @@ PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id
- `--json` 必须是 **JSON 对象**,顶层直接传字段定义。
- 更新语义是 `PUT`(全量字段配置更新),不要只传零散片段;至少显式包含 `name``type`,并补齐该类型所需关键配置。
- 如需字段说明,直接传 `description`;支持纯文本,也支持 Markdown 链接,如 `协作约定可参考[团队字段约定](https://example.com/field-spec)`
- 所有字段类型都支持可选 `description`;支持纯文本,也支持 Markdown 链接。
- `select` 更新时:`options` 仍按对象数组传,避免混入无效字段。
- `link` 更新限制:
- 不能把非 `link` 字段改成 `link`,也不能把 `link` 改成非 `link`
@@ -68,7 +68,7 @@ PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id
"name": "负责人",
"type": "user",
"multiple": false,
"description": "用于标记记录的直接负责人;协作约定可参考[团队字段约定](https://example.com/field-spec)"
"description": "用于标记记录的直接负责人"
}
```

View File

@@ -83,10 +83,10 @@ lark-cli base +form-questions-create \
| 类型 | style 结构 | 说明 |
|------|------|------|
| `text` | `{"type":"plain"}` | type 可选:`plain`(纯文本)、`phone`(电话)、`url`(链接)、`email`(邮件)、`barcode`(扫码) |
| `text` | `{"type":"plain"}` | 当前仅支持 `plain` |
| `number` | `{"type":"plain","precision":2}` | precision 为小数位数 |
| `number`(评分) | `{"type":"rating","icon":"star","min":1,"max":5}` | icon 可选:`star`/`heart`/`thumbsup`/`fire`/`smile`/`lightning`/`flower`/`number` |
| `datetime` | `{"type":"plain","format":"yyyy/MM/dd"}` | format 可选:`yyyy/MM/dd``yyyy/MM/dd HH:mm``MM-dd``MM/dd/yyyy``dd/MM/yyyy` |
| `datetime` | `{"format":"yyyy/MM/dd"}` | format 可选:`yyyy/MM/dd``yyyy/MM/dd HH:mm``MM-dd``MM/dd/yyyy``dd/MM/yyyy` |
## 输出格式

View File

@@ -2,69 +2,112 @@
> 适用命令:`lark-cli base +field-create`、`lark-cli base +field-update`
本文件定义 **shortcut 写字段**`--json` 的推荐格式,避免 AI 混用旧版 `type=数字 + field_name + property` 结构
本文件定义 **shortcut 写字段**`--json` 的推荐格式,是字段类型与字段 JSON 结构的 source of truth。目标不是复刻完整 schema而是让 agent 稳定产出正确 payload
## 1. 顶层规则(必须遵守)
- `--json` 必须是 JSON 对象。
- 顶层统一使用:`type` + `name` + 类型特有字段。
- 如需字段说明,直接传 `description`;支持纯文本,也支持 Markdown 链接。
- 所有字段类型都支持可选 `description`;支持纯文本,也支持 Markdown 链接。
- 不要使用旧结构:`field_name``property``ui_type`、数字枚举 `type`
- `+field-update``PUT` 语义,建议先 `+field-get` 再全量提交目标字段配置
- `type=formula``type=lookup` 创建,必须先读对应 guide。
- `+field-update` 使用同样的字段 JSON 结构,但语义`PUT`建议先 `+field-get`按目标状态全量提交。
- `type=formula``type=lookup` 创建/更新前,必须先读对应 guide。
推荐示例:
```json
{
"type": "text",
"name": "需求背景",
"description": "记录需求背景与已知约束;填写口径可参考[说明模板](https://example.com/spec)"
"description": "记录需求背景与已知约束"
}
```
## 2. 各类型格式与示例
## 2. 字段速查
### 2.1 text
| 类型 | 最小必填字段 | 常见补充字段 |
|------|--------------|-------------|
| `text` | `type` `name` | `style.type` |
| `number` | `type` `name` | `style` |
| `select` | `type` `name` | `multiple` + `options`,或 `multiple` + `dynamic_options_source` |
| `datetime` | `type` `name` | `style.format` |
| `created_at` / `updated_at` | `type` `name` | `style.format` |
| `user` / `group_chat` | `type` `name` | `multiple` |
| `created_by` / `updated_by` | `type` `name` | 无 |
| `link` | `type` `name` `link_table` | `bidirectional` `bidirectional_link_field_name` |
| `formula` | `type` `name` `expression` | 无 |
| `lookup` | `type` `name` `from` `select` `where` | `aggregate` |
| `auto_number` | `type` `name` | `style.rules` |
| `attachment` / `location` / `checkbox` | `type` `name` | 无 |
**要求**`name` 必填;可选传 `description``style.type` 可选,默认 `plain`
所有类型都可额外传 `description`;上表的“常见补充字段”只列类型特有配置
## 3. 各类型写法
### 3.1 text
文本字段;电话、超链接、邮箱、条码也都属于 `text`,通过 `style.type` 区分。
最小写法(默认 `style.type``plain`
```json
{
"type": "text",
"name": "标题"
}
```
常用写法:
```json
{
"type": "text",
"name": "标题",
"style": { "type": "plain" }
"description": "主标题字段"
}
```
`style.type` 常用值:`plain``phone``url``email``barcode`
**Schema**
```json
{
"type": "object",
"properties": {
"type": { "type": "string", "const": "text", "description": "Text field type" },
"name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" },
"description": { "type": "string", "description": "Field description; supports plain text or Markdown links" },
"style": {
"type": "object",
"properties": { "type": { "type": "string", "enum": ["plain", "phone", "url", "email", "barcode"], "description": "Text style type" } },
"required": ["type"],
"additionalProperties": false,
"description": "Text style",
"default": { "type": "plain" }
}
},
"required": ["type", "name"],
"additionalProperties": false,
"description": "Text field",
"$schema": "http://json-schema.org/draft-07/schema#"
"type": "text",
"name": "联系电话",
"style": { "type": "phone" }
}
```
### 2.2 number
```json
{
"type": "text",
"name": "官网",
"style": { "type": "url" }
}
```
**要求**`name` 必填;`style.type` 常用 `plain/currency/progress/rating`
常用 `style.type``plain`(默认)、`phone``url``email``barcode`
### 3.2 number
数字字段;货币、进度、评分都属于 `number`,通过 `style.type` 区分。
最小写法(默认 `style.type``plain`
```json
{
"type": "number",
"name": "工时"
}
```
`style` 是按 `type` 区分的对象;不同 `style.type` 的内部字段不一样,不要混传。
#### `plain`
支持字段:`precision``percentage``thousands_separator`
默认值 / 约束:
- `precision` 取值 `0..4`,默认 `2`
- `percentage` 默认 `false`
- `thousands_separator` 默认 `false`
```json
{
@@ -79,6 +122,14 @@
}
```
#### `currency`
支持字段:`precision``currency_code`
默认值 / 约束:
- `precision` 取值 `0..4`,默认 `2`
- `currency_code` 必填,如 `CNY``USD``EUR`
```json
{
"type": "number",
@@ -87,6 +138,15 @@
}
```
#### `progress`
支持字段:`percentage``color`
默认值 / 约束:
- `percentage` 默认 `true`
- `color` 必填
- `color` 可用:`Blue``Purple``DarkGreen``Green``Cyan``Orange``Red``Gray``WhiteToBlueGradient``WhiteToPurpleGradient``WhiteToOrangeGradient``GreenToRedGradient``RedToGreenGradient``BlueToPinkGradient``PinkToBlueGradient``SpectralGradient`
```json
{
"type": "number",
@@ -95,6 +155,16 @@
}
```
#### `rating`
支持字段:`icon``min``max`
默认值 / 约束:
- `icon` 默认 `star`
- `icon` 可用:`star``heart``thumbsup``fire``smile``lightning``flower``number`
- `min` 取值 `0..1`,默认 `1`
- `max` 取值 `1..10`,默认 `5`
```json
{
"type": "number",
@@ -103,87 +173,22 @@
}
```
**Schema**
### 3.3 select
```json
{
"type": "object",
"properties": {
"type": { "type": "string", "const": "number", "description": "Number field type" },
"name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" },
"description": { "type": "string", "description": "Field description; supports plain text or Markdown links" },
"style": {
"anyOf": [
{
"type": "object",
"properties": {
"type": { "type": "string", "const": "plain", "description": "Plain style type" },
"precision": { "type": "number", "minimum": 0, "maximum": 4, "default": 2, "description": "Decimal precision" },
"percentage": { "type": "boolean", "default": false, "description": "Use percentage" },
"thousands_separator": { "$ref": "#/properties/style/anyOf/0/properties/percentage", "default": false, "description": "Use thousand separator" }
},
"required": ["type"],
"additionalProperties": false,
"description": "Plain number style"
},
{
"type": "object",
"properties": {
"type": { "type": "string", "const": "currency", "description": "Currency style type" },
"precision": { "type": "number", "minimum": 0, "maximum": 4, "default": 2, "description": "Decimal precision" },
"currency_code": {
"type": "string",
"enum": ["CNY", "USD", "EUR", "GBP", "AED", "AUD", "BRL", "CAD", "CHF", "HKD", "INR", "IDR", "JPY", "KRW", "MOP", "MXN", "MYR", "PHP", "PLN", "RUB", "SGD", "THB", "TRY", "TWD", "VND"],
"default": "CNY",
"description": "Currency code"
}
},
"required": ["type"],
"additionalProperties": false,
"description": "Currency style"
},
{
"type": "object",
"properties": {
"type": { "type": "string", "const": "progress", "description": "Progress style type" },
"percentage": { "$ref": "#/properties/style/anyOf/0/properties/percentage", "default": true, "description": "Use percentage" },
"color": {
"type": "string",
"enum": ["Blue", "Purple", "DarkGreen", "Green", "Cyan", "Orange", "Red", "Gray", "WhiteToBlueGradient", "WhiteToPurpleGradient", "WhiteToOrangeGradient", "GreedToRedGradient", "RedToGreenGradient", "BlueToPinkGradient", "PinkToBlueGradient", "SpectralGradient"],
"description": "Progress color"
}
},
"required": ["type", "color"],
"additionalProperties": false,
"description": "Progress style"
},
{
"type": "object",
"properties": {
"type": { "type": "string", "const": "rating", "description": "Rating style type" },
"icon": { "type": "string", "enum": ["star", "heart", "thumbsup", "fire", "smile", "lightning", "flower", "number"], "default": "star", "description": "Rating icon" },
"min": { "type": "integer", "minimum": 0, "maximum": 1, "default": 1, "description": "Minimum rating" },
"max": { "type": "integer", "minimum": 1, "maximum": 10, "default": 5, "description": "Maximum rating" }
},
"required": ["type"],
"additionalProperties": false,
"description": "Rating style"
}
],
"default": { "type": "plain" },
"description": "Number style"
}
},
"required": ["type", "name"],
"additionalProperties": false,
"description": "Number field",
"$schema": "http://json-schema.org/draft-07/schema#"
}
```
单选和多选都使用 `select`;用 `multiple` 区分。`multiple` 默认 `false`。静态选项用 `options`,动态选项用 `dynamic_options_source`;两者不要同时传。
### 2.3 select单选/多选)
#### 静态选项
**要求**`name` 必填;`multiple` 控制单/多选;`options` 为对象数组。
支持字段:`multiple``options`
默认值 / 约束:
- `multiple` 默认 `false`
- `options` 最多 `10000`
- `options[]` 结构是 `{name, hue?, lightness?}`
- `options[].name` 必填
- `options[].hue` 可用:`Red``Orange``Yellow``Lime``Green``Turquoise``Wathet``Blue``Carmine``Purple``Gray` 缺省值为 `Blue`
- `options[].lightness` 可用:`Lighter``Light``Standard``Dark``Darker` 缺省值为 `Lighter`
- 选项里没有 `id`,只有 `name`
```json
{
@@ -197,46 +202,48 @@
}
```
- `options[].name` 必填。
- `options` 不要传 `id`(创建场景由后端生成)。
#### 动态选项
**Schema**
支持字段:`multiple``dynamic_options_source`
默认值 / 约束:
- `multiple` 默认 `false`
- `dynamic_options_source` 结构是 `{table_id, field_id}`
- `dynamic_options_source.table_id` 填来源表 id 或表名
- `dynamic_options_source.field_id` 填来源字段 id 或字段名
- `dynamic_options_source` 仅创建支持;更新已有字段时不要传
```json
{
"type": "object",
"properties": {
"type": { "type": "string", "const": "select", "description": "Select field type" },
"name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" },
"description": { "type": "string", "description": "Field description; supports plain text or Markdown links" },
"multiple": { "type": "boolean", "default": false, "description": "Allow multiple" },
"options": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string", "description": "Option name" },
"hue": { "type": "string", "enum": ["Red", "Orange", "Yellow", "Lime", "Green", "Turquoise", "Wathet", "Blue", "Carmine", "Purple", "Gray"], "description": "Option hue", "default": "Blue" },
"lightness": { "type": "string", "enum": ["Lighter", "Light", "Standard", "Dark", "Darker"], "description": "Option lightness", "default": "Lighter" }
},
"required": ["name"],
"additionalProperties": false,
"description": "Select option"
},
"maxItems": 10000,
"description": "Static options"
}
},
"required": ["type", "name", "options"],
"additionalProperties": false,
"description": "Select field",
"$schema": "http://json-schema.org/draft-07/schema#"
"type": "select",
"name": "动态状态",
"multiple": false,
"dynamic_options_source": {
"table_id": "选项表",
"field_id": "候选状态"
}
}
```
### 2.4 datetime / created_at / updated_at
### 3.4 datetime
**要求**`name` 必填;`style.format` 可选
手动填写的日期/时间字段。系统时间用 `created_at` / `updated_at`
最小写法:
```json
{
"type": "datetime",
"name": "截止时间"
}
```
支持字段:`style.format`
默认值 / 约束:
- `style.format` 默认 `yyyy/MM/dd` 可用格式:`yyyy/MM/dd``yyyy/MM/dd HH:mm``yyyy/MM/dd HH:mm Z``yyyy-MM-dd``yyyy-MM-dd HH:mm``yyyy-MM-dd HH:mm Z``MM-dd``MM/dd/yyyy``dd/MM/yyyy`
常用写法:
```json
{
@@ -246,92 +253,43 @@
}
```
### 3.5 created_at / updated_at
系统创建时间 / 系统更新时间字段;可配显示格式,但记录写入时应视为只读。
支持字段:`style.format`
默认值 / 约束:
- `style.format` 默认 `yyyy/MM/dd`
- 可用格式:`yyyy/MM/dd``yyyy/MM/dd HH:mm``yyyy/MM/dd HH:mm Z``yyyy-MM-dd``yyyy-MM-dd HH:mm``yyyy-MM-dd HH:mm Z``MM-dd``MM/dd/yyyy``dd/MM/yyyy`
```json
{ "type": "created_at", "name": "创建时间", "style": { "format": "yyyy/MM/dd" } }
{ "type": "created_at", "name": "创建时间" }
```
```json
{ "type": "updated_at", "name": "更新时间", "style": { "format": "yyyy/MM/dd HH:mm" } }
```
**Schema - datetime**
### 3.6 user / group_chat
```json
{
"type": "object",
"properties": {
"type": { "type": "string", "const": "datetime", "description": "Date time type" },
"name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" },
"description": { "type": "string", "description": "Field description; supports plain text or Markdown links" },
"style": {
"type": "object",
"properties": { "format": { "type": "string", "enum": ["yyyy/MM/dd", "yyyy/MM/dd HH:mm", "yyyy/MM/dd HH:mm Z", "yyyy-MM-dd", "yyyy-MM-dd HH:mm", "yyyy-MM-dd HH:mm Z", "MM-dd", "MM/dd/yyyy", "dd/MM/yyyy"], "default": "yyyy/MM/dd", "description": "Date format" } },
"additionalProperties": false,
"default": { "format": "yyyy/MM/dd" },
"description": "Date time style"
}
},
"required": ["type", "name"],
"additionalProperties": false,
"description": "Date time field",
"$schema": "http://json-schema.org/draft-07/schema#"
}
```
人员字段和群字段都支持 `multiple`
**Schema - created_at**
```json
{
"type": "object",
"properties": {
"type": { "type": "string", "const": "created_at", "description": "Created time type" },
"name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" },
"description": { "type": "string", "description": "Field description; supports plain text or Markdown links" },
"style": {
"type": "object",
"properties": { "format": { "type": "string", "enum": ["yyyy/MM/dd", "yyyy/MM/dd HH:mm", "yyyy/MM/dd HH:mm Z", "yyyy-MM-dd", "yyyy-MM-dd HH:mm", "yyyy-MM-dd HH:mm Z", "MM-dd", "MM/dd/yyyy", "dd/MM/yyyy"], "default": "yyyy/MM/dd", "description": "Date format" } },
"additionalProperties": false,
"default": { "format": "yyyy/MM/dd" },
"description": "Created time style"
}
},
"required": ["type", "name"],
"additionalProperties": false,
"description": "Created time field",
"$schema": "http://json-schema.org/draft-07/schema#"
}
```
**Schema - updated_at**
```json
{
"type": "object",
"properties": {
"type": { "type": "string", "const": "updated_at", "description": "Modified time type" },
"name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" },
"description": { "type": "string", "description": "Field description; supports plain text or Markdown links" },
"style": {
"type": "object",
"properties": { "format": { "type": "string", "enum": ["yyyy/MM/dd", "yyyy/MM/dd HH:mm", "yyyy/MM/dd HH:mm Z", "yyyy-MM-dd", "yyyy-MM-dd HH:mm", "yyyy-MM-dd HH:mm Z", "MM-dd", "MM/dd/yyyy", "dd/MM/yyyy"], "default": "yyyy/MM/dd", "description": "Date format" } },
"additionalProperties": false,
"default": { "format": "yyyy/MM/dd" },
"description": "Modified time style"
}
},
"required": ["type", "name"],
"additionalProperties": false,
"description": "Modified time field",
"$schema": "http://json-schema.org/draft-07/schema#"
}
```
### 2.5 user / created_by / updated_by
默认值 / 约束:
- `multiple` 默认 `true`
```json
{ "type": "user", "name": "负责人", "multiple": true }
```
```json
{ "type": "group_chat", "name": "负责群", "multiple": true }
```
### 3.7 created_by / updated_by
系统创建人 / 系统修改人字段;记录写入时应视为只读。
```json
{ "type": "created_by", "name": "创建人" }
```
@@ -340,48 +298,28 @@
{ "type": "updated_by", "name": "更新人" }
```
**Schema - user**
### 3.8 link
关联字段;`link_table` 必填。
支持字段:`link_table``bidirectional``bidirectional_link_field_name`
默认值 / 约束:
- `link_table` 必填
- `link` 字段的单元格表示“当前记录关联到的对侧表记录集合”
- `bidirectional` 默认 `false`
- `bidirectional=true` 时,会在被关联表自动创建一个反向关联字段。任一侧记录的关联关系发生变更时,另一侧对应记录会自动同步更新
- `bidirectional_link_field_name` 仅在 `bidirectional=true` 时使用
```json
{
"type": "object",
"properties": { "type": { "type": "string", "const": "user", "description": "User field type" }, "name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" }, "description": { "type": "string", "description": "Field description; supports plain text or Markdown links" }, "multiple": { "type": "boolean", "default": true, "description": "Allow multiple" } },
"required": ["type", "name"],
"additionalProperties": false,
"description": "User field",
"$schema": "http://json-schema.org/draft-07/schema#"
"type": "link",
"name": "关联任务",
"link_table": "任务表"
}
```
**Schema - created_by**
```json
{
"type": "object",
"properties": { "type": { "type": "string", "const": "created_by", "description": "Created by type" }, "name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" }, "description": { "type": "string", "description": "Field description; supports plain text or Markdown links" } },
"required": ["type", "name"],
"additionalProperties": false,
"description": "Created by field",
"$schema": "http://json-schema.org/draft-07/schema#"
}
```
**Schema - updated_by**
```json
{
"type": "object",
"properties": { "type": { "type": "string", "const": "updated_by", "description": "Modified by type" }, "name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" }, "description": { "type": "string", "description": "Field description; supports plain text or Markdown links" } },
"required": ["type", "name"],
"additionalProperties": false,
"description": "Modified by field",
"$schema": "http://json-schema.org/draft-07/schema#"
}
```
### 2.6 link
**要求**`link_table` 必填;`bidirectional` 默认 `false`
双向关联:
```json
{
@@ -393,29 +331,13 @@
}
```
**Schema**
更新时注意:
- `link` 不允许转换为其他类型,其他类型也不能转换为 `link`
- 现有 `link` 字段的 `bidirectional` 不能改。
```json
{
"type": "object",
"properties": {
"type": { "type": "string", "const": "link", "description": "Link field type" },
"name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" },
"description": { "type": "string", "description": "Field description; supports plain text or Markdown links" },
"link_table": { "type": "string", "minLength": 1, "maxLength": 100, "description": "Linked table" },
"bidirectional": { "type": "boolean", "default": false, "description": "Bidirectional link" },
"bidirectional_link_field_name": { "$ref": "#/properties/name", "description": "Bidirectional link field name" }
},
"required": ["type", "name", "link_table"],
"additionalProperties": false,
"description": "Link field",
"$schema": "http://json-schema.org/draft-07/schema#"
}
```
### 3.9 formula
### 2.7 formula
**要求**`expression` 必填。
公式字段;`expression` 必填。创建/更新前先读 [formula-field-guide.md](formula-field-guide.md) 学习公式语法。
```json
{
@@ -425,22 +347,19 @@
}
```
**Schema**
### 3.10 lookup
```json
{
"type": "object",
"properties": { "type": { "type": "string", "const": "formula", "description": "Formula field type" }, "name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" }, "description": { "type": "string", "description": "Field description; supports plain text or Markdown links" }, "expression": { "type": "string", "description": "Formula expression" } },
"required": ["type", "name", "expression"],
"additionalProperties": false,
"description": "Formula field",
"$schema": "http://json-schema.org/draft-07/schema#"
}
```
查找引用字段;`from``select``where` 必填,`aggregate` 可选。创建/更新前先读 [lookup-field-guide.md](lookup-field-guide.md)。
### 2.8 lookup
支持字段:`from``select``where``aggregate`
**要求**`from``select``where` 必填;`aggregate` 可选。`where.logic` 仅支持 `and/or``conditions` 每项必须是三元组 `[field, op, value]`
默认值 / 约束:
- `from``select``where` 必填
- `aggregate` 默认 `raw_value` 代表不进行聚合,直接返回 select 回的原始值
- `aggregate` 可用:`raw_value``sum``average``counta``unique_counta``max``min``unique`
- `where.logic` 默认 `and`,仅支持 `and` / `or`
- `where.conditions` 至少 1 条
- `conditions` 每项是三元组 `[field, op, value?]`
```json
{
@@ -459,87 +378,67 @@
}
```
**Schema**
### 3.11 auto_number
自动编号字段;不写 `style.rules` 时使用默认规则:`NO.001`
最小写法:
```json
{
"type": "object",
"properties": {
"type": { "type": "string", "const": "lookup", "description": "Lookup field type" },
"name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" },
"description": { "type": "string", "description": "Field description; supports plain text or Markdown links" },
"from": { "type": "string", "minLength": 1, "maxLength": 100, "description": "Source data table" },
"select": { "type": "string", "minLength": 1, "maxLength": 100, "description": "Field to aggregate from source table" },
"where": {
"type": "object",
"properties": {
"logic": { "type": "string", "enum": ["and", "or"], "default": "and", "description": "Filter Condition Logic" },
"conditions": {
"type": "array",
"items": {
"type": "array",
"minItems": 3,
"maxItems": 3,
"items": [
{ "type": "string", "minLength": 1, "maxLength": 100, "description": "Field from source table to filter on" },
{ "type": "string", "enum": ["==", "!=", ">", ">=", "<", "<=", "intersects", "disjoint", "empty", "non_empty"], "description": "Condition operator" },
{
"anyOf": [
{
"anyOf": [
{
"type": "object",
"properties": {
"type": { "type": "string", "const": "constant" },
"value": {
"anyOf": [
{ "type": "string", "description": "text & formula & location field support string as filter value" },
{ "type": "number", "description": "number & auto_number(the underfly incremental_number) field support number as filter value" },
{ "type": "array", "items": { "type": "string", "description": "option name" }, "description": "select field support one option: [\"option1\"] or multiple options: `[\"option1\", \"option2\"]` as filter value." },
{ "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string", "description": "record id" } }, "required": ["id"], "additionalProperties": false }, "description": "link field support record id list as filter value" },
{ "type": "string", "description": "\ndatetime & create_at & updated_at field support relative and absolute filter value.\nabsolute:\n- \"ExactDate(yyyy-MM-dd)\"\nrelative:\n- Today\n- Tomorrow\n- Yesterday\n" },
{ "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string", "description": "user id" } }, "required": ["id"], "additionalProperties": false }, "description": "user field support user id list as filter value" },
{ "type": "boolean", "description": "checkbox field support boolean as filter value" }
]
}
},
"required": ["type", "value"],
"additionalProperties": false,
"description": "Constant filter value"
},
{
"type": "object",
"properties": { "type": { "type": "string", "const": "field_ref" }, "field": { "type": "string", "minLength": 1, "maxLength": 100, "description": "Field id or name" } },
"required": ["type", "field"],
"additionalProperties": false,
"description": "Dynamic field reference from current table"
}
]
},
{ "type": "null" }
],
"description": "Condition value (null for isEmpty/isNotEmpty)"
}
],
"description": "Lookup condition tuple: [fieldRef, operator, value?]"
},
"minItems": 1,
"description": "Filter conditions"
}
},
"required": ["conditions"],
"additionalProperties": false
},
"aggregate": { "type": "string", "enum": ["raw_value", "sum", "average", "counta", "unique_counta", "max", "min", "unique"], "default": "raw_value", "description": "Aggregation function" }
},
"required": ["type", "name", "from", "select", "where"],
"additionalProperties": false,
"description": "Lookup field. You MUST Read xxx document first!",
"$schema": "http://json-schema.org/draft-07/schema#"
"type": "auto_number",
"name": "编号"
}
```
### 2.9 auto_number
支持字段:`style.rules`
默认值 / 约束:
- `style.rules` 是规则数组,数量 `1..9`
- 默认规则:
```json
{
"style": {
"rules": [
{ "type": "text", "text": "NO." },
{ "type": "incremental_number", "length": 3 }
]
}
}
```
#### `text`
支持字段:`text`
```json
{ "type": "text", "text": "TASK-" }
```
#### `incremental_number`
支持字段:`length`
默认值 / 约束:
- `length` 取值 `1..9`
```json
{ "type": "incremental_number", "length": 4 }
```
#### `created_time`
支持字段:`date_format`
默认值 / 约束:
- `date_format` 可用:`yyyyMMdd``yyyyMM``yyMM``MMdd``yyyy``MM``dd`
```json
{ "type": "created_time", "date_format": "yyyyMMdd" }
```
自定义规则:
```json
{
@@ -548,78 +447,14 @@
"style": {
"rules": [
{ "type": "text", "text": "TASK-" },
{ "type": "created_time", "date_format": "yyyyMMdd" },
{ "type": "incremental_number", "length": 4 }
]
}
}
```
**Schema**
```json
{
"type": "object",
"properties": {
"type": { "type": "string", "const": "auto_number", "description": "Auto number type" },
"name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" },
"description": { "type": "string", "description": "Field description; supports plain text or Markdown links" },
"style": {
"type": "object",
"properties": {
"rules": {
"type": "array",
"items": {
"anyOf": [
{
"type": "object",
"properties": { "type": { "type": "string", "const": "text", "description": "Text rule type" }, "text": { "type": "string", "description": "Prefix text" } },
"required": ["type", "text"],
"additionalProperties": false,
"description": "Auto number text rule"
},
{
"type": "object",
"properties": { "type": { "type": "string", "const": "incremental_number", "description": "Increment rule type" }, "length": { "type": "integer", "minimum": 1, "maximum": 9, "description": "Serial length" } },
"required": ["type", "length"],
"additionalProperties": false,
"description": "Auto number increment rule"
},
{
"type": "object",
"properties": {
"type": { "type": "string", "const": "created_time", "description": "Date rule type(auto fill record created date)" },
"date_format": { "type": "string", "enum": ["yyyyMMdd", "yyyyMM", "yyMM", "MMdd", "yyyy", "MM", "dd"], "description": "Date format" }
},
"required": ["type", "date_format"],
"additionalProperties": false,
"description": "Auto number date rule"
}
]
},
"minItems": 1,
"maxItems": 9,
"description": "Numbering rules"
}
},
"required": ["rules"],
"additionalProperties": false,
"default": {
"rules": [
{ "type": "text", "text": "NO." },
{ "type": "incremental_number", "length": 3 }
]
},
"description": "Auto number style"
}
},
"required": ["type", "name"],
"additionalProperties": false,
"description": "Auto number field",
"$schema": "http://json-schema.org/draft-07/schema#"
}
```
### 2.10 attachment / location / checkbox
### 3.12 attachment / location / checkbox
```json
{ "type": "attachment", "name": "附件" }
@@ -633,47 +468,14 @@
{ "type": "checkbox", "name": "完成" }
```
**Schema - attachment**
## 4. 创建与更新
```json
{
"type": "object",
"properties": { "type": { "type": "string", "const": "attachment", "description": "Attachment field type" }, "name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" }, "description": { "type": "string", "description": "Field description; supports plain text or Markdown links" } },
"required": ["type", "name"],
"additionalProperties": false,
"description": "Attachment field",
"$schema": "http://json-schema.org/draft-07/schema#"
}
```
- `+field-create`:按目标字段配置直接构造 `--json`
- `+field-update`:使用同样的 JSON 结构,但语义是 `PUT`;建议先 `+field-get`,再按目标完整状态提交。
**Schema - location**
## 5. 易错点
```json
{
"type": "object",
"properties": { "type": { "type": "string", "const": "location", "description": "Location field type" }, "name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" }, "description": { "type": "string", "description": "Field description; supports plain text or Markdown links" } },
"required": ["type", "name"],
"additionalProperties": false,
"description": "Location field",
"$schema": "http://json-schema.org/draft-07/schema#"
}
```
**Schema - checkbox**
```json
{
"type": "object",
"properties": { "type": { "type": "string", "const": "checkbox", "description": "Checkbox field type" }, "name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" }, "description": { "type": "string", "description": "Field description; supports plain text or Markdown links" } },
"required": ["type", "name"],
"additionalProperties": false,
"description": "Checkbox field",
"$schema": "http://json-schema.org/draft-07/schema#"
}
```
## 3. 推荐工作流
1. `+field-list` / `+field-get` 先拿当前字段结构。
2. 按本规范构造 `--json`
3. `type=formula/lookup` 时先读对应 guide再创建。
- `select` 只有一个类型;不要写 `single_select` / `multi_select`,用 `multiple` 控制是否多选。
- `number` 的精度、货币、进度、评分配置都放在 `style` 下,不要写顶层 `precision`
- `datetime` 是手动日期字段;系统时间请改用 `created_at` / `updated_at`
- `formula` / `lookup` 没读 guide 前不要直接写。

View File

@@ -2,82 +2,49 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
创建一个或多个视图。
创建一个视图。
## 推荐命令
## 1. 顶层规则
- --json 结构是 `{name, type?}`
- `name` 必填;同表内应唯一。
- `type` 可省略;省略时默认 `grid`
- 视图类型取值范围:`grid``kanban``gallery``calendar``gantt`
- `+view-create` 不负责排序、分组、筛选、时间轴、卡片封面、可见字段顺序;这些配置需要创建后再调用对应命令。
- 表单视图不走 `+view-create`;使用表单相关命令。
## 2. 推荐命令
```bash
lark-cli base +view-create \
--base-token app_xxx \
--table-id tbl_xxx \
--json '{"name":"进行中","type":"grid"}'
lark-cli base +view-create \
--base-token app_xxx \
--table-id tbl_xxx \
--json '[{"name":"进行中","type":"grid"},{"name":"日历","type":"calendar"}]'
--base-token <base_token> \
--table-id <table_id> \
--json '{"name":"进行中","type":"grid"}'
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--base-token <token>` | 是 | Base Token |
| `--table-id <id_or_name>` | 是 | 表 ID 或表名 |
| `--json <body>` | 是 | 视图 JSON 对象或数组 |
## API 入参详情
**HTTP 方法和路径:**
```
POST /open-apis/base/v3/bases/:base_token/tables/:table_id/views
```
## 返回重点
- 总是返回 `views` 数组;即使只创建了一个视图也一样。
## 关键规则
- `type` 仅支持 `grid` `kanban` `gallery` `calendar` `gantt`
- `/views` 不接受 `type=form`;表单创建走 `/forms`
- `name` 必填,长度 `1..100`,且同表内必须唯一。
## JSON 结构(`--json`
支持单对象或对象数组:
## 3. JSON 写法
```json
{ "name": "进行中", "type": "grid" }
```
```json
[
{ "name": "进行中", "type": "grid" },
{ "name": "日历", "type": "calendar" }
]
```
## JSON Schema原文
最小写法:
```json
{"type":"object","properties":{"type":{"type":"string","enum":["grid","kanban","gallery","gantt","calendar"],"default":"grid","description":"view type"},"name":{"type":"string","minLength":1,"maxLength":100,"description":"View name"}},"required":["name"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}
{ "name": "默认视图" }
```
## 工作流
## 4. 使用建议
- 需要设置可见字段顺序时,创建后继续调用 [lark-base-view-set-visible-fields.md](lark-base-view-set-visible-fields.md)。
- 需要设置筛选、分组、排序、时间轴、卡片封面时,创建后继续调用对应 `+view-set-*` 命令。
1. 多视图批量创建时,优先用数组一次提交,减少重复调用。
2. 如果用户要“查看视图字段顺序”或“查看可见字段”,使用 `+view-get-visible-fields` 读取当前 `visible_fields`
3. 如果用户同时要求“视图字段顺序”或“可见字段”,创建完成后必须继续调用 `+view-set-visible-fields` 设置 `visible_fields``+view-create` 本身不负责字段顺序/可见性配置。
## 5. 易错点
## 坑点
- 不要把 `form` 当成 `type` 传进来。
- 不要指望 `+view-create` 一次完成视图布局与属性配置。
- ⚠️ 这是写入操作,执行前必须确认。
## 6. 参考
## 参考
- [lark-base-view.md](lark-base-view.md) — view 索引页
- [lark-base-view.md](lark-base-view.md)
- [lark-base-view-set-visible-fields.md](lark-base-view-set-visible-fields.md)

View File

@@ -4,34 +4,24 @@
获取可见字段配置。
## 推荐命令
## 1. 顶层规则
- 读取当前视图的可见字段列表与顺序。
-`grid` / `kanban` / `gallery` / `calendar` / `gantt` 视图支持。
## 2. 推荐命令
```bash
lark-cli base +view-get-visible-fields \
--base-token XXXXXX \
--table-id tblXXX \
--view-id vewXXX
--base-token <base_token> \
--table-id <table_id> \
--view-id <view_id>
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--base-token <token>` | 是 | Base Token |
| `--table-id <id_or_name>` | 是 | 表 ID 或表名 |
| `--view-id <id_or_name>` | 是 | 视图 ID 或视图名 |
## API 入参详情
**HTTP 方法和路径:**
```
GET /open-apis/base/v3/bases/:base_token/tables/:table_id/views/:view_id/visible_fields
```
## 返回重点
## 3. 返回重点
- 返回当前视图可见字段列表。
- 返回结果中的主字段会位于第一位。
## 参考

View File

@@ -33,7 +33,7 @@ GET /open-apis/base/v3/bases/:base_token/tables/:table_id/views
## 返回重点
- 返回视图列表及 `offset / limit / count / total`
- 返回 `views``total`
## 坑点

View File

@@ -2,19 +2,28 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
更新卡片配置。
更新卡片封面配置。
## 推荐命令
## 1. 顶层规则
- `--json` 必须是 JSON 对象。
-`gallery` / `kanban` 视图支持。
- `cover_field` 必填;可传 `attachment` 类型的字段 id、字段名`null`
## 2. 推荐命令
设置封面:
```bash
lark-cli base +view-set-card \
--base-token app_xxx \
--table-id tbl_xxx \
--view-id viw_xxx \
--json {"cover_field":"fld_cover"}
--base-token <base_token> \
--table-id <table_id> \
--view-id <view_id> \
--json '{"cover_field":"fld_cover"}'
```
## JSON 结构
## 3. JSON 写法
```json
{
@@ -28,54 +37,19 @@ lark-cli base +view-set-card \
}
```
## 参数
## 4. 使用建议
| 参数 | 必填 | 说明 |
|------|------|------|
| `--base-token <token>` | 是 | Base Token |
| `--table-id <id_or_name>` | 是 | 表 ID 或表名 |
| `--view-id <id_or_name>` | 是 | 视图 ID 或视图名 |
| `--json <body>` | 是 | JSON 对象 |
- 建议先用 [lark-base-view-get-card.md](lark-base-view-get-card.md) 读取现状,再改。
- 优先传字段 id不要依赖字段名。
- 普通文本、数字、选择字段不能作为封面字段。
## API 入参详情
## 5. 易错点
**HTTP 方法和路径:**
- 不要传空字符串;清空时传 `null`
- 不要在 `grid` / `calendar` / `gantt` 视图上调用。
- 不要假设任意字段都能做封面;稳定做法是先找 `attachment` 字段。
```
PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/views/:view_id/card
```
## 6. 参考
## 返回重点
- 返回更新后的卡片配置。
## 结构规则
- `cover_field`:必填,字段 id 或字段名,长度 `1..100`;也可以显式传 `null`
-`null` 时,字段必须是封面支持字段,实际就是 `attachment` 字段
-`null` 表示清空封面配置
## JSON Schema原文
```json
{"type":"object","properties":{"cover_field":{"anyOf":[{"type":"string","minLength":1,"maxLength":100,"description":"Field id or name"},{"type":"null"}],"description":"cover field id or name. must be a attachment field"}},"required":["cover_field"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}
```
## 工作流
1. 建议先用 `+view-get-card` 拉现状,再修改。
## 坑点
- ⚠️ 这是写入操作,执行前必须确认。
- ⚠️ 只支持 `gallery` / `kanban`
- ⚠️ `cover_field` 不是任何字段都能填,普通文本/数字字段会直接报错。
- ⚠️ 清空封面必须传 `null`,不要传空字符串。
## 参考
- [lark-base-view.md](lark-base-view.md) — view 索引页
- [lark-base-view-get-card.md](lark-base-view-get-card.md) — 读取卡片
- [lark-base-view.md](lark-base-view.md)
- [lark-base-view-get-card.md](lark-base-view-get-card.md)

View File

@@ -2,91 +2,176 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
更新筛选配置。
更新视图筛选配置。
## 推荐命令
## 1. 顶层规则
- `--json` 必须是 JSON 对象。
- 顶层结构是 `{logic?, conditions?}`
- `logic` 默认 `and`;推荐只用 canonical 值 `and` / `or`
- `conditions` 默认空数组。
- 每条条件写成 tuple`[field, operator, value?]`
- `empty` / `non_empty` 可写成 2 项:`[field, "empty"]``[field, "non_empty"]`
- 支持 `filter` 的视图类型:`grid``kanban``gallery``calendar``gantt`
## 2. operator
可用 operator
- `==`
- `!=`
- `>`
- `>=`
- `<`
- `<=`
- `intersects`
- `disjoint`
- `empty`
- `non_empty`
## 3. value 写法
### `text` / `location`
用字符串:
```json
["标题", "intersects", "发布"]
```
### `number` / `auto_number`
用数字:
```json
["工时", ">=", 3.5]
```
### `select`
用选项名数组:
```json
["状态", "intersects", ["Doing", "Blocked"]]
```
### `user` / `created_by` / `updated_by`
用对象数组:
```json
["负责人", "intersects", [{ "id": "ou_xxx" }]]
```
### `group_chat`
用对象数组:
```json
["负责群", "intersects", [{ "id": "oc_xxx" }]]
```
### `link`
用记录 id 对象数组:
```json
["关联任务", "intersects", [{ "id": "rec_xxx" }]]
```
### `checkbox`
用布尔值:
```json
["完成", "==", true]
```
### `datetime` / `created_at` / `updated_at`
用相对时间关键字或 `ExactDate(...)`
```json
["截止时间", "==", "ExactDate(2026-01-01)"]
```
```json
["截止时间", "==", "ExactDate(2026-01-01 11:30)"]
```
```json
["截止时间", "==", "Today"]
```
可用关键字:
- `Today`
- `Yesterday`
- `Tomorrow`
### `formula` / `lookup`
- 筛选值类型由字段计算结果类型动态决定。
- 拿不准时,先把 `value` 当作单个字符串填入做一次尝试。
- 如果报错,再按错误提示把 `value` 改成对应类型。
字符串示例:
```json
["风险说明", "intersects", "高风险"]
```
数字示例:
```json
["汇总分", ">=", 80]
```
## 4. 推荐命令
```bash
lark-cli base +view-set-filter \
--base-token app_xxx \
--table-id tbl_xxx \
--view-id viw_xxx \
--json '{"logic":"and","conditions":[["fld_status","intersects",["Doing"]],["fld_owner","intersects",[{"id":"ou_xxx"}]],["fld_end","empty"]]}'
--base-token <base_token> \
--table-id <table_id> \
--view-id <view_id> \
--json '{"logic":"and","conditions":[["状态","intersects",["Doing"]],["负责人","intersects",[{"id":"ou_xxx"}]],["截止时间","empty"]]}'
```
## JSON 结构
## 5. JSON 写法
```json
{
"logic": "and",
"conditions": [
["fld_status", "intersects", ["Doing"]],
["fld_owner", "intersects", [{ "id": "ou_xxx" }]],
["fld_end", "empty"]
["状态", "intersects", ["Doing"]],
["负责人", "intersects", [{ "id": "ou_xxx" }]],
["截止时间", "empty"]
]
}
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--base-token <token>` | 是 | Base Token |
| `--table-id <id_or_name>` | 是 | 表 ID 或表名 |
| `--view-id <id_or_name>` | 是 | 视图 ID 或视图名 |
| `--json <body>` | 是 | JSON 对象 |
## API 入参详情
**HTTP 方法和路径:**
```
PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/views/:view_id/filter
```
## 返回重点
- 返回更新后的筛选配置。
## 结构规则
- `logic`:可选,`and` / `or`,默认 `and`
- `conditions`:数组,可为空;每项必须是 `[field, operator, value?]`
- `field`:字段 id 或字段名,长度 `1..100`
- `operator``== != > >= < <= intersects disjoint empty non_empty`
- `value`:按字段类型填写;`empty` / `non_empty` 可省略 `value`
### 典型 `value` 形状
- `text` / `location` / `formula`:字符串
- `number` / `auto_number`:数字
- `select``["Todo"]`
- `user` / `created_by` / `updated_by``[{ "id": "ou_xxx" }]`
- `link``[{ "id": "rec_xxx" }]`
- `checkbox``true` / `false`
- `datetime` / `created_at` / `updated_at``"ExactDate(YYYY-MM-DD)"``"Today"``"Tomorrow"``"Yesterday"`
## JSON Schema原文
清空写法:
```json
{"type":"object","properties":{"logic":{"type":"string","enum":["and","or"],"default":"and","description":"Filter Condition Logic"},"conditions":{"type":"array","items":{"type":"array","minItems":3,"maxItems":3,"items":[{"type":"string","minLength":1,"maxLength":100,"description":"Field id or name"},{"type":"string","enum":["==","!=",">",">=","<","<=","intersects","disjoint","empty","non_empty"],"description":"Condition operator"},{"anyOf":[{"not":{}},{"anyOf":[{"anyOf":[{"type":"string","description":"text & formula & location field support string as filter value"},{"type":"number","description":"number & auto_number(the underfly incremental_number) field support number as filter value"},{"type":"array","items":{"type":"string","description":"option name"},"description":"select field support one option: [\"option1\"] or multiple options: `[\"option1\", \"option2\"]` as filter value."},{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"record id"}},"required":["id"],"additionalProperties":false},"description":"link field support record id list as filter value"},{"type":"string","description":"\ndatetime & create_at & updated_at field support relative and absolute filter value.\nabsolute:\n- \"ExactDate(yyyy-MM-dd)\"\nrelative:\n- Today\n- Tomorrow\n- Yesterday\n"},{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"user id"}},"required":["id"],"additionalProperties":false},"description":"user field support user id list as filter value"},{"type":"boolean","description":"checkbox field support boolean as filter value"}]},{"type":"null"}]}]}],"description":"one condition expression. shape: [field_id, filter_operator, value]. when operator is \"empty\" or \"non_empty\", the value is not required."},"default":[]}},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}
{
"conditions": []
}
```
## 工作流
## 6. 使用建议
- 建议先用 [lark-base-view-get-filter.md](lark-base-view-get-filter.md) 读取现状,再改。
- 优先传字段 id不要依赖字段名。
- 需要清空全部筛选时,直接传 `{"conditions":[]}`
1. 建议先用 `+view-get-filter` 拉现状,再做最小化修改。
## 7. 易错点
## 坑点
- 不要再写旧对象风格:`{"field_name":...,"operator":...}`
- `user` / `group_chat` / `link` 不要写成单个标量。
- `empty` / `non_empty` 不要硬塞无意义的 value。
- 日期条件稳定写法用 `ExactDate(...)``Today` / `Yesterday` / `Tomorrow`
- `formula` / `lookup` 的 value 形状不固定;拿不准时先读当前 filter 或字段定义,或根据错误提示修正类型。
- ⚠️ 这是写入操作,执行前必须确认。
- ⚠️ 条件必须用 tuple不要再写旧的 `{"field_name":...,"operator":...}` 对象风格。
- ⚠️ `empty` / `non_empty` 不要硬塞 value`select` / `user` / `link` 也不要直接写单值。
- ⚠️ 日期值要保留 `ExactDate(...)` 外壳,不要直接写裸日期字符串。
## 8. 参考
## 参考
- [lark-base-view.md](lark-base-view.md) — view 索引页
- [lark-base-view-get-filter.md](lark-base-view-get-filter.md) — 读取筛选
- [lark-base-view.md](lark-base-view.md)
- [lark-base-view-get-filter.md](lark-base-view-get-filter.md)
- [lookup-field-guide.md](lookup-field-guide.md)

View File

@@ -2,76 +2,64 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
更新分组配置。
更新视图分组配置。
## 推荐命令
## 1. 顶层规则
- `--json` 必须是 JSON 对象。
- 顶层写法固定为 `{"group_config":[...]}`
- 每项写 `{ "field": "<field_id_or_name>", "desc": false }`
- `desc` 可省略;省略时等价于 `false`
-`grid` / `kanban` / `gantt` 视图支持。
- `group_config` 传空数组 `[]` 表示清空分组。
- 分组字段必须是当前视图可分组的字段;字段存在也不代表一定可分组。
## 2. 推荐命令
设置分组:
```bash
lark-cli base +view-set-group \
--base-token app_xxx \
--table-id tbl_xxx \
--view-id viw_xxx \
--json '{"group_config":[{"field":"fldStatus","desc":false}]}'
--base-token <base_token> \
--table-id <table_id> \
--view-id <view_id> \
--json '{"group_config":[{"field":"fld_status","desc":false}]}'
```
## JSON 结构
清空分组:
```bash
lark-cli base +view-set-group \
--base-token <base_token> \
--table-id <table_id> \
--view-id <view_id> \
--json '{"group_config":[]}'
```
## 3. JSON 写法
```json
{
"group_config": [
{ "field": "fldStatus", "desc": false }
{ "field": "fld_status", "desc": false }
]
}
```
## 参数
## 4. 使用建议
| 参数 | 必填 | 说明 |
|------|------|------|
| `--base-token <token>` | 是 | Base Token |
| `--table-id <id_or_name>` | 是 | 表 ID 或表名 |
| `--view-id <id_or_name>` | 是 | 视图 ID 或视图名 |
| `--json <body>` | 是 | JSON 对象 |
- 优先传字段 id不要依赖字段名。
- 建议先用 [lark-base-view-get-group.md](lark-base-view-get-group.md) 读取现状。
- 只传对象;不要传 `[]``[{"field":"..."}]` 这类裸数组。
- 提交项数不要超过 3如果当前视图实际允许更少以错误提示为准收敛。
## API 入参详情
## 5. 易错点
**HTTP 方法和路径:**
- 不要把 `group_config` 写成对象。
- 不要拿当前视图不支持分组的字段去分组。
- 不要在 `gallery` / `calendar` 视图上调用。
```
PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/views/:view_id/group
```
## 6. 参考
## 返回重点
- 返回更新后的分组配置。
## 结构规则
- `group_config`:数组,长度 `0..3`
- 每项:
- `field`:字段 id 或字段名,长度 `1..100`
- `desc`:可选,默认 `false`
## JSON Schema原文
```json
{"type":"object","properties":{"group_config":{"type":"array","items":{"type":"object","properties":{"field":{"type":"string","minLength":1,"maxLength":100,"description":"Field id or name"},"desc":{"type":"boolean","default":false,"description":"define how to sort group headers"}},"required":["field"],"additionalProperties":false},"minItems":0,"maxItems":3}},"required":["group_config"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}
```
## 工作流
1. 优先用字段 id避免同名字段和后续改名影响。
## 坑点
- ⚠️ 这是写入操作,执行前必须确认。
- ⚠️ 分组只支持 `grid` / `kanban` / `gantt`
- ⚠️ `group_config` 最多 3 项,超出会直接失败。
## 参考
- [lark-base-view.md](lark-base-view.md) — view 索引页
- [lark-base-view-get-group.md](lark-base-view-get-group.md) — 读取分组
- [lark-base-view.md](lark-base-view.md)
- [lark-base-view-get-group.md](lark-base-view-get-group.md)

View File

@@ -2,76 +2,62 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
更新排序配置。
更新视图排序配置。
## 推荐命令
## 1. 顶层规则
- `--json` 必须是 JSON 对象。
- 顶层写法固定为 `{"sort_config":[...]}`
- `sort_config` 最多 10 项。
- 每项写 `{ "field": "<field_id_or_name>", "desc": false }`
- `desc` 可省略;省略时等价于 `false`
-`grid` / `kanban` / `gallery` / `gantt` 视图支持。
## 2. 推荐命令
设置排序:
```bash
lark-cli base +view-set-sort \
--base-token app_xxx \
--table-id tbl_xxx \
--view-id viw_xxx \
--json [{"field":"fld_priority","desc":true}]
--base-token <base_token> \
--table-id <table_id> \
--view-id <view_id> \
--json '{"sort_config":[{"field":"fld_priority","desc":true},{"field":"fld_created_at","desc":false}]}'
```
## JSON 结构
清空排序:
```bash
lark-cli base +view-set-sort \
--base-token <base_token> \
--table-id <table_id> \
--view-id <view_id> \
--json '{"sort_config":[]}'
```
## 3. JSON 写法
```json
[
{ "field": "fld_priority", "desc": true }
]
{
"sort_config": [
{ "field": "fld_priority", "desc": true }
]
}
```
## 参数
## 4. 使用建议
| 参数 | 必填 | 说明 |
|------|------|------|
| `--base-token <token>` | 是 | Base Token |
| `--table-id <id_or_name>` | 是 | 表 ID 或表名 |
| `--view-id <id_or_name>` | 是 | 视图 ID 或视图名 |
| `--json <body>` | 是 | JSON 对象或数组 |
- 优先传字段 id不要依赖字段名。
- 如需覆盖已有排序,建议先用 [lark-base-view-get-sort.md](lark-base-view-get-sort.md) 读取现状。
- 只传对象;不要传 `[]``[{"field":"..."}]` 这类裸数组。
## API 入参详情
## 5. 易错点
**HTTP 方法和路径:**
- 不要把 `sort_config` 写成对象。
- 不要超过 10 项。
- 不要在 `calendar` 这类不支持排序配置的视图上调用。
```
PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/views/:view_id/sort
```
## 6. 参考
## 返回重点
- 返回更新后的排序配置。
## 结构规则
- `sort_config`:数组,长度 `0..10`
- 每项:
- `field`:字段 id 或字段名,长度 `1..100`
- `desc`:可选,默认 `false`
- `--json` 既可传对象 `{"sort_config":[...]}`,也可直接传数组 `[...]`
- 直接传数组时CLI 会自动包装成 `sort_config`
## JSON Schema原文
```json
{"type":"array","items":{"type":"object","properties":{"field":{"type":"string","minLength":1,"maxLength":100,"description":"Field id or name"},"desc":{"type":"boolean","default":false,"description":"define how to sort records"}},"required":["field"],"additionalProperties":false},"minItems":0,"maxItems":10,"$schema":"http://json-schema.org/draft-07/schema#"}
```
## 工作流
1. 优先用字段 id避免同名字段和后续改名影响。
## 坑点
- ⚠️ 这是写入操作,执行前必须确认。
- ⚠️ 排序只支持 `grid` / `kanban` / `gallery` / `gantt`
- ⚠️ `sort_config` 最多 10 项,超出会直接失败。
## 参考
- [lark-base-view.md](lark-base-view.md) — view 索引页
- [lark-base-view-get-sort.md](lark-base-view-get-sort.md) — 读取排序
- [lark-base-view.md](lark-base-view.md)
- [lark-base-view-get-sort.md](lark-base-view-get-sort.md)

View File

@@ -4,17 +4,25 @@
更新时间轴配置。
## 推荐命令
## 1. 顶层规则
- `--json` 必须是 JSON 对象。
- 顶层固定写 `start_time``end_time``title` 三个字段,三者都必填。
- `start_time` / `end_time` 必须是当前时间轴支持的日期字段。
- `title` 必须是当前表中已存在的字段。
-`calendar` / `gantt` 视图支持。
## 2. 推荐命令
```bash
lark-cli base +view-set-timebar \
--base-token app_xxx \
--table-id tbl_xxx \
--view-id viw_xxx \
--json {"start_time":"fld_start","end_time":"fld_end","title":"fld_title"}
--base-token <base_token> \
--table-id <table_id> \
--view-id <view_id> \
--json '{"start_time":"fld_start","end_time":"fld_end","title":"fld_title"}'
```
## JSON 结构
## 3. JSON 写法
```json
{
@@ -24,56 +32,20 @@ lark-cli base +view-set-timebar \
}
```
## 参数
## 4. 使用建议
| 参数 | 必填 | 说明 |
|------|------|------|
| `--base-token <token>` | 是 | Base Token |
| `--table-id <id_or_name>` | 是 | 表 ID 或表名 |
| `--view-id <id_or_name>` | 是 | 视图 ID 或视图名 |
| `--json <body>` | 是 | JSON 对象 |
- 优先传字段 id不要依赖字段名。
- `start_time` / `end_time` 稳定做法优先使用日期时间字段。
- `title` 通常传主字段或文本标题字段。
- 建议先用 [lark-base-view-get-timebar.md](lark-base-view-get-timebar.md) 读取现状。
## API 入参详情
## 5. 易错点
**HTTP 方法和路径:**
- 不要把普通文本、选项、链接字段写到 `start_time` / `end_time`
- 不要漏传 `title`
- 不要在 `grid` / `gallery` / `kanban` 视图上调用。
```
PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/views/:view_id/timebar
```
## 6. 参考
## 返回重点
- 返回更新后的时间轴配置。
## 结构规则
- `start_time`:必填,字段 id 或字段名,长度 `1..100`
- `end_time`:必填,字段 id 或字段名,长度 `1..100`
- `title`:必填,字段 id 或字段名,长度 `1..100`
- `start_time` / `end_time` 必须是时间条支持的日期类字段;当前以 `datetime` / `created_at` 这类字段为准,优先传字段 id
- `title` 通常传主字段,用来显示条目标题
## JSON Schema原文
```json
{"type":"object","properties":{"start_time":{"type":"string","minLength":1,"maxLength":100,"description":"start time field id or name (must be a datetime/created_at field)"},"end_time":{"type":"string","minLength":1,"maxLength":100,"description":"end time field id or name (must be a datetime/created_at field)"},"title":{"type":"string","minLength":1,"maxLength":100,"description":"title datasource field id or name"}},"required":["start_time","end_time","title"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}
```
## 工作流
1. 建议先用 `+view-get-timebar` 拉现状,再修改。
## 坑点
- ⚠️ 这是写入操作,执行前必须确认。
- ⚠️ 只支持 `calendar` / `gantt`
- ⚠️ `start_time``end_time` 不能填普通文本、选项或链接字段。
- ⚠️ 字段不存在或类型不对会直接失败,不要靠猜。
## 参考
- [lark-base-view.md](lark-base-view.md) — view 索引页
- [lark-base-view-get-timebar.md](lark-base-view-get-timebar.md) — 读取时间轴
- [lark-base-view.md](lark-base-view.md)
- [lark-base-view-get-timebar.md](lark-base-view-get-timebar.md)

View File

@@ -4,69 +4,42 @@
更新视图可见字段列表(同时控制视图中的字段顺序)。
## 推荐命令
## 1. 顶层规则
- `--json` 必须是 JSON 对象。
- 顶层固定写法:`{"visible_fields":[...]}`
- `visible_fields` 每项可传字段 id 或字段名。
-`grid` / `kanban` / `gallery` / `calendar` / `gantt` 视图支持。
## 2. 推荐命令
```bash
lark-cli base +view-set-visible-fields \
--base-token XXXXXX \
--table-id tblXXX \
--view-id vewXXX \
--json '{"visible_fields":["标题","fldXXX"]}'
--base-token <base_token> \
--table-id <table_id> \
--view-id <view_id> \
--json '{"visible_fields":["标题","fld_status"]}'
```
## JSON 结构
## 3. JSON 写法
```json
{
"visible_fields": ["标题", "fldXXX"]
"visible_fields": ["标题", "fld_status"]
}
```
## 参数
## 4. 使用建议
| 参数 | 必填 | 说明 |
|------|------|------|
| `--base-token <token>` | 是 | Base Token |
| `--table-id <id_or_name>` | 是 | 表 ID 或表名 |
| `--view-id <id_or_name>` | 是 | 视图 ID 或视图名 |
| `--json <body>` | 是 | JSON 对象,且必须包含 `visible_fields` |
- 优先传字段 id不要依赖字段名。
- 数组顺序用于控制视图字段顺序。
- 如果未包含主字段 `primaryField`,结果中会自动补到第一位。
## API 入参详情
## 5. 易错点
**HTTP 方法和路径:**
```
PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/views/:view_id/visible_fields
```
**接口 body 格式:**
```json
{
"visible_fields": ["标题", "fldXXX"]
}
```
## 返回重点
- 返回可见字段列表与顺序(`primaryField` 会被强制置顶)。
## 结构规则
- `visible_fields`:字符串数组,每项可传字段 id 或字段名
- 数组顺序用于控制视图字段顺序;主字段 `primaryField` 必须存在且位于第一位,否则 API 会强制将其提升到第一位
- `--json` 必须传对象:`{ "visible_fields": [...] }`
## 工作流
1. 用户要求“改字段顺序”或“设置可见字段”时,直接使用本命令。
2. 建议优先使用字段 id避免字段重名或后续改名带来的歧义。
## 坑点
- ⚠️ 这是写入操作,执行前必须确认。
- ⚠️ 接口最终结果会受后端 `primaryField` 强制显示规则影响,返回顺序可能与传入数组不同。
- ⚠️ 如果传字段名,必须与当前表真实字段名精确匹配。
- 不要传裸数组:`["fld_a","fld_b"]`;必须包成 `{"visible_fields":[...]}`
- 如果传字段名,必须与当前表真实字段名精确匹配。
- 最终返回顺序可能与传入顺序不同,因为主字段会被强制置顶。
## 参考

View File

@@ -32,11 +32,11 @@ view 相关命令索引。
| 视图类型 | 可用能力 |
|------|------|
| `grid` | `group` `sort` `filter` |
| `kanban` | `group` `sort` `filter` `card` |
| `gallery` | `sort` `filter` `card` |
| `calendar` | `filter` `timebar` |
| `gantt` | `group` `sort` `filter` `timebar` |
| `grid` | `group` `sort` `filter` `visible_fields` |
| `kanban` | `group` `sort` `filter` `card` `visible_fields` |
| `gallery` | `sort` `filter` `card` `visible_fields` |
| `calendar` | `filter` `timebar` `visible_fields` |
| `gantt` | `group` `sort` `filter` `timebar` `visible_fields` |
## 说明

View File

@@ -158,7 +158,7 @@ POST /open-apis/base/v3/bases/:base_token/workflows
- ⚠️ **新建后默认禁用**`status` 固定返回 `disabled`,需要额外调用 `+workflow-enable` 才能让工作流生效;不要误报"创建成功即启用"
- ⚠️ **steps 中 id 字段必须唯一**:每个步骤的 `id` 由调用方指定,且在工作流内必须唯一;`next``children.links[].to` 引用的 ID 必须在同一 steps 数组中存在,否则服务端返回 `[2200] Internal Error`
- ⚠️ **字段类型校验**:设置字段值时,`value_type` 必须与字段实际类型匹配:
- **select 类型字段**单选/多选/流程):必须用 `option`,不能用 `text`
- **`select` 类型字段**`multiple=false/true`):必须用 `option`,不能用 `text`
```json
// ✅ 正确
{ "field_name": "大区", "value": [{"value_type": "option", "value": {"name": "华东"}}] }

View File

@@ -119,5 +119,6 @@ POST /open-apis/base/v3/bases/:base_token/workflows/list
## 参考
- [lark-base](../SKILL.md) — 多维表格全部命令
- [lark-base-workflow-enable-disable](lark-base-workflow-enable-disable.md) — 启用/禁用工作流
- [lark-base-workflow-enable](lark-base-workflow-enable.md) — 启用工作流
- [lark-base-workflow-disable](lark-base-workflow-disable.md) — 禁用工作流
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -248,7 +248,7 @@
| 字段 | 必填 | 说明 |
|------|------|------|
| `table_name` | 是 | 数据表名 |
| `field_name` | 是 | 日期字段名(必须为 DateTime / CreatedTime / Formula / Lookup 类型) |
| `field_name` | 是 | 日期字段名(必须为 `datetime` / `created_at` / `formula` / `lookup` 类型) |
| `unit` | 是 | 偏移单位:`MINUTE` / `HOUR` / `DAY` / `WEEK` / `MONTH` |
| `offset` | 是 | 提前/延后的偏移量(正数=提前,负数=延后;范围由 `unit` 决定):`MINUTE` ∈ {0, 5, 15, 30, -5, -15, -30}`HOUR` ∈ [-6, -1] [1, 6]`DAY` ∈ [-7, 7]`WEEK` ∈ [-7, -1] [1, 7]`MONTH` ∈ [-7, -1] [1, 7] |
| `hour` | 是 | 触发小时 (0-23),默认 9 |
@@ -708,27 +708,27 @@ $.{stepId}.{pathId}.{childPathId}.{grandChildPathId}
|----------|---------|-------------|--------------|------|
| **所有字段(基础)** | 字段 ID | `fieldId` | `string` | 字段的唯一标识 |
| | 字段名称 | `fieldName` | `string` | 字段的显示名称 |
| **人员字段**User / CreatedUser / ModifiedUser | 姓名 | `name` | `string` | 用户姓名 |
| **日期字段**DateTime / CreatedTime / ModifiedTime | 时间戳 | `timestamp` | `number` | 时间戳数值 |
| **附件字段**Attachment | 文件名 | `fileName` | `string` | 附件文件名 |
| **人员字段**`user` / `created_by` / `updated_by` | 姓名 | `name` | `string` | 用户姓名 |
| **日期字段**`datetime` / `created_at` / `updated_at` | 时间戳 | `timestamp` | `number` | 时间戳数值 |
| **附件字段**`attachment` | 文件名 | `fileName` | `string` | 附件文件名 |
| | 文件类型 | `fileType` | `string` | MIME 类型 |
| | 文件大小 | `size` | `number` | 文件字节数 |
| | 文件 Token | `fileToken` | `string` | 附件 token |
| **超链接字段**URL | 文本 | `text` | `string` | 链接文本部分 |
| **超链接文本字段**`text``style.type=url` | 文本 | `text` | `string` | 链接文本部分 |
| | 链接 | `link` | `string` | 链接 URL 部分 |
| **自动编号字段**AutoNumber | 序号 | `sequence` | `number` | 编号的纯数字序号 |
| **关联字段**SingleLink / DuplexLink | 字段下钻 | `{fieldId}` | - | 可下钻到关联表的字段 |
| **自动编号字段**`auto_number` | 序号 | `sequence` | `number` | 编号的纯数字序号 |
| **关联字段**`link` | 字段下钻 | `{fieldId}` | - | 可下钻到关联表的字段 |
> 其他字段类型(如文本、数字、复选框、单选/多选、电话、地理位置、进度、公式、引用查找等)仅支持 `fieldId` 和 `fieldName` 两个基础属性。
> 其他字段类型(如 `text`、`number`、`checkbox`、`select`、`location`、`formula`、`lookup` 等)仅支持 `fieldId` 和 `fieldName` 两个基础属性。
下钻引用示例:
```
$.{stepId}.{fieldId} → 字段值本身
$.{stepId}.{fieldId}.fieldId → 字段 IDstring
$.{stepId}.{fieldId} → 字段值本身
$.{stepId}.{fieldId}.fieldId → 字段 IDstring
$.{stepId}.{fieldId}.fieldName → 字段名称string
$.{stepId}.{fieldId}.name → 人员姓名列表array<string>,仅人员字段)
$.{stepId}.{fieldId}.unionId → 人员 unionId 列表array<string>,仅人员字段)
$.{stepId}.{fieldId}.name → 人员姓名列表array<string>,仅人员字段)
$.{stepId}.{fieldId}.unionId → 人员 unionId 列表array<string>,仅人员字段)
$.{stepId}.{fieldId}.timestamp → 时间戳array<number>,仅日期字段)
$.{stepId}.{fieldId}.fileName → 文件名列表array<string>,仅附件字段)
$.{stepId}.{fieldId}.fileToken → 文件 Token 列表array<string>,仅附件字段)
@@ -820,7 +820,7 @@ $.{stepId}.{fieldId}.fileToken → 文件 Token 列表array<string>,仅
}
```
### Select / MultiSelect 字段多值匹配
### `select` 字段多值匹配
| 操作 | operator | 正确写法 |
|------|---------|---------|

View File

@@ -151,21 +151,22 @@ The `value` inside `{ "type": "constant", "value": ... }` varies by field type:
| Field type | Constant value format | Example |
|-----------|----------------------|---------|
| Text / Phone / Email / Url | String | `"已完成"` |
| Number / Currency / Progress / Rating | Number | `100`, `0.8` |
| DateTime / CreatedTime / ModifiedTime | Duration tuple | `["ExactDate", "2025-01-01"]`, `["Today"]`, `["Yesterday"]`, `["Tomorrow"]` |
| SingleSelect / MultiSelect | Option ID or ID array | `"opt_xxx"`, `["opt_xxx", "opt_yyy"]` |
| Link (SingleLink / DuplexLink) | Record ID or ID array | `"rec_xxx"`, `["rec_xxx", "rec_yyy"]` |
| User | User ID or ID array | `"123"`, `["123", "456"]` |
| Checkbox | Boolean | `true`, `false` |
| Attachment / Location | Only `empty` / `non_empty` | value must be `null` or omitted |
| AutoNumber | Not supported for constant comparison | Use dynamic field\_ref instead |
| Formula / Lookup (exact type) | Follow the underlying type rules | — |
| Formula / Lookup (fuzzy type) | String | `"some text"` |
| `text` | String | `"已完成"` |
| `number` | Number | `100`, `0.8` |
| `datetime` / `created_at` / `updated_at` | String | `"ExactDate(2025-01-01)"`, `"ExactDate(2025-01-01 09:30)"`, `"Today"`, `"Yesterday"`, `"Tomorrow"` |
| `select` (`multiple=false/true`) | Option name array | `["Todo"]`, `["Todo", "Done"]` |
| `link` | Record reference array | `[{ "id": "rec_xxx" }]`, `[{ "id": "rec_xxx" }, { "id": "rec_yyy" }]` |
| `user` / `created_by` / `updated_by` | User reference array | `[{ "id": "ou_xxx" }]`, `[{ "id": "ou_xxx" }, { "id": "ou_yyy" }]` |
| `checkbox` | Boolean | `true`, `false` |
| `attachment` / `location` | Only `empty` / `non_empty` | value must be `null` or omitted |
| `auto_number` | Not supported for constant comparison | Use dynamic field\_ref instead |
| `formula` / `lookup` (exact type) | Follow the underlying type rules | — |
| `formula` / `lookup` (fuzzy type) | String | `"some text"` |
**DateTime notes**:
- Only `ExactDate`, `Today`, `Yesterday`, `Tomorrow` are supported as duration formats
- `["ExactDate", "2025-01-01"]` means the exact moment `2025-01-01 00:00:00`, NOT the entire day
**`datetime` notes**:
- Supported datetime constant values are `ExactDate(...)`, `Today`, `Yesterday`, `Tomorrow`
- Date-only fields use `ExactDate(YYYY-MM-DD)`
- Fields that include time use `ExactDate(YYYY-MM-DD HH:mm)`
- For complex or relative date filtering, consider using a Formula field instead
### Dynamic field reference — set comparison semantics
@@ -179,12 +180,12 @@ When using `{ "type": "field_ref", "field": "..." }`, values from both sides are
| Field type | Converted to |
|-----------|-------------|
| Text / Phone / Email / Url | Single-element string set |
| Number / Currency / AutoNumber / DateTime | Single-element number set |
| SingleSelect / MultiSelect | Set of option name strings |
| User | Set of user name strings |
| Link (SingleLink / DuplexLink) | Set of linked records' primary field string representations |
| Formula / Lookup | The computed value set |
| `text` | Single-element string set |
| `number` / `auto_number` / `datetime` | Single-element number set |
| `select` (`multiple=false/true`) | Set of option name strings |
| `user` / `created_by` / `updated_by` | Set of user name strings |
| `link` | Set of linked records' primary field string representations |
| `formula` / `lookup` | The computed value set |
**Examples**:
- User field `["name1", "name2"]` **intersects** text `"name1"` → true; **==** text `"name1"` → false (sets not equal)
@@ -193,14 +194,14 @@ When using `{ "type": "field_ref", "field": "..." }`, values from both sides are
### Supported operators
| Operator | Meaning | Applicable types |
| Operator | Meaning | Applicable field types |
|----------|---------|-----------------|
| `==` | Equal (exact match) | All types |
| `!=` | Not equal | All types |
| `>` | Greater than | Number, DateTime |
| `>=` | Greater than or equal | Number, DateTime |
| `<` | Less than | Number, DateTime |
| `<=` | Less than or equal | Number, DateTime |
| `>` | Greater than | `number`, `datetime` |
| `>=` | Greater than or equal | `number`, `datetime` |
| `<` | Less than | `number`, `datetime` |
| `<=` | Less than or equal | `number`, `datetime` |
| `intersects` | Has intersection (non-empty overlap) | All types (most commonly used for dynamic field\_ref) |
| `disjoint` | No intersection | All types |
| `empty` | Field is empty | All types (value must be null or omitted) |
@@ -217,10 +218,10 @@ When using `{ "type": "field_ref", "field": "..." }`, values from both sides are
| Aggregate | Common user phrasing | Select field should be | Result type |
|-----------|---------------------|----------------------|-------------|
| `sum` | "total" / "sum" / "cumulative amount" | Numeric field (e.g., amount) | Number |
| `average` | "average" / "mean" | Numeric field | Number |
| `max` | "maximum" / "latest" / "most recent" | Numeric / DateTime field | Same as source |
| `min` | "minimum" / "earliest" | Numeric / DateTime field | Same as source |
| `sum` | "total" / "sum" / "cumulative amount" | `number` field (e.g., amount) | Number |
| `average` | "average" / "mean" | `number` field | Number |
| `max` | "maximum" / "latest" / "most recent" | `number` / `datetime` field | Same as source |
| `min` | "minimum" / "earliest" | `number` / `datetime` field | Same as source |
| `counta` | "count" / "how many" / "total number" | Any field | Number |
| `unique_counta` | "count distinct" / "how many different" | Field to deduplicate | Number |
| `unique` | "list distinct" / "which ones" / "show different" | Field to display | List |
@@ -287,8 +288,8 @@ How to handle multiple matching records?
When the source table has a Link pointing to the current table:
```
Exhibition table: ExhibitionName (primaryField) ← current table
Artwork table: ArtworkName (primaryField), ← source table (Link is here)
Exhibition table: ExhibitionName (primaryField) ← current table
Artwork table: ArtworkName (primaryField), ← source table (Link is here)
Exhibition (Link → Exhibition table)
```
@@ -315,8 +316,8 @@ Artwork table: ArtworkName (primaryField), ← source table (Link
When the current table has a Link pointing to the source table:
```
Supplier table: SupplierName (primaryField), Contact (Text) ← source table
Inventory table: ProductName (primaryField), ← current table (Link is here)
Supplier table: SupplierName (primaryField), Contact (Text) ← source table
Inventory table: ProductName (primaryField), ← current table (Link is here)
Supplier (Link → Supplier table)
```
@@ -340,8 +341,8 @@ Inventory table: ProductName (primaryField), ← current
**Scenario**: "Sum order amounts per project" (tables share a "ProjectName" field but no Link)
```
Project table: ProjectName (primaryField) ← current table
Order table: OrderID (primaryField), ProjectName (Text), ← source table
Project table: ProjectName (primaryField) ← current table
Order table: OrderID (primaryField), ProjectName (Text), ← source table
Amount (Number)
```
@@ -399,7 +400,7 @@ Combine row-level matching with fixed-value filtering using `logic: "and"`:
"logic": "and",
"conditions": [
["ProjectName", "==", { "type": "field_ref", "field": "ProjectName" }],
["CreatedDate", ">=", { "type": "constant", "value": ["ExactDate", "2025-01-01"] }]
["CreatedDate", ">=", { "type": "constant", "value": "ExactDate(2025-01-01)" }]
]
}
}
@@ -504,5 +505,6 @@ The user says "aggregate order amounts" — use Lookup, not Link. Link establish
- Aggregate values are snake_case lowercase: `sum`, `counta`, `unique_counta` (NOT `count`)
- Operators: `==`, `!=`, `>`, `>=`, `<`, `<=`, `intersects`, `disjoint`, `empty`, `non_empty`
- Table and field names must exactly match `+table-get` output
- DateTime constant values use duration tuple format: `["ExactDate", "2025-01-01"]`, `["Today"]`, `["Yesterday"]`, `["Tomorrow"]`
- Select/Link/User constant values use IDs, not display names
- `datetime` constant values use string format: `ExactDate(YYYY-MM-DD)` / `ExactDate(YYYY-MM-DD HH:mm)` / `Today` / `Yesterday` / `Tomorrow`
- `select` constant values use option names;
- `link` / `user` constant values use `{id}` object arrays

View File

@@ -285,9 +285,9 @@
**⚠️ field_perms 重要规则**:
1. 写入前必须先查看字段的 `type`
2. Formula / Lookup / AutoNumber 类型字段**必须强制**降级为 `read``no_perm`**严禁**设为 `edit`
2. `formula` / `lookup` / `auto_number` 类型字段**必须强制**降级为 `read``no_perm`**严禁**设为 `edit`
3. 必须输出除 4 个系统字段外的所有字段
4. `allow_edit_and_modify_option_fields`:仅当用户明确要求"允许增删改选项"时才配置,否则必须为空数组 `[]`。仅支持 SingleSelect / MultiSelect 类型,**严禁包含 Stage流程类型字段**
4. `allow_edit_and_modify_option_fields`:仅当用户明确要求"允许增删改选项"时才配置,否则必须为空数组 `[]`。仅支持 `select` 类型字段
5. `allow_edit_and_download_file_fields`:用户没有要求时不要设置,且仅 `field_perm_mode``specify` 时才能设置
---
@@ -346,7 +346,6 @@
{
"field_name": "部门",
"operator": "is",
"field_type": "SingleSelect",
"filter_values": ["财务部"]
}
]
@@ -373,11 +372,11 @@
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `field_name` | string | 是 | 字段名。仅限 `can_filter``true` 的字段。`field_type``CreatedUser` 时必须为空 |
| `field_name` | string | 是 | 字段名。仅限 `can_filter``true` 的字段。若服务端要求当前用户类条件,可按 API 返回结构处理 |
| `operator` | string | 是 | 操作符,见下表 |
| `field_type` | string | | 字段类型。仅支持SingleSelect / MultiSelect / User / CreatedUser / Stage / Number 类字段(含进度条、评分、货币)及部分 Formula / LookUp 字段(以 `can_filter = true` 为准) |
| `field_type` | string | | 通常由服务端 filterFiller 补全Agent 判断字段类型时以 `+field-list` / 字段操作接口的 `type` 为准,常见可筛选类型包括 `select``user``created_by``number` 及部分 `formula` / `lookup` |
| `reference_type` | string | 条件 | 引用类型。`field_type` 为公式或引用字段时必须赋值,其他情况不能赋值 |
| `filter_values` | []string | 条件 | 筛选值。`operator``isEmpty` / `isNotEmpty` 时不设置,`field_type` `User` 时也无需设置,其他情况必须设置。值为选项的 `name` |
| `filter_values` | []string | 条件 | 筛选值。`operator``isEmpty` / `isNotEmpty` 时不设置,字段类型`user` 时也无需设置,其他情况必须设置。值为选项的 `name` |
| `field_ui_type` | string | 条件 | 该字段有值时一定要填 |
| `is_invalid` | bool | 否 | 判断筛选条件是否有效 |
@@ -461,7 +460,7 @@
| 步骤 | 操作 | 说明 |
|------|------|------|
| 1. 基准设定 | `perm = edit` → 全部字段预设 `"edit"``perm = read_only` → 全部预设 `"read"` | 基于 `base_table_info` 中的全量字段 |
| 2. 物理降级 | Formula / Lookup / AutoNumber 及系统字段 → 强制降级为 `"read"` | 不可变字段严禁设为 `edit` |
| 2. 物理降级 | `formula` / `lookup` / `auto_number` 及系统字段 → 强制降级为 `"read"` | 不可变字段严禁设为 `edit` |
| 3. 用户覆盖 | 仅对用户**显式指定**了特定权限的字段应用 `no_perm` / `read` / `create` | 未显式指定的保持基准值 |
| 4. 反筛选误判 | 用于 `filter_rules` 的字段,若基准为 `"edit"` 且用户未要求降级 → **保持 `"edit"`** | 筛选条件不影响字段可编辑性 |
| 5. 筛选依赖兜底 | 出现在 `filter_rules` 中的字段**不允许**遗漏,权限至少为 `"read"` | 最终校验步骤 |
@@ -501,20 +500,20 @@
### 字段类型与筛选算子的强约束关系
当字段被用于记录筛选条件时,字段类型FieldType与可用算子Operator存在固定绑定关系:
当字段被用于记录筛选条件时,字段操作接口返回的 `type` 与可用算子存在固定绑定关系:
**User / CreatedUser 类型字段:**
**`user` / `created_by` 类型字段:**
- 仅允许使用 `contains` 算子
- 不允许使用 `is``isNot` 等精确匹配算子
- 筛选条件中无需填写具体值(由系统自动匹配当前成员)
**SingleSelect、Stage 类型字段:**
**`select` (`multiple=false`) 类型字段:**
- `is``isNot` 算子仅允许用于匹配**单一选项**,不得用于多个值
- 当用户表达"字段值等于/不等于某一个具体选项"(如"出勤状态不等于出勤"Agent 必须使用 `is` / `isNot`,且 filter_values 仅包含单一值。
- 当用户表达"字段值等于/不等于多个选项集合"(如"学历不是专科和其他"Agent 必须使用 `contains` / `doesNotContain`,并将多个选项填入 filter_values。
- `contains` / `doesNotContain`中的filter_values可包含多个值表示或关系
**MultiSelect 类型字段:**
**`select` (`multiple=true`) 类型字段:**
- `is` / `isNot`filter_values 允许填写多个选项
- 当 operator = is 且勾选 A、B 时,语义为该字段**同时包含** A 和 BA&B不是"等于 A 或等于 B"
- 当用户表达"包含任一选项"时,除了可以使用 contains 实现外,也可以使用 is 并且配套通过 filter_rules.conjunction = or 实现
@@ -537,4 +536,4 @@
**字段是否可编辑edit不作强制要求**,由具体权限方案决定,不属于 infra 强制约束范围。
上述由系统自动施加的字段权限,不可被手动取消或降级。
上述由系统自动施加的字段权限,不可被手动取消或降级。

View File

@@ -12,13 +12,14 @@
## 关键 flag
`--query` / `--user-ids` / 4 个 bool filter 至少传一个,否则报错。完整 flag 看 `lark-cli contact +search-user --help`
`--query` / `--queries` / `--user-ids` / bool filter 至少传一个。bool filter 显式传 `=false` 会报错——不传等于不过滤
| Flag | 作用 |
|---|---|
| `--query <text>` | 关键词(姓名 / 邮箱 / 手机号),≤ 64 rune |
| `--user-ids <csv>` | open_id 列表,逗号分隔,≤ 100;支持 `me` 表示自己;与 `--query` 同传时把搜索范围限定在该集合 |
| `--has-chatted` | 仅搜聊过天的(opt-in;**显式 `=false` 会被拒**,下同) |
| `--query <text>` | 关键词(姓名 / 邮箱 / 手机号),≤ 50 rune |
| `--queries <csv>` | 多个关键词并行搜,**最多 20 条**;与 `--query` / `--user-ids` 互斥;输出新 shape(见下) |
| `--user-ids <csv>` | open_id 列表,≤ 100;支持 `me` 表示自己;与 `--query` 同传时把搜索范围限定在该集合 |
| `--has-chatted` | 仅搜聊过天的 |
| `--has-enterprise-email` | 仅搜有企业邮箱的 |
| `--exclude-external-users` | 仅搜同租户(排除外部联系人) |
| `--left-organization` | 仅搜已离职的 |
@@ -47,6 +48,30 @@ lark-cli contact +search-user --query "王" --exclude-external-users --has-enter
lark-cli contact +search-user --has-chatted --left-organization
```
## 批量并行查询 (fanout)
一次查多个名字:
```bash
lark-cli contact +search-user --queries "Alice,Bob,张三"
```
- 每行 user 带 `matched_query`,标识来自哪个 query
- `queries[]` 每个输入一条 `{query, error?, has_more}`,失败的有 `error`
- 部分失败不影响其它 query;全部失败才 exit 非 0
```bash
# bool filter 对每个 query 都生效
lark-cli contact +search-user --queries "Alice,Bob" --has-chatted
# 与 --query / --user-ids 互斥
lark-cli contact +search-user --queries "a" --query "b" # ❌ exit 2
```
约束:
- 最多 20 条; 每条 ≤ 50 字符
- 重复条目静默去重;全空 csv (`,,,`) 报错
## 同名 disambiguation
搜常见姓名常返回多条同名结果。后续操作若有副作用(发消息、邀请会议等),把候选列给用户挑;**不要擅自选**。
@@ -61,28 +86,39 @@ lark-cli contact +search-user --query "张三" \
## 注意事项
- **不会自动翻页**。`has_more=true` 表示要 refine query,不是叫你翻页
- **bool filter 显式传 `=false` 会报错**:不传等于不过滤;启用就传 flag(不带值)。
- **不会自动翻页**。`has_more=true` 表示要 refine query。
- **`--lang` 只影响输出展示名**,不影响匹配字段。
- **`--query``--user-ids` 同时设**:`--user-ids` `filter.user_ids`(限定搜索范围),`--query` 进顶层(关键字),按服务端 filter 语义在该 ID 集合内匹配;请求结构可 `--dry-run` 确认
- **`--query``--user-ids` 同时设**:`--user-ids` 限定搜索范围,`--query` 在该集合内匹配
## 输出字段 contract
`data.users[]` 的字段集合稳定,可直接 jq / 反序列化。跨租户用户(`is_cross_tenant=true`)按飞书可见性规则,业务字段可能为空字符串 —— 下游做空值兜底,不要当成"字段缺失"
跨租户用户(`is_cross_tenant=true`)业务字段可能为空字符串,需做空值兜底
| 字段 | 类型 | 说明 | 跨租户 |
|---|---|---|---|
| `open_id` | string | 稳定标识,后续命令以此为准 | 始终非空 |
| `localized_name` | string | 按 `--lang` / brand 选出的展示名;想换语言重查时传 `--lang en_us` | 始终非空(兜底为 open_id) |
| `open_id` | string | 稳定标识,后续命令的输入 | 始终非空 |
| `localized_name` | string | 按 `--lang` / brand 选出的展示名 | 始终非空(兜底为 open_id) |
| `email` | string | 个人邮箱 | 可能为空 |
| `enterprise_email` | string | 企业邮箱 | 可能为空 |
| `is_activated` | bool | 是否已激活飞书账号(未激活也可投递消息,但用户可能看不到) | 可能 false |
| `is_cross_tenant` | bool | 是否跨租户用户(同公司=false,外部联系人=true) | — |
| `p2p_chat_id` | string | 与当前用户的现有 P2P 会话 ID(`oc_...`);空表示从未私聊过。可作为任何接受 `--chat-id` 的 IM 命令的输入 | 可能为空 |
| `p2p_chat_id` | string | 与当前用户的 P2P 会话 ID(`oc_...`);空表示从未私聊过。可作为接受 `--chat-id` 的 IM 命令的输入 | 可能为空 |
| `has_chatted` | bool | `p2p_chat_id != ""` 的派生字段 | — |
| `department` | string | 部门路径,服务端可能用 `-` 拼层级,层级数不固定。**按可子串匹配的字符串处理** | 可能为空 |
| `signature` | string | 用户个性签名(API 原名 `description`,本 CLI 重命名以反映真实语义)。同名 disambiguation 时可作为辅助信号 | 可能为空 |
| `chat_recency_hint` | string | 最近联系提示文案,`"Contacted 2 days ago"`;空表示无近期联系 | 可能为空 |
| `signature` | string (optional) | 用户个性签名;空时字段不出现 | 可能不出现 |
| `chat_recency_hint` | string | 最近联系提示文案,仅供展示 | 可能为空 |
| `match_segments` | string[] | 关键词命中的字符串片段,用于高亮展示;无命中则为空数组 | — |
表中字段即本 shortcut 的输出契约,移除或改名按 breaking change 处理。
### `--queries` 模式额外字段
`data.users[]` 每条多 `matched_query` (string),指明本行来自哪个 query。
`data.queries[]` 按输入顺序、dedup 后每个 query 一条:
| 字段 | 类型 | 说明 |
|---|---|---|
| `query` | string | 该输入 |
| `error` | string (optional) | 失败原因;成功时不出现 |
| `has_more` | bool | 该 query 还有更多结果 |
fanout 模式无顶层 `data.has_more`

View File

@@ -250,6 +250,34 @@ lark-cli mail user_mailbox.sent_messages get_recall_detail --as user \
- 需要同时授权 mail 和 im 两个域的 scope
- 分享的卡片包含邮件摘要信息,收件人可点击查看
### 发送日程邀请邮件
在邮件中嵌入日程邀请(`text/calendar`),收件人收信后可直接接受或拒绝日程。`To`/`Cc` 收件人自动成为参会人ATTENDEE发件人自动成为组织者ORGANIZER
```bash
# 发送带日程邀请的新邮件(先保存草稿,确认后发送)
lark-cli mail +send --as user \
--to alice@example.com --cc bob@example.com \
--subject '产品评审' \
--body '<p>请参加本次产品评审会议。</p>' \
--event-summary '产品评审' \
--event-start '2026-05-10T14:00+08:00' \
--event-end '2026-05-10T15:00+08:00' \
--event-location '5F 大会议室' \
--confirm-send
```
**参数说明:**
- `--event-summary`:日程标题,设置此参数即开启日程邀请模式,需同时设置 `--event-start` 和 `--event-end`
- `--event-start` / `--event-end`ISO 8601 格式时间,如 `2026-05-10T14:00+08:00`
- `--event-location`:可选,日程地点
**约束:**
- `--event-*` 与 `--send-time`(定时发送)互斥,不可同时使用
- `Bcc` 收件人不会成为日程参会人;如果邮件同时包含 Bcc 和日程,后端在发送时会拒绝该请求
读取含日程邀请的邮件时,`calendar_event` 字段包含日程详情(`method`、`summary`、`start`、`end`、`organizer`、`attendees` 等)。
### 正文格式:优先使用 HTML
撰写邮件正文时,**默认使用 HTML 格式**body 内容会被自动检测)。仅当用户明确要求纯文本时,才使用 `--plain-text` 标志强制纯文本模式。

View File

@@ -48,13 +48,20 @@ lark-cli mail +draft-create --to alice@example.com --subject '测试' --body 'te
| `--from <email>` | 否 | 发件人邮箱地址EML From 头。使用别名send_as发信时设为别名地址并配合 `--mailbox` 指定所属邮箱。省略时使用邮箱主地址 |
| `--mailbox <email>` | 否 | 邮箱地址,指定草稿所属的邮箱(默认回退到 `--from`,再回退到 `me`)。当发件人(`--from`)与邮箱不同时使用,如通过别名或 send_as 地址发信。可通过 `accessible_mailboxes` 查询可用邮箱 |
| `--cc <emails>` | 否 | 完整抄送列表,多个用逗号分隔 |
| `--bcc <emails>` | 否 | 完整密送列表,多个用逗号分隔 |
| `--bcc <emails>` | 否 | 完整密送列表,多个用逗号分隔。与 `--event-*` 不兼容(见 `+send` 日程邀请约束) |
| `--plain-text` | 否 | 强制纯文本模式,忽略 HTML 自动检测。不可与 `--inline` 同时使用 |
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔。相对路径。当附件导致 EML 总大小超过 25 MB 时超出部分自动上传为超大附件HTML 邮件插入下载卡片,纯文本邮件追加下载链接),单个文件上限 3 GB |
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到正文末尾。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 |
| `--priority <level>` | 否 | 邮件优先级:`high``normal``low`。省略或 `normal` 时不设置优先级 |
| `--request-receipt` | 否 | 请求已读回执RFC 3798 Message Disposition Notification。在草稿 EML 里写 `Disposition-Notification-To: <sender>` 头,发送时生效。收件人的邮件客户端可能弹出提示、自动发送或忽略——送达不保证 |
| `--event-summary <text>` | 否 | 日程标题。设置此参数即在邮件中嵌入日程邀请。需同时设置 `--event-start``--event-end` |
| `--event-start <time>` | 条件必填 | 日程开始时间ISO 8601 |
| `--event-end <time>` | 条件必填 | 日程结束时间ISO 8601 |
| `--event-location <text>` | 否 | 日程地点 |
> **日程约束**`--event-*` 与 `--send-time` 不可同时使用;`--to` 和 `--cc` 收件人自动成为日程参与者ATTENDEE`--bcc` 收件人不计入参与者。
| `--format <mode>` | 否 | 输出格式:`json`(默认)/ `pretty` / `table` / `ndjson` / `csv` |
| `--dry-run` | 否 | 仅打印请求,不执行 |

View File

@@ -73,6 +73,11 @@ lark-cli mail +draft-edit --draft-id <draft-id> --set-subject '测试' --dry-run
| `--set-cc <emails>` | 否 | 用此处提供的地址替换整个 Cc 抄送列表 |
| `--set-bcc <emails>` | 否 | 用此处提供的地址替换整个 Bcc 密送列表 |
| `--set-priority <level>` | 否 | 设置邮件优先级:`high``normal``low`。设为 `normal` 会清除已有优先级 |
| `--set-event-summary <text>` | 否 | 设置日程标题。需同时设置 `--set-event-start``--set-event-end` |
| `--set-event-start <time>` | 条件必填 | 设置日程开始时间ISO 8601 |
| `--set-event-end <time>` | 条件必填 | 设置日程结束时间ISO 8601 |
| `--set-event-location <text>` | 否 | 设置日程地点 |
| `--remove-event` | 否 | 移除草稿中的日程邀请。与 `--set-event-*` 互斥 |
| `--patch-file <path>` | 否 | 所有正文编辑、增量收件人编辑、邮件头编辑、附件变更和内嵌图片变更的入口。相对路径。先运行 `--print-patch-template` 查看 JSON 结构 |
| `--print-patch-template` | 否 | 打印 `--patch-file` 的 JSON 模板和支持的操作。建议在生成补丁文件前先运行此命令。不会读取或写入草稿 |
| `--inspect` | 否 | 查看草稿但不修改。返回包含 `has_quoted_content`(是否有引用区)、`attachments_summary`(普通附件,含 `part_id`/`cid`/`filename`)、`large_attachments_summary`(超大附件,含 `token`/`filename`/`size_bytes`)和 `inline_summary` 的草稿投影 |

View File

@@ -64,13 +64,19 @@ lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --dry-run
| `--from <email>` | 否 | 发件人邮箱地址EML From 头。使用别名send_as发信时设为别名地址并配合 `--mailbox` 指定所属邮箱。默认读取邮箱主地址 |
| `--mailbox <email>` | 否 | 邮箱地址,指定草稿所属的邮箱(默认回退到 `--from`,再回退到 `me`)。当发件人(`--from`)与邮箱不同时使用。可通过 `accessible_mailboxes` 查询可用邮箱 |
| `--cc <emails>` | 否 | 抄送邮箱,多个用逗号分隔 |
| `--bcc <emails>` | 否 | 密送邮箱,多个用逗号分隔 |
| `--bcc <emails>` | 否 | 密送邮箱,多个用逗号分隔。与 `--event-*` 不兼容(见 `+send` 日程邀请约束) |
| `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 |
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔,追加在原邮件附件之后。相对路径。当附件导致 EML 总大小超过 25 MB 时超出部分自动上传为超大附件HTML 邮件插入下载卡片,纯文本邮件追加下载链接),单个文件上限 3 GB |
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到转发正文与引用块之间。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 |
| `--priority <level>` | 否 | 邮件优先级:`high``normal``low`。省略或 `normal` 时不设置优先级 |
| `--event-summary <text>` | 否 | 日程标题。设置此参数即在邮件中嵌入日程邀请。需同时设置 `--event-start``--event-end` |
| `--event-start <time>` | 条件必填 | 日程开始时间ISO 8601 |
| `--event-end <time>` | 条件必填 | 日程结束时间ISO 8601 |
| `--event-location <text>` | 否 | 日程地点 |
| `--confirm-send` | 否 | 确认发送转发(默认只保存草稿)。仅在用户明确确认后使用 |
> **日程约束**`--event-*` 与 `--send-time` 不可同时使用;`--to` 和 `--cc` 收件人自动成为日程参与者ATTENDEE`--bcc` 收件人不计入参与者。
| `--send-time <timestamp>` | 否 | 定时发送时间Unix 时间戳(秒)。需至少为当前时间 + 5 分钟。配合 `--confirm-send` 使用可定时发送邮件 |
| `--request-receipt` | 否 | 请求已读回执RFC 3798 Message Disposition Notification。在出站 EML 里写 `Disposition-Notification-To: <sender>` 头。收件人的邮件客户端可能弹出提示、自动发送或忽略——送达不保证 |
| `--dry-run` | 否 | 仅打印请求,不执行 |

View File

@@ -192,6 +192,35 @@ lark-cli mail user_mailbox.message.attachments download_url \
普通附件和内嵌图片使用同一个 `user_mailbox.message.attachments download_url` 原生 API无 shortcut 封装),传入 `attachments[].id` 即可。
## 日程邀请邮件
当邮件包含日程邀请(`text/calendar`)时,输出中会包含 `calendar_event` 对象:
```json
{
"calendar_event": {
"method": "REQUEST",
"uid": "abc123",
"summary": "产品评审",
"start": "2026-04-20T14:00:00+08:00",
"end": "2026-04-20T15:00:00+08:00",
"location": "5F-大会议室",
"organizer": "sender@example.com",
"attendees": ["alice@example.com", "bob@example.com"]
}
}
```
字段说明:
- `method`ICS `METHOD`,通常为 `REQUEST` / `REPLY` / `CANCEL`
- `uid`:日程 UID。
- `summary`:日程标题。
- `start` / `end`:开始 / 结束时间RFC 3339 UTC
- `location`:地点(可能为空)。
- `organizer`:组织者邮箱。
- `attendees`:参会人邮箱列表。
## 相关命令
- `lark-cli mail +thread` — 读取会话中所有邮件

View File

@@ -67,13 +67,17 @@ lark-cli mail +reply-all --message-id <邮件ID> --body '测试' --dry-run
| `--mailbox <email>` | 否 | 邮箱地址,指定草稿所属的邮箱(默认回退到 `--from`,再回退到 `me`)。当发件人(`--from`)与邮箱不同时使用。可通过 `accessible_mailboxes` 查询可用邮箱 |
| `--to <emails>` | 否 | 额外收件人,多个用逗号分隔(追加到自动聚合结果) |
| `--cc <emails>` | 否 | 额外抄送,多个用逗号分隔 |
| `--bcc <emails>` | 否 | 密送邮箱,多个用逗号分隔 |
| `--bcc <emails>` | 否 | 密送邮箱,多个用逗号分隔。与 `--event-*` 不兼容(见 `+send` 日程邀请约束) |
| `--remove <emails>` | 否 | 从自动聚合结果中排除的邮箱,多个用逗号分隔 |
| `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 |
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔。相对路径。当附件导致 EML 总大小超过 25 MB 时超出部分自动上传为超大附件HTML 邮件插入下载卡片,纯文本邮件追加下载链接),单个文件上限 3 GB |
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到回复正文与引用块之间。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 |
| `--priority <level>` | 否 | 邮件优先级:`high``normal``low`。省略或 `normal` 时不设置优先级 |
| `--event-summary <text>` | 否 | 日程标题。设置此参数即在邮件中嵌入日程邀请。需同时设置 `--event-start``--event-end` |
| `--event-start <time>` | 条件必填 | 日程开始时间ISO 8601 |
| `--event-end <time>` | 条件必填 | 日程结束时间ISO 8601 |
| `--event-location <text>` | 否 | 日程地点 |
| `--confirm-send` | 否 | 确认发送回复(默认只保存草稿)。仅在用户明确确认后使用 |
| `--send-time <timestamp>` | 否 | 定时发送时间Unix 时间戳(秒)。需至少为当前时间 + 5 分钟。配合 `--confirm-send` 使用可定时发送邮件 |
| `--request-receipt` | 否 | 请求已读回执RFC 3798 Message Disposition Notification。在出站 EML 里写 `Disposition-Notification-To: <sender>` 头。收件人的邮件客户端可能弹出提示、自动发送或忽略——送达不保证 |

View File

@@ -71,12 +71,16 @@ lark-cli mail +reply --message-id <邮件ID> --body '<p>测试</p>' --dry-run
| `--mailbox <email>` | 否 | 邮箱地址,指定草稿所属的邮箱(默认回退到 `--from`,再回退到 `me`)。当发件人(`--from`)与邮箱不同时使用。可通过 `accessible_mailboxes` 查询可用邮箱 |
| `--to <emails>` | 否 | 额外收件人,多个用逗号分隔(追加到原发件人) |
| `--cc <emails>` | 否 | 抄送邮箱,多个用逗号分隔 |
| `--bcc <emails>` | 否 | 密送邮箱,多个用逗号分隔 |
| `--bcc <emails>` | 否 | 密送邮箱,多个用逗号分隔。与 `--event-*` 不兼容(见 `+send` 日程邀请约束) |
| `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 |
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔。相对路径。当附件导致 EML 总大小超过 25 MB 时超出部分自动上传为超大附件HTML 邮件插入下载卡片,纯文本邮件追加下载链接),单个文件上限 3 GB |
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到回复正文与引用块之间。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 |
| `--priority <level>` | 否 | 邮件优先级:`high``normal``low`。省略或 `normal` 时不设置优先级 |
| `--event-summary <text>` | 否 | 日程标题。设置此参数即在邮件中嵌入日程邀请。需同时设置 `--event-start``--event-end` |
| `--event-start <time>` | 条件必填 | 日程开始时间ISO 8601 |
| `--event-end <time>` | 条件必填 | 日程结束时间ISO 8601 |
| `--event-location <text>` | 否 | 日程地点 |
| `--confirm-send` | 否 | 确认发送回复(默认只保存草稿)。仅在用户明确确认后使用 |
| `--send-time <timestamp>` | 否 | 定时发送时间Unix 时间戳(秒)。需至少为当前时间 + 5 分钟。配合 `--confirm-send` 使用可定时发送邮件 |
| `--request-receipt` | 否 | 请求已读回执RFC 3798 Message Disposition Notification。在出站 EML 里写 `Disposition-Notification-To: <sender>` 头。收件人的邮件客户端可能弹出提示、自动发送或忽略——送达不保证 |

View File

@@ -77,11 +77,23 @@ lark-cli mail +send --to alice@example.com --subject '测试' --body '<p>test</p
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到正文末尾。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 |
| `--priority <level>` | 否 | 邮件优先级:`high``normal``low`。省略或 `normal` 时不设置优先级 |
| `--event-summary <text>` | 否 | 日程标题。设置此参数即在邮件中嵌入日程邀请text/calendar。需同时设置 `--event-start``--event-end` |
| `--event-start <time>` | 条件必填 | 日程开始时间ISO 8601`2026-04-20T14:00+08:00` |
| `--event-end <time>` | 条件必填 | 日程结束时间ISO 8601 |
| `--event-location <text>` | 否 | 日程地点 |
| `--confirm-send` | 否 | 确认发送邮件(默认只保存草稿)。仅在用户明确确认收件人和内容后使用 |
| `--send-time <timestamp>` | 否 | 定时发送时间Unix 时间戳(秒)。需至少为当前时间 + 5 分钟。配合 `--confirm-send` 使用可定时发送邮件 |
| `--request-receipt` | 否 | 请求已读回执RFC 3798 Message Disposition Notification。在出站 EML 里写 `Disposition-Notification-To: <sender>` 头。收件人的邮件客户端**可能**弹出提示询问是否回执、可能自动发送、也可能忽略——送达不保证 |
| `--dry-run` | 否 | 仅打印请求,不执行 |
### 日程邀请约束
使用 `--event-*` 时需满足以下条件:
- `--event-summary``--event-start``--event-end` 必须同时出现或同时不出现
-`--send-time` 互斥,不可同时使用(日程邀请必须立即发送,否则收件人可能在日程开始后才收到)
- 不可与 `--bcc` 同时使用日程参会人ATTENDEE仅来自 To 和 CcBcc 收件人不在参会人列表中、无法 RSVP且该组合将导致邮件发送失败。需要邀请某人参加日程请用 `--to``--cc`;如只想告知而不邀请,请单独发一封无日程的邮件
## 返回值
**草稿模式(默认):**

View File

@@ -14,6 +14,13 @@ metadata:
**CRITICAL — 生成任何 XML 之前MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**
**CRITICAL — 如果用户提到“模板”“套用模板”“参考某种主题/风格/版式”或用户需求明显落在已有场景模板内如工作汇报、产品介绍、商业计划书、培训、晋升汇报等MUST 先用 [`scripts/template_tool.py`](scripts/template_tool.py) 的 `search` 做模板检索;默认给出 2-3 个最匹配模板候选供用户选择。锁定模板后用 `summarize` 获取主题和布局摘要;只有需要布局骨架时才用 `extract` 裁切目标页型 XML。不要直接读取完整模板 XML。**
> [!NOTE]
> `scripts/template_tool.py` 需要 Python 3。`references/template-index.json` 是脚本缓存/轻量路由索引,不是默认给 agent 阅读的文档;`assets/templates/*.xml` 是机器资源,只应通过脚本摘要或裁切,不要全文读取。
**CRITICAL — 使用模板生成或改写页面时MUST 先 `summarize` 目标页型;只有需要具体布局骨架时才 `extract`。生成本地 XML 后,如可运行 PythonMUST 先用 [`scripts/layout_lint.py`](scripts/layout_lint.py) 检查 XML well-formed、重叠/越界/文本高度风险,再创建或追加页面。它不是完整 XSD schema 校验。**
**编辑已有幻灯片页面**:优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序);选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。
## 身份选择
@@ -46,6 +53,12 @@ lark-cli slides +create --title "演示文稿标题" --slides '[
也可以分两步(先创建空白 PPT再逐页添加详见 [+create 参考文档](references/lark-slides-create.md)。
> [!WARNING]
> `--slides '[...]'` 适合简单页面批量创建但并不等同于“10 页以内都安全”。如果 slide XML 含中文、大段文本、复杂布局、嵌套引号或较多特殊字符shell 传参时可能出现转义或截断问题,导致内容丢失、页面空白或布局异常。遇到复杂页面时,优先改用“两步创建法”。
> [!IMPORTANT]
> `slides +create --slides` 底层是“先创建空白 PPT再逐页调用 `xml_presentation.slide.create`”。这不是原子操作中途某一页失败时前面已创建成功的页面会保留。skill 必须把这种“部分成功”风险提前告诉用户,并在失败后先记录 `xml_presentation_id`,回读确认当前状态,再决定是否在现有 PPT 上继续修复或追加。
> 以上是最小可用示例。更丰富的页面效果(渐变背景、卡片、图表、表格等),参考下方 Workflow 和 XML 模板。
## 执行前必做
@@ -63,6 +76,10 @@ lark-cli slides +create --title "演示文稿标题" --slides '[
| 场景 | 文档 |
|------|------|
| 需要了解详细 XML 结构 | [xml-format-guide.md](references/xml-format-guide.md) |
| 需要快速筛模板、做低成本路由 | [`scripts/template_tool.py search`](scripts/template_tool.py) |
| 需要匹配 PPT 模板/主题风格 | [template-catalog.md](references/template-catalog.md) |
| 需要按页型抽摘要或裁切 XML 片段 | [`scripts/template_tool.py`](scripts/template_tool.py) |
| 需要做本地布局风险检查 | [`scripts/layout_lint.py`](scripts/layout_lint.py) |
| 需要 CLI 调用示例 | [examples.md](references/examples.md) |
| 需要参考真实 PPT 的 XML | [slides_demo.xml](references/slides_demo.xml) |
| 需要用 table/chart 等复杂元素 | [slides_xml_schema_definition.xml](references/slides_xml_schema_definition.xml)(完整 Schema |
@@ -73,19 +90,67 @@ lark-cli slides +create --title "演示文稿标题" --slides '[
> **这是演示文稿,不是文档。** 每页 slide 是独立的视觉画面,信息密度要低,排版要留白。
### 创建方式选择
| 场景 | 推荐方式 |
|------|----------|
| 简单 XML1-3 页、结构简单、几乎无复杂中文和特殊字符) | `slides +create --slides '[...]'` 一步创建 |
| 复杂 XML多页、含中文、大段文本、复杂布局、嵌套引号、特殊字符较多 | **两步创建**:先 `slides +create` 创建空白 PPT再用 `xml_presentation.slide create` 逐页添加 |
| 已有 PPT 继续追加或插入页面 | 使用 `xml_presentation.slide create`,必要时配合 `before_slide_id` |
> [!WARNING]
> `--slides '[...]'` 的风险点主要在 shell 参数传递,而不是单纯页数。即使只有 1 页,只要 XML 足够复杂,也建议使用两步创建法。
### 模板与脚本优先流程
```bash
# 1. 搜索候选:把用户原始需求整句放进 --query不要只放手动提炼的短词
python3 skills/lark-slides/scripts/template_tool.py search --query "<用户需求原文>" --limit 3
# 2. 锁定模板后先看页型摘要
python3 skills/lark-slides/scripts/template_tool.py summarize --template <template-id> --label <封面|目录|分节|内容|结尾>
# 3. 只有需要复用布局骨架时才裁切 XML
python3 skills/lark-slides/scripts/template_tool.py extract --template <template-id> --label <页型> --out /tmp/template-slice.xml
# 4. 生成待创建 XML 后先做布局风险检查
python3 skills/lark-slides/scripts/layout_lint.py --input /tmp/presentation.xml
```
执行规则:
1. `search --query` 使用用户原始描述;如用户明确风格,再额外加 `--tone light|dark|colorful``--formality formal|casual|creative`
2. 候选展示只给 2-3 个,包含模板名、适用场景、风格/色调、推荐理由;不要把完整目录贴给用户。
3. 锁定模板后,复用 `<theme>`、配色、页面流、布局骨架;所有占位文案都必须改写为用户真实内容。
4. `layout_lint.py` 有 error 时先修 XML不要提交创建只有 warning 时,检查是否是可接受的装饰/背景误报。
```text
Step 1: 需求澄清 & 读取知识
- 澄清用户需求:主题、受众、页数、风格偏好
- 如果需求明显落在已有模板场景内,主动提示用户“可以直接基于现成模板生成”,并给出 2-3 个最匹配模板候选(模板名 + 适用场景 + 风格/色调 + 简短推荐理由)
- 默认不要把完整模板目录直接贴给用户;除非用户明确要求看更多,否则只展示 2-3 个候选
- 候选优先选场景强相关模板;只有没有明显场景模板时,才用 `light_general.xml` / `dark_general.xml` 这类通用模板兜底
- 如果用户没有明确风格,根据主题推荐(见下方风格判断表)
- 如果用户要求“模板/主题/风格参考”,或主题属于常见模板场景:
· 优先运行 `python3 skills/lark-slides/scripts/template_tool.py search --query "<用户需求原文>" --limit 3` 做低成本模板匹配
· 需要人类可读说明时,再读 template-catalog.md 组织候选文案
· 锁定模板后,优先运行 `template_tool.py summarize` 看 `<theme>` / 页型摘要;需要具体布局时,再用 `template_tool.py extract`
· 复用模板的 theme、配色、页面流、布局骨架不要照搬占位文案
· `references/template-index.json` 只是脚本缓存/轻量路由索引,`assets/templates/*.xml` 是机器资源;除非用户明确要求审计原始模板,否则不要直接读取
- 读取 XML Schema 参考:
· xml-schema-quick-ref.md — 元素和属性速查
· xml-format-guide.md — 详细结构与示例
· slides_demo.xml — 真实 XML 示例
Step 2: 生成大纲 → 用户确认 → 创建
- 生成大纲前,先确认用户是否采用推荐模板;轻量任务且候选中有明显最佳匹配时,可在大纲里声明“默认基于 <template-id> 改写”并继续,但正式创建前必须给用户改选机会
- 生成结构化大纲(每页标题 + 要点 + 布局描述),交给用户确认
- 10 页以内:用 slides +create --slides '[...]' 一步创建 PPT 并添加所有页面
- 超过 10 页:先 `slides +create` 创建空白 PPT再用 `xml_presentation.slide.create` 逐页添加
- 如果已选模板,大纲和页面布局要明确标注“基于哪个模板/哪些模板改写”
- 如果用户明确不要模板,直接按自定义风格继续,不要重复推动模板选择
- 先判断创建方式:
· 简单 XML可用 `slides +create --slides '[...]'` 一步创建
· 复杂 XML优先先 `slides +create` 创建空白 PPT再用 `xml_presentation.slide.create` 逐页添加
· 超过 10 页:默认使用两步创建,避免单次输入过长
- 含本地图片:
· 新建带图 PPT —— 在 slide XML 里写 <img src="@./pic.png" .../>
+create 会自动上传并替换为 file_token详见 lark-slides-create.md
@@ -98,15 +163,94 @@ Step 2: 生成大纲 → 用户确认 → 创建
绝对路径会被 CLI 拒绝,先 cd 到素材所在目录再执行
- 每页 slide 需要完整的 XML背景、文本、图形、配色
- 复杂元素table、chart需参考 XSD 原文
- 创建前必须做 XML 自检:
· 检查特殊字符是否按 XML 规则转义:文本节点和属性值里的裸 `& -> &amp;`;文本里的 `< -> &lt;`、`> -> &gt;`。例如 `Q&A -> Q&amp;A`URL 属性 `a=1&b=2 -> a=1&amp;b=2`
· 属性值里的双引号必须转义或改为外层安全包装,避免 shell 和 JSON 双重截断
· 确认所有标签闭合,且 `<slide>` 直接子元素只包含 `<style>`、`<data>`、`<note>`
· 如果内容里同时出现中文、大段文本、复杂布局、较多特殊字符,默认不要走 `--slides '[...]'`,直接改用两步创建法
· 如果 XML 已落到本地文件且可运行 Python先执行 `layout_lint.py --input <file>`;它会先检查 XML well-formed 再检查布局风险,但不等价于完整 XSD schema 校验;有 error 先修复再创建
- 如果使用模板生成页面,先复用模板骨架再填内容,不要直接复制模板中的长段占位文本
Step 3: 审查 & 交付
- 创建完成后,用 xml_presentations.get 读取全文 XML确认
· 页数是否正确?每页内容是否完整?
- 创建完成后,必须用 xml_presentations.get 读取全文 XML 做创建后验证,确认:
· 页数是否正确?
· 每页 `<data>` 是否包含预期的 `<shape>` / `<img>` / 其他元素?
· 文本内容是否完整,是否有被截断、丢失、空白区域?
· 关键布局坐标和尺寸是否合理,是否出现明显重叠?
· 配色是否统一?字号层级是否合理?
- 如果本地有 Python 3运行
`python3 skills/lark-slides/scripts/layout_lint.py --input presentation.xml`
做重叠、越界、页脚碰撞、文本高度风险检查;有 error 先修复再交付
- 如果创建过程中失败:
· 先保留并记录 `xml_presentation_id`,不要假设失败代表什么都没创建
· 先判断是否已有部分页面写入,再决定是否在现有 PPT 上修复后继续追加
· 优先排查当前失败页:先看该页 XML再检查是否存在未转义 `&`、错误引号、标签未闭合、shell 传参截断
- 局部问题 → 用 `+replace-slide` 块级修正;整页结构要改 → `slide.delete` 旧页 + `slide.create` 新页
- 没问题 → 交付:告知用户演示文稿 ID 和访问方式
```
### 创建后验证
创建成功不等于内容正确。创建完 PPT 后,**必须**读取全文 XML 校验结果:
```bash
lark-cli slides xml_presentations get --as user \
--params '{"xml_presentation_id":"YOUR_ID"}'
```
重点检查:
- [ ] 页数是否与预期一致
- [ ] 每页 `<data>` 中是否包含所有预期元素
- [ ] 文本内容是否完整,没有被 shell 截断或转义损坏
- [ ] 白底内容区、卡片区、图文区等关键布局是否实际生成
- [ ] 坐标、宽高是否合理,是否出现堆叠或越界
发现问题时:
1. 不要假设“创建成功就代表渲染正确”
2. 先读取问题页的 XML确认是生成问题还是传参损坏
3. 删除问题页后重新添加;复杂页面优先改用两步创建法
### 最小验收清单
创建完成后,默认按下面顺序验收,不要省略:
1. 记录 `xml_presentation_id`
2. 确认返回的 `slides_added` 或实际页数是否符合预期
3. 立即执行 `xml_presentations get`
4. 检查标题、关键页面、关键文本是否存在
5. 检查是否有明显空白页、内容缺失、页序错误
6. 再决定是否向用户交付 URL 和后续编辑建议
推荐最小闭环:
```bash
# 创建
lark-cli slides +create --as user --title "Demo" --slides '[...]'
# 立即回读
lark-cli slides xml_presentations get --as user \
--params '{"xml_presentation_id":"YOUR_ID"}'
```
## XML 自检与排障
在真正创建前,至少做下面 4 项检查:
- [ ] 特殊字符已转义:正文和标题里的 `&``<``>` 不能裸写;属性值里的裸 `&` 也必须写成 `&amp;`
- [ ] 属性引号安全XML 属性、shell 引号、JSON 字符串包装之间没有互相打断
- [ ] 结构合法:`<slide>` 下只放 `<style>``<data>``<note>`,文本都在 `<content>`
- [ ] 路径正确:`<img src="@...">` 只在 `+create --slides` 的支持链路中使用
高频失败信号和处理顺序:
1. `invalid param` / 某一页创建失败
2. 先检查失败页是否含未转义 `&` / `<` / `>``Q&A -> Q&amp;A`,属性 URL `a=1&b=2 -> a=1&amp;b=2`
3. 再检查标签闭合、属性引号、`<content>` 结构
4. 如果是 `--slides '[...]'`,怀疑 shell 截断时直接切两步创建法
5. 创建后无论成功失败,都优先记录 `xml_presentation_id` 并回读确认是否已有部分页面写入
### jq 命令模板(编辑已有 PPT 时使用)
新建 PPT 推荐用 `+create --slides`。以下 jq 模板适用于向已有演示文稿追加页面的场景,可以避免手动转义双引号:
@@ -163,6 +307,8 @@ lark-cli slides xml_presentation.slide create \
```text
[PPT 标题] — [定位描述],面向 [目标受众]
模板:[未使用模板 / <category>/<template>.xml推荐原因]
页面结构N 页):
1. 封面页:[标题文案]
2. [页面主题][要点1]、[要点2]、[要点3]
@@ -279,8 +425,8 @@ lark-cli slides <resource> <method> [flags] # 调用 API
## 核心规则
1. **先出大纲再动手**创建 PPT 前先生成大纲交给用户确认,避免返工
2. **创建流程**10 页以内推荐 `slides +create --slides '[...]'` 一步创建;超过 10 页先 `slides +create` 创建空白 PPT再用 `xml_presentation.slide.create` 逐页添加
1. **先定模板/风格并出大纲再动手**如果需求可匹配模板,先给用户 2-3 个模板候选;模板或自定义风格确定后,再生成大纲交给用户确认,避免返工
2. **创建流程**简单短 XML1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT再用 `xml_presentation.slide.create` 逐页添加
3. **`<slide>` 直接子元素只有 `<style>`、`<data>`、`<note>`**:文本和图形必须放在 `<data>` 内
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
5. **保存关键 ID**:后续操作需要 `xml_presentation_id`、`slide_id`、`revision_id`
@@ -307,13 +453,14 @@ lark-cli slides <resource> <method> [flags] # 调用 API
|--------|------|----------|
| 400 | XML 格式错误 | 检查 XML 语法,确保标签闭合 |
| 400 | 请求包装错误 | 检查 `--data` 是否按 schema 传入 `xml_presentation.content` 或 `slide.content` |
| 创建成功但页面空白/内容缺失/布局错乱 | 常见于 `--slides '[...]'` 的 shell 转义或长参数传递问题 | 改用两步创建:先 `slides +create`,再用 `jq -n` 包装 `xml_presentation.slide.create` 逐页添加,并在创建后立即读取 XML 验证 |
| 404 | 演示文稿不存在 | 检查 `xml_presentation_id` 是否正确 |
| 404 | 幻灯片不存在 | 检查 `slide_id` 是否正确 |
| 403 | 权限不足 | 检查是否拥有对应的 scope |
| 400 | 无法删除唯一幻灯片 | 演示文稿至少保留一页幻灯片 |
| 1061002 | params error媒体上传时 | 用 `slides +media-upload`,不要手拼原生 `medias/upload_all`slides 唯一可用 `parent_type` 是 `slide_file` |
| 1061004 | forbidden当前身份对演示文稿无编辑权限 | 确认 user/bot 对目标 PPT 有编辑权限bot 常见于 PPT 非该 bot 创建,需先授权或用 `+create --as bot` 新建 |
| 3350001 | `xml_presentation.slide.replace` 失败catch-all | 检查 `block_replace` 替换根是否带 `id=<block_id>``<shape>` 是否含 `<content/>`;坐标是否在 960×540 内。详见 [lark-slides-replace-slide.md](references/lark-slides-replace-slide.md) |
| 3350001 | XML 非 well-formed、XML 结构不符合服务端要求,或 `xml_presentation.slide.replace` 失败catch-all | 优先检查未转义 `&` / `<` / `>``Q&A -> Q&amp;A`,属性 URL `a=1&b=2 -> a=1&amp;b=2`;运行 `layout_lint.py --input <file>` 定位行列和上下文;再检查 replace 场景的 `block_id` / `<content/>` / 坐标 |
| 3350002 | `revision_id` 大于当前版本 | 用 `-1` 取当前版本,或重新读 `xml_presentations.get` 取最新 `revision_id` |
| validation: unsafe file path | `--file` 给了绝对路径或上层路径 | `--file` 必须是 CWD 内相对路径;先 `cd` 到素材目录再执行 |
@@ -359,6 +506,10 @@ lark-cli slides <resource> <method> [flags] # 调用 API
| [lark-slides-media-upload.md](references/lark-slides-media-upload.md) | **+media-upload Shortcut上传本地图片返回 `file_token`** |
| [lark-slides-replace-slide.md](references/lark-slides-replace-slide.md) | **+replace-slide Shortcut块级替换/插入,含合法根元素速查与 3350001 排错** |
| [lark-slides-edit-workflows.md](references/lark-slides-edit-workflows.md) | 编辑已有页面的读-改-写流程与 action 决策树 |
| [template-index.json](references/template-index.json) | **脚本缓存/轻量路由索引:由 `template_tool.py search` 使用,不是默认阅读入口** |
| [template-catalog.md](references/template-catalog.md) | **按场景/色调匹配现成 PPT 模板,并定位到页型范围** |
| [`scripts/template_tool.py`](scripts/template_tool.py) | **可选 Python 辅助脚本:`search` / `summarize` / `extract`,支持 `--layout-tag` 与 `extract --with-summary`** |
| [`scripts/layout_lint.py`](scripts/layout_lint.py) | **本地预检脚本:先检查 XML well-formed再检测重叠、越界、页脚碰撞、文本高度风险不是完整 XSD schema 校验** |
| [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md) | **XML Schema 精简速查(必读)** |
| [slide-templates.md](references/slide-templates.md) | 可复制的 Slide XML 模板 |
| [xml-format-guide.md](references/xml-format-guide.md) | XML 详细结构与示例 |

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,912 @@
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<title>员工培训</title>
<theme>
<background>
<fillColor color="rgba(13, 20, 32, 1)"/>
</background>
<textStyles>
<title fontColor="#000000FF" fontSize="48"/>
<headline fontColor="#000000FF" fontSize="36"/>
<sub-headline fontColor="#000000FF"/>
<body fontColor="#000000FF"/>
<caption fontColor="#808080FF" fontSize="14"/>
</textStyles>
</theme>
<slide>
<style>
<fill>
<fillImg src="AiubbdDtOosfbEx6K6Oc9jJPnhb" alpha="1" rotateWithShape="false"/>
</fill>
</style>
<data>
<line startX="51" startY="457" endX="904" endY="456">
<border color="rgba(143, 149, 158, 1)" width="1" lineCap="square" lineJoin="miter" miterLimit="10"/>
</line>
<shape width="300" height="74" topLeftX="51" topLeftY="351" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="18" color="rgba(255, 255, 255, 1)">
<p>
<span color="rgba(255, 255, 255, 1)" fontSize="18">主讲人:李天天 </span>
</p>
<p>
<span color="rgba(255, 255, 255, 1)" fontSize="18">资深研究员创业公司CEO</span>
</p>
</content>
</shape>
<shape width="547" height="176" topLeftX="46" topLeftY="134" type="text">
<content textType="title" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="65" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.2">
<p>
<strong>
<span color="rgba(255, 255, 255, 1)" fontSize="65">员工</span>
</strong>
</p>
<p>
<strong>
<span color="rgba(255, 255, 255, 1)" fontSize="65">培训指南</span>
</strong>
</p>
</content>
</shape>
<shape width="202" height="41" topLeftX="51" topLeftY="462" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="14" autoFit="shape-auto-fit">
<p>输入你的互联网公司</p>
</content>
</shape>
<shape width="16" height="15" topLeftX="78" topLeftY="60" flipX="true" type="round2diag-rect">
<fill>
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
</shape>
<shape width="36" height="16" topLeftX="48" topLeftY="50" rotation="90" flipX="true" type="round2diag-rect">
<fill>
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
</shape>
<shape width="16" height="15" topLeftX="78" topLeftY="39" type="ellipse">
<fill>
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
</shape>
<shape width="202" height="41" topLeftX="712" topLeftY="28" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="14" fontFamily="undefined" color="rgba(255, 255, 255, 1)" backgroundColor="rgba(0, 0, 0, 0)" bold="false" italic="false" underline="false" strikethrough="false" list="none" listStyle="circle-hollow-square" textAlign="right" autoFit="shape-auto-fit">
<p list="none" textAlign="right">公司名字</p>
</content>
</shape>
<shape width="202" height="39" topLeftX="58" topLeftY="462" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" fontFamily="undefined" color="rgba(255, 255, 255, 1)" backgroundColor="rgba(0, 0, 0, 0)" bold="false" italic="false" underline="false" strikethrough="false" list="none" listStyle="circle-hollow-square" textAlign="left" autoFit="shape-auto-fit">
<p>输入互联网公司</p>
</content>
</shape>
<shape width="202" height="38" topLeftX="702" topLeftY="462" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" fontFamily="undefined" color="rgba(255, 255, 255, 1)" backgroundColor="rgba(0, 0, 0, 0)" bold="false" italic="false" underline="false" strikethrough="false" list="none" listStyle="circle-hollow-square" textAlign="right">
<p>2026年第一季度</p>
</content>
</shape>
</data>
<note>
<content/>
</note>
</slide>
<slide>
<style>
<fill>
<fillColor color="rgba(13, 20, 32, 1)"/>
</fill>
</style>
<data>
<line startX="60" startY="152" endX="540" endY="152" alpha="0.5">
<border color="rgba(143, 149, 158, 1)" width="1" lineCap="square" lineJoin="miter" miterLimit="10"/>
</line>
<shape width="531" height="104" topLeftX="47" topLeftY="38" type="text">
<content textType="title" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="60" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.4">
<p>
<strong>
<span color="rgba(255, 255, 255, 1)" fontSize="60">目录</span>
</strong>
</p>
</content>
</shape>
<shape width="310" height="260" topLeftX="60" topLeftY="203" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:2.3">
<ul listStyle="circle-hollow-square">
<li>
<p lineSpacing="multiple:3">
<strong>
<span color="rgba(255, 255, 255, 1)" fontSize="16">培训目的</span>
</strong>
</p>
</li>
<li>
<p lineSpacing="multiple:3">
<strong>
<span color="rgba(255, 255, 255, 1)" fontSize="16">合同签订的注意事项</span>
</strong>
</p>
</li>
<li>
<p lineSpacing="multiple:3">
<strong>
<span color="rgba(255, 255, 255, 1)" fontSize="16">劳动争议风险</span>
</strong>
</p>
</li>
<li>
<p lineSpacing="multiple:3">
<strong>
<span color="rgba(255, 255, 255, 1)" fontSize="16">知识产权及商业机密风险</span>
</strong>
</p>
</li>
<li>
<p lineSpacing="multiple:3">
<strong>
<span color="rgba(255, 255, 255, 1)" fontSize="16">商业道德方面</span>
</strong>
</p>
</li>
</ul>
</content>
</shape>
</data>
<note>
<content/>
</note>
</slide>
<slide>
<style>
<fill>
<fillImg src="NXFgbhXpSoDgJGxhkF4c8vC4n6e" alpha="1" rotateWithShape="false"/>
</fill>
</style>
<data>
<line startX="51" startY="457" endX="904" endY="456">
<border color="linear-gradient(90deg,rgba(4, 58, 138, 1) 0%,rgba(104, 158, 239, 1) 16%,rgba(152, 222, 251, 1) 36%,rgba(255, 157, 11, 1) 66%,rgba(255, 92, 0, 1) 84%,rgba(62, 15, 197, 1) 100%)" lineCap="square" lineJoin="miter" miterLimit="10"/>
</line>
<shape width="347" height="83" topLeftX="34" topLeftY="94" alpha="0.5" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="14" color="rgba(255, 255, 255, 1)">
<p>
<span color="rgba(255, 255, 255, 1)" fontSize="14">请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</span>
</p>
</content>
</shape>
<shape width="895" height="64" topLeftX="34" topLeftY="31" type="text">
<content textType="headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="32" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35">
<p>
<strong>
<span color="rgba(255, 255, 255, 1)" fontSize="32">培训目的</span>
</strong>
</p>
</content>
</shape>
<shape width="422" height="224" topLeftX="512" topLeftY="190" type="text">
<content textType="title" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="200" fontFamily="黑体" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1" textAlign="right">
<p>01</p>
</content>
</shape>
<shape width="10" height="10" topLeftX="65" topLeftY="484" flipX="true" type="round2diag-rect">
<fill>
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
</shape>
<shape width="24" height="10" topLeftX="45" topLeftY="476" rotation="90" flipX="true" type="round2diag-rect">
<fill>
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
</shape>
<shape width="10" height="10" topLeftX="65" topLeftY="470" type="ellipse">
<fill>
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
</shape>
<shape width="202" height="38" topLeftX="702" topLeftY="462" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" fontFamily="undefined" color="rgba(255, 255, 255, 1)" backgroundColor="rgba(0, 0, 0, 0)" bold="false" italic="false" underline="false" strikethrough="false" list="none" listStyle="circle-hollow-square" textAlign="right">
<p>2026年第一季度</p>
</content>
</shape>
</data>
<note>
<content/>
</note>
</slide>
<slide>
<style>
<fill>
<fillColor color="rgba(13, 20, 32, 1)"/>
</fill>
</style>
<data>
<shape width="413" height="326" topLeftX="492" topLeftY="150" presetHandlers="8" flipX="true" alpha="0.2" type="round-rect">
<border color="rgba(170, 170, 170, 1)" width="1" lineJoin="miter" miterLimit="10"/>
</shape>
<shape width="413" height="326" topLeftX="59" topLeftY="150" presetHandlers="8" alpha="0.2" type="round-rect">
<border color="rgba(170, 170, 170, 1)" width="1" lineJoin="miter" miterLimit="10"/>
</shape>
<line startX="59" startY="101" endX="905" endY="101" alpha="0.5">
<border color="rgba(143, 149, 158, 1)" width="1" lineCap="square" lineJoin="miter" miterLimit="10"/>
</line>
<img src="JOdrbcqdUoQvVwxO3bpcScHynPJ" width="382" height="191" topLeftX="508" topLeftY="164">
<crop type="rect" leftOffset="0" rightOffset="0" topOffset="0" bottomOffset="0" presetHandlers="8"/>
</img>
<img src="PIvhbkqeaoY3O8xp9CEco4ZZnvg" width="382" height="191" topLeftX="75" topLeftY="164">
<crop type="rect" leftOffset="0" rightOffset="0" topOffset="0" bottomOffset="0" presetHandlers="8"/>
</img>
<shape width="468" height="53" topLeftX="53" topLeftY="48" type="text">
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="24" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35">
<p>培训目的</p>
</content>
</shape>
<shape width="382" height="56" topLeftX="507" topLeftY="411" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)">
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
</content>
</shape>
<shape width="382" height="56" topLeftX="75" topLeftY="411" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)">
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
</content>
</shape>
<shape width="382" height="44" topLeftX="508" topLeftY="374" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)" bold="true" textAlign="center">
<p>提高自我防护意识和能力</p>
</content>
</shape>
<shape width="382" height="44" topLeftX="75" topLeftY="374" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)" bold="true" textAlign="center">
<p>增强公司员工法律意识以及法律观念</p>
</content>
</shape>
</data>
<note>
<content/>
</note>
</slide>
<slide>
<style>
<fill>
<fillImg src="NXFgbhXpSoDgJGxhkF4c8vC4n6e" alpha="1" rotateWithShape="false"/>
</fill>
</style>
<data>
<line startX="51" startY="457" endX="904" endY="456">
<border color="linear-gradient(90deg,rgba(4, 58, 138, 1) 0%,rgba(104, 158, 239, 1) 16%,rgba(152, 222, 251, 1) 36%,rgba(255, 157, 11, 1) 66%,rgba(255, 92, 0, 1) 84%,rgba(62, 15, 197, 1) 100%)" lineCap="square" lineJoin="miter" miterLimit="10"/>
</line>
<shape width="347" height="83" topLeftX="34" topLeftY="94" alpha="0.5" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="14" color="rgba(255, 255, 255, 1)">
<p>
<span color="rgba(255, 255, 255, 1)" fontSize="14">请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</span>
</p>
</content>
</shape>
<shape width="895" height="64" topLeftX="34" topLeftY="31" type="text">
<content textType="headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="32" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35">
<p>
<strong>
<span color="rgba(255, 255, 255, 1)" fontSize="32">合同签订的注意事项</span>
</strong>
</p>
</content>
</shape>
<shape width="422" height="224" topLeftX="512" topLeftY="190" type="text">
<content textType="title" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="200" fontFamily="黑体" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1" textAlign="right">
<p>02</p>
</content>
</shape>
<shape width="10" height="10" topLeftX="65" topLeftY="484" flipX="true" type="round2diag-rect">
<fill>
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
</shape>
<shape width="24" height="10" topLeftX="45" topLeftY="476" rotation="90" flipX="true" type="round2diag-rect">
<fill>
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
</shape>
<shape width="10" height="10" topLeftX="65" topLeftY="470" type="ellipse">
<fill>
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
</shape>
<shape width="202" height="38" topLeftX="702" topLeftY="462" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" fontFamily="undefined" color="rgba(255, 255, 255, 1)" backgroundColor="rgba(0, 0, 0, 0)" bold="false" italic="false" underline="false" strikethrough="false" list="none" listStyle="circle-hollow-square" textAlign="right">
<p>2026年第一季度</p>
</content>
</shape>
</data>
<note>
<content/>
</note>
</slide>
<slide>
<style>
<fill>
<fillColor color="rgba(13, 20, 32, 1)"/>
</fill>
</style>
<data>
<line startX="59" startY="101" endX="905" endY="101" alpha="0.5">
<border color="rgba(143, 149, 158, 1)" width="1" lineCap="square" lineJoin="miter" miterLimit="10"/>
</line>
<shape width="63" height="68" topLeftX="59" topLeftY="180" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)" textAlign="left">
<p>
<strong>
<span color="rgba(255, 255, 255, 1)" fontSize="32" fontFamily="Arial Black">01</span>
</strong>
</p>
</content>
</shape>
<shape width="63" height="68" topLeftX="60" topLeftY="342" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)" textAlign="left">
<p>
<strong>
<span color="rgba(255, 255, 255, 1)" fontSize="32" fontFamily="Arial Black">02</span>
</strong>
</p>
</content>
</shape>
<shape width="468" height="53" topLeftX="53" topLeftY="48" type="text">
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="24" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35">
<p>合同签订的注意事项</p>
</content>
</shape>
<img src="A0G0btjSBoQYwwxHio7cjyTnnhh" width="344" height="300" topLeftX="561" topLeftY="164">
<crop type="rect" leftOffset="0" rightOffset="0" topOffset="22" bottomOffset="22" presetHandlers="12"/>
</img>
<shape width="341" height="74" topLeftX="59" topLeftY="396" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)">
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
</content>
</shape>
<shape width="341" height="74" topLeftX="59" topLeftY="234" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)">
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
</content>
</shape>
<shape width="411" height="44" topLeftX="115" topLeftY="358" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)" bold="true">
<p>签订合同过程中应注意的问题</p>
</content>
</shape>
<shape width="411" height="44" topLeftX="110" topLeftY="195" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)" bold="true">
<p>合同签订前的调查工作</p>
</content>
</shape>
</data>
<note>
<content/>
</note>
</slide>
<slide>
<style>
<fill>
<fillImg src="NXFgbhXpSoDgJGxhkF4c8vC4n6e" alpha="1" rotateWithShape="false"/>
</fill>
</style>
<data>
<line startX="51" startY="457" endX="904" endY="456">
<border color="linear-gradient(90deg,rgba(4, 58, 138, 1) 0%,rgba(104, 158, 239, 1) 16%,rgba(152, 222, 251, 1) 36%,rgba(255, 157, 11, 1) 66%,rgba(255, 92, 0, 1) 84%,rgba(62, 15, 197, 1) 100%)" lineCap="square" lineJoin="miter" miterLimit="10"/>
</line>
<shape width="347" height="83" topLeftX="34" topLeftY="94" alpha="0.5" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="14" color="rgba(255, 255, 255, 1)">
<p>
<span color="rgba(255, 255, 255, 1)" fontSize="14">请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</span>
</p>
</content>
</shape>
<shape width="895" height="64" topLeftX="34" topLeftY="31" type="text">
<content textType="headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="32" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35">
<p>
<strong>
<span color="rgba(255, 255, 255, 1)" fontSize="32">劳动争议风险</span>
</strong>
</p>
</content>
</shape>
<shape width="422" height="224" topLeftX="512" topLeftY="190" type="text">
<content textType="title" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="200" fontFamily="黑体" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1" textAlign="right">
<p>03</p>
</content>
</shape>
<shape width="10" height="10" topLeftX="65" topLeftY="484" flipX="true" type="round2diag-rect">
<fill>
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
</shape>
<shape width="24" height="10" topLeftX="45" topLeftY="476" rotation="90" flipX="true" type="round2diag-rect">
<fill>
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
</shape>
<shape width="10" height="10" topLeftX="65" topLeftY="470" type="ellipse">
<fill>
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
</shape>
<shape width="202" height="38" topLeftX="702" topLeftY="462" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" fontFamily="undefined" color="rgba(255, 255, 255, 1)" backgroundColor="rgba(0, 0, 0, 0)" bold="false" italic="false" underline="false" strikethrough="false" list="none" listStyle="circle-hollow-square" textAlign="right">
<p>2026年第一季度</p>
</content>
</shape>
</data>
<note>
<content/>
</note>
</slide>
<slide>
<style>
<fill>
<fillColor color="rgba(13, 20, 32, 1)"/>
</fill>
</style>
<data>
<shape width="395" height="395" topLeftX="99" topLeftY="116" type="ellipse">
<border color="linear-gradient(140deg,rgba(0, 20, 51, 1) 0%,rgba(48, 171, 240, 1) 26%,rgba(255, 107, 0, 1) 62%,rgba(0, 16, 102, 1) 100%)" width="6" lineJoin="miter" miterLimit="10"/>
</shape>
<shape width="369" height="369" topLeftX="113" topLeftY="129" type="ellipse">
<border color="linear-gradient(140deg,rgba(0, 20, 51, 1) 0%,rgba(48, 171, 240, 1) 26%,rgba(255, 107, 0, 1) 62%,rgba(0, 16, 102, 1) 100%)" lineJoin="miter" miterLimit="10"/>
</shape>
<shape width="340" height="340" topLeftX="127" topLeftY="144" type="ellipse">
<border color="linear-gradient(140deg,rgba(0, 20, 51, 1) 0%,rgba(48, 171, 240, 1) 26%,rgba(255, 107, 0, 1) 62%,rgba(0, 16, 102, 1) 100%)" lineJoin="miter" miterLimit="10"/>
</shape>
<shape width="395" height="395" topLeftX="465" topLeftY="116" type="ellipse">
<fill>
<fillColor color="rgba(13, 20, 32, 1)"/>
</fill>
<border color="linear-gradient(90deg,rgba(4, 58, 138, 1) 0%,rgba(104, 158, 239, 1) 16%,rgba(152, 222, 251, 1) 36%,rgba(255, 157, 11, 1) 66%,rgba(255, 92, 0, 1) 84%,rgba(62, 15, 197, 1) 100%)" width="6" lineJoin="miter" miterLimit="10"/>
</shape>
<shape width="468" height="53" topLeftX="53" topLeftY="48" type="text">
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="24" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35">
<p>劳动争议风险</p>
</content>
</shape>
<shape width="236" height="74" topLeftX="545" topLeftY="306" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(212, 212, 212, 1)" bold="false" textAlign="center">
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
</content>
</shape>
<shape width="236" height="74" topLeftX="179" topLeftY="305" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)" textAlign="center">
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
</content>
</shape>
<shape width="395" height="49" topLeftX="465" topLeftY="250" type="text">
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="21" color="rgba(212, 212, 212, 1)" bold="true" lineSpacing="multiple:1.35" textAlign="center">
<p>合同内容与工资</p>
</content>
</shape>
<shape width="395" height="49" topLeftX="99" topLeftY="250" type="text">
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="21" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35" textAlign="center">
<p>劳动合约签约的时间</p>
</content>
</shape>
</data>
<note>
<content/>
</note>
</slide>
<slide>
<style>
<fill>
<fillColor color="rgba(13, 20, 32, 1)"/>
</fill>
</style>
<data>
<line startX="59" startY="101" endX="905" endY="101" alpha="0.5">
<border color="rgba(143, 149, 158, 1)" width="1" lineCap="square" lineJoin="miter" miterLimit="10"/>
</line>
<shape width="258" height="287" topLeftX="645" topLeftY="165" presetHandlers="8" alpha="0.2" type="round-rect">
<border color="rgba(170, 170, 170, 1)" width="1" lineJoin="miter" miterLimit="10"/>
</shape>
<shape width="258" height="287" topLeftX="351" topLeftY="165" presetHandlers="8" alpha="0.2" type="round-rect">
<border color="rgba(170, 170, 170, 1)" width="1" lineJoin="miter" miterLimit="10"/>
</shape>
<shape width="258" height="287" topLeftX="57" topLeftY="165" presetHandlers="8" alpha="0.2" type="round-rect">
<border color="rgba(170, 170, 170, 1)" width="1" lineJoin="miter" miterLimit="10"/>
</shape>
<img src="Qd97bGCM1oeyAQxRUmvcPUJjnyb" width="231" height="116" topLeftX="658" topLeftY="181">
<crop type="rect" leftOffset="0" rightOffset="0" topOffset="0.02" bottomOffset="0.02" presetHandlers="8"/>
</img>
<img src="Z5ldbgltxoBN0XxyNxVcV2tWnWb" width="231" height="116" topLeftX="364" topLeftY="181">
<crop type="rect" leftOffset="0" rightOffset="0" topOffset="0.02" bottomOffset="0.02" presetHandlers="8"/>
</img>
<img src="YXs0befYaoW0X5xtARpcuGT0nCb" width="231" height="116" topLeftX="71" topLeftY="181">
<crop type="rect" leftOffset="0" rightOffset="0" topOffset="0.02" bottomOffset="0.02" presetHandlers="8"/>
</img>
<shape width="231" height="92" topLeftX="658" topLeftY="355" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)" textAlign="center">
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
</content>
</shape>
<shape width="231" height="92" topLeftX="364" topLeftY="355" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)" textAlign="center">
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
</content>
</shape>
<shape width="231" height="92" topLeftX="71" topLeftY="355" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)" textAlign="center">
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
</content>
</shape>
<shape width="258" height="44" topLeftX="645" topLeftY="313" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)" bold="true" textAlign="center">
<p>解除劳动合同</p>
</content>
</shape>
<shape width="258" height="44" topLeftX="351" topLeftY="313" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)" bold="true" textAlign="center">
<p>工伤</p>
</content>
</shape>
<shape width="258" height="44" topLeftX="57" topLeftY="313" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)" bold="true" textAlign="center">
<p>合同约定内容</p>
</content>
</shape>
<shape width="468" height="53" topLeftX="53" topLeftY="48" type="text">
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="24" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35">
<p>劳动争议风险</p>
</content>
</shape>
</data>
<note>
<content/>
</note>
</slide>
<slide>
<style>
<fill>
<fillImg src="NXFgbhXpSoDgJGxhkF4c8vC4n6e" alpha="1" rotateWithShape="false"/>
</fill>
</style>
<data>
<line startX="51" startY="457" endX="904" endY="456">
<border color="linear-gradient(90deg,rgba(4, 58, 138, 1) 0%,rgba(104, 158, 239, 1) 16%,rgba(152, 222, 251, 1) 36%,rgba(255, 157, 11, 1) 66%,rgba(255, 92, 0, 1) 84%,rgba(62, 15, 197, 1) 100%)" lineCap="square" lineJoin="miter" miterLimit="10"/>
</line>
<shape width="347" height="83" topLeftX="34" topLeftY="94" alpha="0.5" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="14" color="rgba(255, 255, 255, 1)">
<p>
<span color="rgba(255, 255, 255, 1)" fontSize="14">请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</span>
</p>
</content>
</shape>
<shape width="895" height="64" topLeftX="34" topLeftY="31" type="text">
<content textType="headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="32" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35">
<p>
<strong>
<span color="rgba(255, 255, 255, 1)" fontSize="32">知识产权及商业机密风险</span>
</strong>
</p>
</content>
</shape>
<shape width="422" height="224" topLeftX="512" topLeftY="190" type="text">
<content textType="title" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="200" fontFamily="黑体" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1" textAlign="right">
<p>04</p>
</content>
</shape>
<shape width="202" height="38" topLeftX="702" topLeftY="462" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" fontFamily="undefined" color="rgba(255, 255, 255, 1)" backgroundColor="rgba(0, 0, 0, 0)" bold="false" italic="false" underline="false" strikethrough="false" list="none" listStyle="circle-hollow-square" textAlign="right" autoFit="shape-auto-fit">
<p>2026年第一季度</p>
</content>
</shape>
<shape width="10" height="10" topLeftX="65" topLeftY="484" flipX="true" type="round2diag-rect">
<fill>
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
</shape>
<shape width="24" height="10" topLeftX="45" topLeftY="476" rotation="90" flipX="true" type="round2diag-rect">
<fill>
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
</shape>
<shape width="10" height="10" topLeftX="65" topLeftY="470" type="ellipse">
<fill>
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
</shape>
</data>
<note>
<content/>
</note>
</slide>
<slide>
<style>
<fill>
<fillColor color="rgba(13, 20, 32, 1)"/>
</fill>
</style>
<data>
<line startX="59" startY="101" endX="905" endY="101" alpha="0.5">
<border color="rgba(143, 149, 158, 1)" width="1" lineCap="square" lineJoin="miter" miterLimit="10"/>
</line>
<shape width="278" height="278" topLeftX="627" topLeftY="202" presetHandlers="8" alpha="0.5" type="round-rect">
<border color="rgba(170, 170, 170, 1)" width="1" lineJoin="miter" miterLimit="10"/>
</shape>
<line startX="649" startY="303" endX="883" endY="304" alpha="0.25">
<border color="rgba(143, 149, 158, 1)" width="1" lineCap="square" lineJoin="miter" miterLimit="10"/>
</line>
<icon width="62" height="62" topLeftX="644" topLeftY="221" iconType="iconpark/Safe/protect.svg">
<border color="linear-gradient(140deg,rgba(0, 20, 51, 1) 0%,rgba(48, 171, 240, 1) 26%,rgba(255, 107, 0, 1) 62%,rgba(0, 16, 102, 1) 100%)" width="2" lineJoin="miter" miterLimit="10"/>
</icon>
<shape width="278" height="278" topLeftX="59" topLeftY="202" presetHandlers="8" alpha="0.5" type="round-rect">
<border color="rgba(170, 170, 170, 1)" width="1" lineJoin="miter" miterLimit="10"/>
</shape>
<line startX="81" startY="303" endX="315" endY="304" alpha="0.25">
<border color="rgba(143, 149, 158, 1)" width="1" lineCap="square" lineJoin="miter" miterLimit="10"/>
</line>
<icon width="62" height="62" topLeftX="76" topLeftY="221" iconType="iconpark/Clothes/bachelor-cap-one.svg">
<border color="linear-gradient(140deg,rgba(0, 20, 51, 1) 0%,rgba(48, 171, 240, 1) 26%,rgba(255, 107, 0, 1) 62%,rgba(0, 16, 102, 1) 100%)" width="2" lineJoin="miter" miterLimit="10"/>
</icon>
<shape width="278" height="278" topLeftX="343" topLeftY="202" presetHandlers="8" alpha="0.5" type="round-rect">
<border color="rgba(170, 170, 170, 1)" width="1" lineJoin="miter" miterLimit="10"/>
</shape>
<line startX="365" startY="303" endX="599" endY="304" alpha="0.25">
<border color="rgba(143, 149, 158, 1)" width="1" lineCap="square" lineJoin="miter" miterLimit="10"/>
</line>
<icon width="62" height="62" topLeftX="360" topLeftY="221" iconType="iconpark/Edit/bring-to-front-one.svg">
<border color="linear-gradient(140deg,rgba(0, 20, 51, 1) 0%,rgba(48, 171, 240, 1) 26%,rgba(255, 107, 0, 1) 62%,rgba(0, 16, 102, 1) 100%)" width="2" lineJoin="miter" miterLimit="10"/>
</icon>
<shape width="230" height="38" topLeftX="713" topLeftY="222" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)" bold="true">
<p>Topic 03</p>
</content>
</shape>
<shape width="230" height="38" topLeftX="145" topLeftY="222" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)" bold="true">
<p>Topic 01</p>
</content>
</shape>
<shape width="468" height="53" topLeftX="53" topLeftY="48" type="text">
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="24" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35">
<p>知识产权及商业机密风险</p>
</content>
</shape>
<shape width="230" height="38" topLeftX="429" topLeftY="221" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)" bold="true">
<p>Topic 02</p>
</content>
</shape>
<shape width="228" height="44" topLeftX="145" topLeftY="245" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)" bold="true">
<p>什么是知识产权?</p>
</content>
</shape>
<shape width="237" height="92" topLeftX="649" topLeftY="324" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)">
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
</content>
</shape>
<shape width="237" height="92" topLeftX="361" topLeftY="324" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)">
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
</content>
</shape>
<shape width="237" height="92" topLeftX="78" topLeftY="324" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)">
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
</content>
</shape>
<shape width="228" height="44" topLeftX="713" topLeftY="245" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)" bold="true">
<p>风险防范</p>
</content>
</shape>
<shape width="228" height="44" topLeftX="429" topLeftY="245" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)" bold="true">
<p>专利的定义</p>
</content>
</shape>
</data>
<note>
<content/>
</note>
</slide>
<slide>
<style>
<fill>
<fillImg src="NXFgbhXpSoDgJGxhkF4c8vC4n6e" alpha="1" rotateWithShape="false"/>
</fill>
</style>
<data>
<line startX="51" startY="457" endX="904" endY="456">
<border color="linear-gradient(90deg,rgba(4, 58, 138, 1) 0%,rgba(104, 158, 239, 1) 16%,rgba(152, 222, 251, 1) 36%,rgba(255, 157, 11, 1) 66%,rgba(255, 92, 0, 1) 84%,rgba(62, 15, 197, 1) 100%)" lineCap="square" lineJoin="miter" miterLimit="10"/>
</line>
<shape width="347" height="83" topLeftX="34" topLeftY="94" alpha="0.5" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="14" color="rgba(255, 255, 255, 1)">
<p>
<span color="rgba(255, 255, 255, 1)" fontSize="14">请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</span>
</p>
</content>
</shape>
<shape width="895" height="64" topLeftX="34" topLeftY="31" type="text">
<content textType="headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="32" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35">
<p>
<strong>
<span color="rgba(255, 255, 255, 1)" fontSize="32">商业道德方面</span>
</strong>
</p>
</content>
</shape>
<shape width="422" height="224" topLeftX="512" topLeftY="190" type="text">
<content textType="title" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="200" fontFamily="黑体" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1" textAlign="right">
<p>05</p>
</content>
</shape>
<shape width="202" height="38" topLeftX="702" topLeftY="462" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" fontFamily="undefined" color="rgba(255, 255, 255, 1)" backgroundColor="rgba(0, 0, 0, 0)" bold="false" italic="false" underline="false" strikethrough="false" list="none" listStyle="circle-hollow-square" textAlign="right" autoFit="shape-auto-fit">
<p>2026年第一季度</p>
</content>
</shape>
<shape width="10" height="10" topLeftX="65" topLeftY="484" flipX="true" type="round2diag-rect">
<fill>
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
</shape>
<shape width="24" height="10" topLeftX="45" topLeftY="476" rotation="90" flipX="true" type="round2diag-rect">
<fill>
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
</shape>
<shape width="10" height="10" topLeftX="65" topLeftY="470" type="ellipse">
<fill>
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
</shape>
</data>
<note>
<content/>
</note>
</slide>
<slide>
<style>
<fill>
<fillColor color="rgba(13, 20, 32, 1)"/>
</fill>
</style>
<data>
<line startX="59" startY="101" endX="905" endY="101" alpha="0.5">
<border color="rgba(143, 149, 158, 1)" width="1" lineCap="square" lineJoin="miter" miterLimit="10"/>
</line>
<line startX="64" startY="188" endX="185" endY="188">
<border color="linear-gradient(90deg,rgba(16, 0, 81, 1) 0%,rgba(62, 0, 239, 1) 100%)" width="6" lineCap="square" lineJoin="miter" miterLimit="10"/>
</line>
<line startX="242" startY="188" endX="363" endY="188">
<border color="linear-gradient(90deg,rgba(62, 0, 239, 1) 0%,rgba(48, 206, 196, 1) 100%)" width="6" lineCap="square" lineJoin="miter" miterLimit="10"/>
</line>
<line startX="414" startY="188" endX="535" endY="188">
<border color="linear-gradient(90deg,rgba(48, 206, 196, 1) 0%,rgba(255, 122, 0, 1) 100%)" width="6" lineCap="square" lineJoin="miter" miterLimit="10"/>
</line>
<line startX="591" startY="188" endX="712" endY="188">
<border color="linear-gradient(90deg,rgba(255, 122, 0, 1) 0%,rgba(152, 16, 174, 1) 100%)" width="6" lineCap="square" lineJoin="miter" miterLimit="10"/>
</line>
<line startX="770" startY="188" endX="891" endY="188">
<border color="linear-gradient(90deg,rgba(152, 16, 174, 1) 0%,rgba(5, 0, 36, 1) 100%)" width="6" lineCap="square" lineJoin="miter" miterLimit="10"/>
</line>
<shape width="230" height="41" topLeftX="685" topLeftY="64" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="14">
<p>公司名字</p>
</content>
</shape>
<shape width="468" height="53" topLeftX="53" topLeftY="48" type="text">
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="24" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35">
<p>商业道德标准</p>
</content>
</shape>
<shape width="132" height="200" topLeftX="765" topLeftY="254" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)">
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
</content>
</shape>
<shape width="132" height="200" topLeftX="586" topLeftY="254" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)">
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
</content>
</shape>
<shape width="132" height="200" topLeftX="409" topLeftY="254" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)">
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
</content>
</shape>
<shape width="132" height="200" topLeftX="237" topLeftY="254" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)">
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
</content>
</shape>
<shape width="132" height="200" topLeftX="59" topLeftY="254" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)">
<p>请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字请在此处编辑文字</p>
</content>
</shape>
<shape width="131" height="53" topLeftX="765" topLeftY="192" type="text">
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="24" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35">
<p>05</p>
</content>
</shape>
<shape width="131" height="53" topLeftX="586" topLeftY="192" type="text">
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="24" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35">
<p>04</p>
</content>
</shape>
<shape width="131" height="53" topLeftX="409" topLeftY="192" type="text">
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="24" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35">
<p>03</p>
</content>
</shape>
<shape width="131" height="53" topLeftX="237" topLeftY="192" type="text">
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="24" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35">
<p>02</p>
</content>
</shape>
<shape width="131" height="53" topLeftX="59" topLeftY="192" type="text">
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="24" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.35">
<p>01</p>
</content>
</shape>
</data>
<note>
<content/>
</note>
</slide>
<slide>
<style>
<fill>
<fillImg src="YBLRbyEjxo8hiIxsQaFcYLSmnQc" alpha="1" rotateWithShape="false"/>
</fill>
</style>
<data>
<line startX="51" startY="457" endX="904" endY="456">
<border color="rgba(143, 149, 158, 1)" width="1" lineCap="square" lineJoin="miter" miterLimit="10"/>
</line>
<shape width="230" height="41" topLeftX="684" topLeftY="28" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="14">
<p>公司名字</p>
</content>
</shape>
<shape width="678" height="116" topLeftX="236" topLeftY="302" type="text">
<content textType="title" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="80" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.2" textAlign="right">
<p>
<strong>
<span color="rgba(255, 255, 255, 1)" fontSize="80">谢谢观看</span>
</strong>
</p>
</content>
</shape>
<shape width="16" height="15" topLeftX="78" topLeftY="60" flipX="true" type="round2diag-rect">
<fill>
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
</shape>
<shape width="36" height="16" topLeftX="48" topLeftY="50" rotation="90" flipX="true" type="round2diag-rect">
<fill>
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
</shape>
<shape width="16" height="15" topLeftX="78" topLeftY="39" type="ellipse">
<fill>
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
</shape>
<shape width="185" height="38" topLeftX="51" topLeftY="461" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" fontFamily="undefined" color="rgba(255, 255, 255, 1)" backgroundColor="rgba(0, 0, 0, 0)" bold="false" italic="false" underline="false" strikethrough="false" list="none" listStyle="circle-hollow-square" textAlign="left" autoFit="shape-auto-fit">
<p list="none" textAlign="left">
<span color="rgba(255, 255, 255, 1)" backgroundColor="rgba(0, 0, 0, 0)" fontSize="12" fontFamily="undefined" bold="false" italic="false" strikethrough="false" underline="false">A座32楼会议室</span>
</p>
</content>
</shape>
<shape width="202" height="38" topLeftX="722" topLeftY="461" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" fontFamily="undefined" color="rgba(255, 255, 255, 1)" backgroundColor="rgba(0, 0, 0, 0)" bold="false" italic="false" underline="false" strikethrough="false" list="none" listStyle="circle-hollow-square" textAlign="right" autoFit="shape-auto-fit">
<p list="none" textAlign="right">
<span color="rgba(255, 255, 255, 1)" backgroundColor="rgba(0, 0, 0, 0)" fontSize="12" fontFamily="undefined" bold="false" italic="false" strikethrough="false" underline="false">2026年第一季度</span>
</p>
</content>
</shape>
<shape width="202" height="42" topLeftX="712" topLeftY="28" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="14" fontFamily="undefined" color="rgba(255, 255, 255, 1)" backgroundColor="rgba(0, 0, 0, 0)" bold="false" italic="false" underline="false" strikethrough="false" list="none" listStyle="circle-hollow-square" textAlign="right" autoFit="shape-auto-fit">
<p list="none" textAlign="right">公司名字</p>
</content>
</shape>
</data>
<note>
<content/>
</note>
</slide>
</presentation>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,933 @@
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<title>新人入职培训</title>
<theme>
<textStyles>
<title fontColor="#000000FF" fontSize="60"/>
<headline fontColor="#000000FF" fontSize="54"/>
<sub-headline fontColor="#000000FF"/>
<body fontColor="#000000FF"/>
<caption fontColor="#808080FF"/>
</textStyles>
</theme>
<slide>
<style>
<fill>
<fillImg src="SC2Wbh3kaopv4Jxk1cXc4laHnyd" alpha="1" rotateWithShape="false"/>
</fill>
</style>
<data>
<shape width="960" height="540" topLeftX="0" topLeftY="0" presetHandlers="0" alpha="0.63" type="rect">
<fill>
<fillColor color="rgba(75, 63, 221, 1)"/>
</fill>
</shape>
<shape width="312" height="56" topLeftX="324" topLeftY="289" alpha="0.5" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)" textAlign="center">
<p>
<span color="rgba(255, 255, 255, 1)" fontSize="12">描述相关的信息以解释你的标题。现在就开始打字吧。写任何你想表达的内容</span>
</p>
</content>
</shape>
<shape width="290" height="92" topLeftX="335" topLeftY="197" type="text">
<content textType="title" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="60" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.2" textAlign="center">
<p>新人培训</p>
</content>
</shape>
<shape width="900" height="40" topLeftX="30" topLeftY="476" presetHandlers="48" type="rect">
<fill>
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
</shape>
<shape width="30" height="30" topLeftX="36" topLeftY="481" type="ellipse">
<fill>
<fillColor color="rgba(75, 63, 221, 1)"/>
</fill>
</shape>
<icon width="18" height="18" topLeftX="43" topLeftY="487" iconType="iconpark/Arrows/arrow-right.svg">
<border color="rgba(255, 255, 255, 1)" width="1" lineJoin="miter" miterLimit="10"/>
</icon>
<shape width="126" height="44" topLeftX="66" topLeftY="474" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(44, 40, 64, 1)" textAlign="left">
<p>
<span color="rgba(44, 40, 64, 1)" fontSize="16">进入今日议程</span>
</p>
</content>
</shape>
<shape width="127" height="44" topLeftX="795" topLeftY="474" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(44, 40, 64, 1)" textAlign="right">
<p>
<span color="rgba(44, 40, 64, 1)" fontSize="16">2026.05.04</span>
</p>
</content>
</shape>
</data>
<note>
<content/>
</note>
</slide>
<slide>
<style/>
<data>
<shape width="100" height="68" topLeftX="59" topLeftY="47" type="text">
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="32" bold="true">
<p>目录</p>
</content>
</shape>
<shape width="311" height="56" topLeftX="59" topLeftY="115" alpha="0.5" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12">
<p>
<span fontSize="12">描述相关的信息以解释你的标题。现在就开始打字吧。写任何你想表达的内容</span>
</p>
</content>
</shape>
<shape width="70" height="70" topLeftX="62" topLeftY="267" type="ellipse">
<fill>
<fillColor color="rgba(44, 40, 64, 1)"/>
</fill>
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="18">
<p>
<strong>
<span color="rgba(255, 255, 255, 1)" fontSize="18">1</span>
</strong>
</p>
</content>
</shape>
<line startX="153" startY="370" endX="471" endY="371" alpha="0.15">
<border width="1" lineCap="square" lineJoin="miter" miterLimit="10"/>
</line>
<shape width="70" height="70" topLeftX="62" topLeftY="385" type="ellipse">
<fill>
<fillColor color="rgba(44, 40, 64, 1)"/>
</fill>
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="18">
<p>
<strong>
<span color="rgba(255, 255, 255, 1)" fontSize="18">3</span>
</strong>
</p>
</content>
</shape>
<line startX="581" startY="370" endX="898" endY="371" alpha="0.15">
<border width="1" lineCap="square" lineJoin="miter" miterLimit="10"/>
</line>
<shape width="70" height="70" topLeftX="489" topLeftY="267" type="ellipse">
<fill>
<fillColor color="rgba(44, 40, 64, 1)"/>
</fill>
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="18">
<p>
<strong>
<span color="rgba(255, 255, 255, 1)" fontSize="18">2</span>
</strong>
</p>
</content>
</shape>
<shape width="70" height="70" topLeftX="489" topLeftY="385" type="ellipse">
<fill>
<fillColor color="rgba(44, 40, 64, 1)"/>
</fill>
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="18">
<p>
<strong>
<span color="rgba(255, 255, 255, 1)" fontSize="18">4</span>
</strong>
</p>
</content>
</shape>
<shape width="100" height="44" topLeftX="153" topLeftY="267" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" bold="true">
<p>
<strong>公司概况</strong>
</p>
</content>
</shape>
<shape width="100" height="44" topLeftX="153" topLeftY="385" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" bold="true">
<p>
<strong>规章制度</strong>
</p>
</content>
</shape>
<shape width="311" height="56" topLeftX="153" topLeftY="302" alpha="0.5" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12">
<p>
<span fontSize="12">描述相关的信息以解释你的标题。现在就开始打字吧。写任何你想表达的内容</span>
</p>
</content>
</shape>
<shape width="311" height="56" topLeftX="153" topLeftY="420" alpha="0.5" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12">
<p>
<span fontSize="12">描述相关的信息以解释你的标题。现在就开始打字吧。写任何你想表达的内容</span>
</p>
</content>
</shape>
<shape width="100" height="44" topLeftX="581" topLeftY="270" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" bold="true">
<p>
<strong>开始旅程</strong>
</p>
</content>
</shape>
<shape width="311" height="56" topLeftX="581" topLeftY="305" alpha="0.5" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12">
<p>
<span fontSize="12">描述相关的信息以解释你的标题。现在就开始打字吧。写任何你想表达的内容</span>
</p>
</content>
</shape>
<shape width="100" height="44" topLeftX="581" topLeftY="385" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" bold="true">
<p>
<strong>公司福利</strong>
</p>
</content>
</shape>
<shape width="311" height="56" topLeftX="581" topLeftY="420" alpha="0.5" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12">
<p>
<span fontSize="12">描述相关的信息以解释你的标题。现在就开始打字吧。写任何你想表达的内容</span>
</p>
</content>
</shape>
</data>
<note>
<content/>
</note>
</slide>
<slide>
<style>
<fill>
<fillColor color="rgba(75, 63, 221, 1)"/>
</fill>
</style>
<data>
<img src="XWWzbxvZmoztg2xETpEcw22mnEd" width="328" height="432" topLeftX="571" topLeftY="54">
<crop type="rect" leftOffset="0.05" rightOffset="0.05" topOffset="0" bottomOffset="0" presetHandlers="24"/>
</img>
<shape width="381" height="44" topLeftX="67" topLeftY="260" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)">
<p>
<span color="rgba(255, 255, 255, 1)" fontSize="16">请输入相关描述信息以解释你的标题</span>
</p>
</content>
</shape>
<shape width="485" height="85" topLeftX="62" topLeftY="294" type="text">
<content textType="headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="54" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.2">
<p>公司概况</p>
</content>
</shape>
</data>
<note>
<content/>
</note>
</slide>
<slide>
<style/>
<data>
<img src="LHhDbYrgZo4vVVxiVmZc85dDn6d" width="194" height="236" topLeftX="487" topLeftY="229">
<crop type="rect" leftOffset="0" rightOffset="0" topOffset="39" bottomOffset="39" presetHandlers="18"/>
</img>
<img src="AooSbNkPko0FWUxrsAJchJgen5j" width="194" height="316" topLeftX="706" topLeftY="55" saturation="11">
<crop type="rect" leftOffset="0.8" rightOffset="0.8" topOffset="0" bottomOffset="0" presetHandlers="18"/>
</img>
<img src="B0aKbJkekobG8exkYDIcZlcrnRd" width="194" height="236" topLeftX="487" topLeftY="-27">
<crop type="rect" leftOffset="0" rightOffset="0" topOffset="3" bottomOffset="3" presetHandlers="18"/>
</img>
<img src="WGEZb10uxoKMfoxPIHNcha7anxe" width="194" height="164" topLeftX="706" topLeftY="391" saturation="34">
<crop type="rect" leftOffset="56" rightOffset="18" topOffset="32" bottomOffset="161" presetHandlers="18"/>
</img>
<shape width="240" height="68" topLeftX="65" topLeftY="116" type="text">
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="32" bold="true">
<p>创始人的故事</p>
</content>
</shape>
<shape width="354" height="164" topLeftX="65" topLeftY="203" alpha="0.5" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16">
<p>
<span fontSize="16">这里可以展示创始人的职业经历。描述他如何在各种困难的条件下建立了公司</span>
</p>
<p/>
<p>
<span fontSize="16">描述他的理念和梦想,及为此付出的努力</span>
</p>
<p/>
<p>
<span fontSize="16">描述公司取得的成绩及下一个 10 年的目标规划</span>
</p>
</content>
</shape>
</data>
</slide>
<slide>
<style>
<fill>
<fillImg src="Q9D9b6hOnoRxsgxSwU0cx3tAn1c" alpha="1" rotateWithShape="false"/>
</fill>
</style>
<data>
<shape width="960" height="540" topLeftX="0" topLeftY="0" presetHandlers="0" alpha="0.7" type="rect">
<fill>
<fillColor color="rgba(75, 63, 221, 1)"/>
</fill>
</shape>
<shape width="131" height="42" topLeftX="56" topLeftY="36" alpha="0.5" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="18" color="rgba(255, 255, 255, 1)" lineSpacing="multiple:1.2" letterSpacing="2" textAlign="left">
<p lineSpacing="multiple:1.2" letterSpacing="2">
<span color="rgba(255, 255, 255, 1)" fontSize="18">公司愿景</span>
</p>
</content>
</shape>
<shape width="53" height="53" topLeftX="56" topLeftY="429" type="ellipse">
<fill>
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
</shape>
<icon width="32" height="32" topLeftX="67" topLeftY="439" iconType="iconpark/Arrows/arrow-right.svg">
<border color="rgba(75, 63, 221, 1)" width="2" lineJoin="miter" miterLimit="10"/>
</icon>
<shape width="822" height="164" topLeftX="56" topLeftY="98" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="32" color="rgba(255, 255, 255, 1)" bold="true">
<p letterSpacing="1">
<strong>
<span color="rgba(255, 255, 255, 1)" fontSize="32">如果你做了一件事,结果还不错,那么你应该去做其他精彩的事,不要在上面纠缠太久。只要想清楚下一步是什么</span>
</strong>
</p>
</content>
</shape>
</data>
</slide>
<slide>
<style/>
<data>
<shape width="210" height="264" topLeftX="55" topLeftY="178" presetHandlers="18" type="round-rect">
<fill>
<fillColor color="rgba(75, 63, 221, 1)"/>
</fill>
</shape>
<shape width="82" height="82" topLeftX="70" topLeftY="191" type="ellipse">
<fill>
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
</shape>
<icon width="43" height="43" topLeftX="90" topLeftY="211" iconType="iconpark/Abstract/six-points.svg">
<border color="rgba(75, 63, 221, 1)" width="2" lineJoin="miter" miterLimit="10"/>
</icon>
<shape width="180" height="74" topLeftX="70" topLeftY="351" alpha="0.5" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)">
<p>
<span color="rgba(255, 255, 255, 1)" fontSize="12">描述相关的信息以解释你的标题。现在就开始打字吧。写任何你想表达的内容</span>
</p>
</content>
</shape>
<shape width="180" height="44" topLeftX="70" topLeftY="317" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16">
<p>
<span color="rgba(255, 255, 255, 1)" fontSize="16">始终创业</span>
</p>
</content>
</shape>
<shape width="210" height="264" topLeftX="268" topLeftY="178" presetHandlers="18" type="round-rect">
<fill>
<fillColor color="rgba(44, 40, 64, 1)"/>
</fill>
</shape>
<shape width="82" height="82" topLeftX="283" topLeftY="191" type="ellipse">
<fill>
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
</shape>
<icon width="43" height="43" topLeftX="303" topLeftY="211" iconType="iconpark/Abstract/cylinder.svg">
<border color="rgba(44, 40, 64, 1)" width="3" lineJoin="miter" miterLimit="10"/>
</icon>
<shape width="180" height="44" topLeftX="283" topLeftY="317" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16">
<p>
<span color="rgba(255, 255, 255, 1)" fontSize="16">坦诚清晰</span>
</p>
</content>
</shape>
<shape width="180" height="74" topLeftX="283" topLeftY="351" alpha="0.5" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12">
<p>
<span color="rgba(255, 255, 255, 1)" fontSize="12">描述相关的信息以解释你的标题。现在就开始打字吧。写任何你想表达的内容</span>
</p>
</content>
</shape>
<shape width="210" height="264" topLeftX="482" topLeftY="178" presetHandlers="18" type="round-rect">
<fill>
<fillColor color="rgba(75, 63, 221, 1)"/>
</fill>
</shape>
<shape width="82" height="82" topLeftX="497" topLeftY="191" type="ellipse">
<fill>
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
</shape>
<icon width="43" height="43" topLeftX="516" topLeftY="211" iconType="iconpark/Abstract/game-emoji.svg">
<border color="rgba(75, 63, 221, 1)" width="2" lineJoin="miter" miterLimit="10"/>
</icon>
<shape width="180" height="44" topLeftX="497" topLeftY="317" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16">
<p>
<span color="rgba(255, 255, 255, 1)" fontSize="16">多元兼容</span>
</p>
</content>
</shape>
<shape width="180" height="74" topLeftX="497" topLeftY="351" alpha="0.5" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12">
<p>
<span color="rgba(255, 255, 255, 1)" fontSize="12">描述相关的信息以解释你的标题。现在就开始打字吧。写任何你想表达的内容</span>
</p>
</content>
</shape>
<shape width="210" height="264" topLeftX="695" topLeftY="178" presetHandlers="18" type="round-rect">
<fill>
<fillColor color="rgba(44, 40, 64, 1)"/>
</fill>
</shape>
<shape width="82" height="82" topLeftX="710" topLeftY="191" type="ellipse">
<fill>
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
</shape>
<icon width="43" height="43" topLeftX="729" topLeftY="211" iconType="iconpark/Abstract/oval-love-two.svg">
<border color="rgba(44, 40, 64, 1)" width="2" lineJoin="miter" miterLimit="10"/>
</icon>
<shape width="180" height="44" topLeftX="710" topLeftY="317" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16">
<p>
<span color="rgba(255, 255, 255, 1)" fontSize="16">求真务实</span>
</p>
</content>
</shape>
<shape width="180" height="74" topLeftX="710" topLeftY="351" alpha="0.5" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12">
<p>
<span color="rgba(255, 255, 255, 1)" fontSize="12">描述相关的信息以解释你的标题。现在就开始打字吧。写任何你想表达的内容</span>
</p>
</content>
</shape>
<shape width="456" height="38" topLeftX="252" topLeftY="108" alpha="0.5" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" textAlign="center">
<p>
<span fontSize="12">描述相关的信息以解释你的标题。现在就开始打字吧。写任何你想表达的内容</span>
</p>
</content>
</shape>
<shape width="487" height="68" topLeftX="236" topLeftY="46" type="text">
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="32" bold="true" textAlign="center">
<p>价值观</p>
</content>
</shape>
</data>
<note>
<content/>
</note>
</slide>
<slide>
<style>
<fill>
<fillColor color="rgba(75, 63, 221, 1)"/>
</fill>
</style>
<data>
<img src="U9DGbgUQmo8Vg2xdYyfcLVKunqb" width="328" height="432" topLeftX="571" topLeftY="54">
<crop type="rect" leftOffset="52" rightOffset="52" topOffset="0" bottomOffset="0" presetHandlers="24"/>
</img>
<shape width="381" height="44" topLeftX="67" topLeftY="260" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)">
<p>
<span color="rgba(255, 255, 255, 1)" fontSize="16">请输入相关描述信息以解释你的标题</span>
</p>
</content>
</shape>
<shape width="485" height="85" topLeftX="62" topLeftY="294" type="text">
<content textType="headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="54" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.2">
<p>
<strong>
<span color="rgba(255, 255, 255, 1)" fontSize="54">开始你的旅程</span>
</strong>
</p>
</content>
</shape>
</data>
<note>
<content/>
</note>
</slide>
<slide>
<style/>
<data>
<line startX="558" startY="404" endX="839" endY="403" alpha="0.5">
<border color="rgba(143, 149, 158, 1)" dashArray="dash" width="1" lineCap="square" lineJoin="miter" miterLimit="10"/>
</line>
<shape width="367" height="92" topLeftX="62" topLeftY="367" alpha="0.5" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(44, 40, 64, 1)" textAlign="left">
<p>
<span color="rgba(44, 40, 64, 1)" fontSize="12">当开始时,我们会进行头脑风暴,分享彼此的想法,并从中激发灵感。在经过一系列的讨论后,开始制定策略,拟定计划。并开始进行分工合作,明确各个成员的职责。最后定期复盘进展,确保计划顺利执行。</span>
</p>
</content>
</shape>
<img src="KImsboGLOoVLYexRUlScDoygnud" width="849" height="201" topLeftX="60" topLeftY="48" exposure="-16" contrast="19" saturation="-100">
<crop type="rect" leftOffset="0" rightOffset="0" topOffset="256" bottomOffset="660" presetHandlers="18"/>
</img>
<shape width="180" height="58" topLeftX="144" topLeftY="289" type="text">
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="32" textAlign="left">
<p lineSpacing="multiple:1.2">
<strong>
<span color="rgba(31, 35, 41, 1)" fontSize="32">入职流程</span>
</strong>
</p>
</content>
</shape>
<icon width="60" height="60" topLeftX="73" topLeftY="283" iconType="iconpark/Travel/cable-car.svg">
<border width="2" lineJoin="miter" miterLimit="10"/>
</icon>
<shape width="12" height="12" topLeftX="550" topLeftY="398" type="ellipse">
<fill>
<fillColor color="rgba(75, 63, 221, 1)"/>
</fill>
</shape>
<shape width="12" height="12" topLeftX="645" topLeftY="398" type="ellipse">
<fill>
<fillColor color="rgba(44, 40, 64, 1)"/>
</fill>
</shape>
<shape width="12" height="12" topLeftX="739" topLeftY="398" type="ellipse">
<fill>
<fillColor color="rgba(44, 40, 64, 1)"/>
</fill>
</shape>
<shape width="12" height="12" topLeftX="833" topLeftY="398" type="ellipse">
<fill>
<fillColor color="rgba(44, 40, 64, 1)"/>
</fill>
</shape>
<shape width="76" height="38" topLeftX="541" topLeftY="412" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(44, 40, 64, 1)" bold="true" textAlign="left">
<p>
<strong>
<span color="rgba(44, 40, 64, 1)" fontSize="12">签订合同</span>
</strong>
</p>
</content>
</shape>
<shape width="76" height="38" topLeftX="635" topLeftY="412" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(44, 40, 64, 1)" bold="true" textAlign="left">
<p>
<strong>
<span color="rgba(44, 40, 64, 1)" fontSize="12">入职手续</span>
</strong>
</p>
</content>
</shape>
<shape width="76" height="38" topLeftX="729" topLeftY="412" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(44, 40, 64, 1)" bold="true" textAlign="left">
<p>
<strong>
<span color="rgba(44, 40, 64, 1)" fontSize="12">申请设备</span>
</strong>
</p>
</content>
</shape>
<shape width="76" height="38" topLeftX="823" topLeftY="412" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(44, 40, 64, 1)" bold="true" textAlign="left">
<p>
<strong>
<span color="rgba(44, 40, 64, 1)" fontSize="12">培训安排</span>
</strong>
</p>
</content>
</shape>
</data>
</slide>
<slide>
<style>
<fill>
<fillColor color="rgba(44, 40, 64, 1)"/>
</fill>
</style>
<data>
<shape width="236" height="44" topLeftX="421" topLeftY="151" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)">
<p>
<span color="rgba(255, 255, 255, 1)" fontSize="16">身份证 #1</span>
</p>
</content>
</shape>
<shape width="208" height="56" topLeftX="421" topLeftY="184" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(147, 150, 156, 1)">
<p>
<span color="rgba(147, 150, 156, 1)" fontSize="12">描述相关的信息以解释你的标题。现在就开始打字吧。</span>
</p>
</content>
</shape>
<shape width="254" height="56" topLeftX="69" topLeftY="155" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(147, 150, 156, 1)">
<p>
<span color="rgba(147, 150, 156, 1)" fontSize="12">描述相关的信息以解释你的标题。现在就开始打字吧。写任何你想表达的内容</span>
</p>
</content>
</shape>
<shape width="236" height="44" topLeftX="704" topLeftY="358" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)">
<p>
<span color="rgba(255, 255, 255, 1)" fontSize="16">体检证明 #4</span>
</p>
</content>
</shape>
<shape width="208" height="56" topLeftX="704" topLeftY="391" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(147, 150, 156, 1)">
<p>
<span color="rgba(147, 150, 156, 1)" fontSize="12">描述相关的信息以解释你的标题。现在就开始打字吧。</span>
</p>
</content>
</shape>
<shape width="236" height="44" topLeftX="421" topLeftY="358" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)">
<p>
<span color="rgba(255, 255, 255, 1)" fontSize="16">学历/学位证明 #3</span>
</p>
</content>
</shape>
<shape width="208" height="56" topLeftX="421" topLeftY="391" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(147, 150, 156, 1)">
<p>
<span color="rgba(147, 150, 156, 1)" fontSize="12">描述相关的信息以解释你的标题。现在就开始打字吧。</span>
</p>
</content>
</shape>
<shape width="236" height="44" topLeftX="704" topLeftY="151" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)">
<p>
<span color="rgba(255, 255, 255, 1)" fontSize="16">银行卡 #2</span>
</p>
</content>
</shape>
<shape width="208" height="56" topLeftX="704" topLeftY="184" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(147, 150, 156, 1)">
<p>
<span color="rgba(147, 150, 156, 1)" fontSize="12">描述相关的信息以解释你的标题。现在就开始打字吧。</span>
</p>
</content>
</shape>
<shape width="270" height="68" topLeftX="71" topLeftY="91" type="text">
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="32" color="rgba(255, 255, 255, 1)" bold="true">
<p>
<strong>
<span color="rgba(255, 255, 255, 1)" fontSize="32">入职手续</span>
</strong>
</p>
</content>
</shape>
<icon width="60" height="60" topLeftX="433" topLeftY="91" iconType="iconpark/Peoples/id-card-h.svg">
<border color="rgba(255, 255, 255, 1)" width="3" lineJoin="miter" miterLimit="10"/>
</icon>
<icon width="60" height="60" topLeftX="711" topLeftY="91" iconType="iconpark/Money/bank-card.svg">
<border color="rgba(255, 255, 255, 1)" width="3" lineJoin="miter" miterLimit="10"/>
</icon>
<icon width="60" height="60" topLeftX="433" topLeftY="297" iconType="iconpark/Clothes/bachelor-cap-one.svg">
<border color="rgba(255, 255, 255, 1)" width="3" lineJoin="miter" miterLimit="10"/>
</icon>
<icon width="60" height="60" topLeftX="711" topLeftY="297" iconType="iconpark/Health/eeg.svg">
<border color="rgba(255, 255, 255, 1)" width="3" lineJoin="miter" miterLimit="10"/>
</icon>
</data>
<note>
<content/>
</note>
</slide>
<slide>
<style>
<fill>
<fillColor color="rgba(75, 63, 221, 1)"/>
</fill>
</style>
<data>
<shape width="42" height="42" topLeftX="822" topLeftY="244" alpha="0.2" type="ellipse">
<border color="rgba(44, 40, 64, 1)" width="1" lineJoin="miter" miterLimit="10"/>
</shape>
<icon width="25" height="25" topLeftX="831" topLeftY="252" alpha="0.2" iconType="iconpark/Arrows/arrow-right.svg">
<border color="rgba(44, 40, 64, 1)" width="1" lineJoin="miter" miterLimit="10"/>
</icon>
<shape width="865" height="454" topLeftX="47" topLeftY="43" presetHandlers="18" type="round-rect">
<fill>
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
</shape>
<img src="TbzSb8bX7oZhvoxGHYoch6qfnAg" width="296" height="408" topLeftX="72" topLeftY="66">
<crop type="rect" leftOffset="56" rightOffset="56" topOffset="0" bottomOffset="0"/>
</img>
<shape width="404" height="68" topLeftX="415" topLeftY="160" type="text">
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="32" bold="true">
<p lineSpacing="multiple:1.2">
<strong>
<span fontSize="32">办公设备申请</span>
</strong>
</p>
</content>
</shape>
<shape width="263" height="128" topLeftX="415" topLeftY="218" alpha="0.5" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12">
<p list="none">描述相关的信息以解释你的标题。现在就开始打字吧。写任何你想表达的内容描述相关的信息以解释你的标题。现在就开始打字吧。写任何你想表达的内容描述相关的信息以解释你的标题。现在就开始打字吧。写任何你想表达的内容</p>
</content>
</shape>
<icon width="25" height="25" topLeftX="831" topLeftY="252" alpha="0.2" iconType="iconpark/Arrows/arrow-right.svg">
<border color="rgba(44, 40, 64, 1)" width="1" lineJoin="miter" miterLimit="10"/>
</icon>
<shape width="42" height="42" topLeftX="822" topLeftY="244" alpha="0.2" type="ellipse">
<border color="rgba(44, 40, 64, 1)" width="1" lineJoin="miter" miterLimit="10"/>
</shape>
</data>
<note>
<content/>
</note>
</slide>
<slide>
<style>
<fill>
<fillColor color="rgba(75, 63, 221, 1)"/>
</fill>
</style>
<data>
<img src="YDX8brid2ofS9vxsbDCcW5MfnHd" width="328" height="432" topLeftX="571" topLeftY="54">
<crop type="rect" leftOffset="0" rightOffset="0" topOffset="2" bottomOffset="2" presetHandlers="24"/>
</img>
<shape width="381" height="44" topLeftX="67" topLeftY="260" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)">
<p>
<span color="rgba(255, 255, 255, 1)" fontSize="16">请输入相关描述信息以解释你的标题</span>
</p>
</content>
</shape>
<shape width="485" height="85" topLeftX="62" topLeftY="294" type="text">
<content textType="headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="54" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.2">
<p lineSpacing="multiple:1.2" letterSpacing="2">
<strong>
<span color="rgba(255, 255, 255, 1)" fontSize="54">规章制度</span>
</strong>
</p>
</content>
</shape>
</data>
<note>
<content/>
</note>
</slide>
<slide>
<style>
<fill>
<fillColor color="rgba(243, 244, 246, 1)"/>
</fill>
</style>
<data>
<shape width="421" height="449" topLeftX="53" topLeftY="46" presetHandlers="24" type="round-rect">
<fill>
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
</shape>
<shape width="421" height="449" topLeftX="487" topLeftY="46" presetHandlers="24" type="round-rect">
<fill>
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
</shape>
<img src="GduebSB0doQ9vAxRVTBcwJOIncf" width="358" height="202" topLeftX="518" topLeftY="74">
<crop type="rect" leftOffset="22" rightOffset="22" topOffset="0" bottomOffset="0"/>
</img>
<img src="Dwv9bc5ngozIaAxH02CcjmptnBs" width="358" height="202" topLeftX="84" topLeftY="74">
<crop type="rect" leftOffset="3" rightOffset="3" topOffset="0" bottomOffset="0" presetHandlers="18"/>
</img>
<shape width="221" height="74" topLeftX="222" topLeftY="373" alpha="0.5" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12">
<p>描述相关的信息以解释你的标题。现在就开始打字吧。写任何你想表达的内容</p>
</content>
</shape>
<shape width="221" height="44" topLeftX="222" topLeftY="339" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" bold="true">
<p>年度旅游福利</p>
</content>
</shape>
<shape width="221" height="74" topLeftX="656" topLeftY="373" alpha="0.5" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12">
<p>
<span fontSize="12">描述相关的信息以解释你的标题。现在就开始打字吧。写任何你想表达的内容</span>
</p>
</content>
</shape>
<shape width="221" height="44" topLeftX="656" topLeftY="339" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" bold="true">
<p>
<strong>
<span fontSize="16">如何处理病假</span>
</strong>
</p>
</content>
</shape>
<shape width="100" height="100" topLeftX="84" topLeftY="343" presetHandlers="100" type="round-rect">
<fill>
<fillColor color="rgba(44, 40, 64, 1)"/>
</fill>
</shape>
<icon width="49" height="49" topLeftX="111" topLeftY="369" rotation="90" iconType="iconpark/Travel/airplane.svg">
<fill>
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
</icon>
<shape width="100" height="100" topLeftX="517" topLeftY="343" presetHandlers="100" type="round-rect">
<fill>
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
<border color="rgba(44, 40, 64, 1)" lineJoin="miter" miterLimit="10"/>
</shape>
<icon width="49" height="49" topLeftX="543" topLeftY="369" iconType="iconpark/Health/cross-society.svg">
<fill>
<fillColor color="rgba(44, 40, 64, 1)"/>
</fill>
</icon>
</data>
<note>
<content/>
</note>
</slide>
<slide>
<style>
<fill>
<fillColor color="rgba(75, 63, 221, 1)"/>
</fill>
</style>
<data>
<img src="FLAWbzsymo9ZUHxmDAkcNe40nbb" width="328" height="432" topLeftX="571" topLeftY="54">
<crop type="rect" leftOffset="52" rightOffset="52" topOffset="0" bottomOffset="0" presetHandlers="24"/>
</img>
<shape width="381" height="44" topLeftX="67" topLeftY="260" type="text">
<content paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="16" color="rgba(255, 255, 255, 1)">
<p>
<span color="rgba(255, 255, 255, 1)" fontSize="16">请输入相关描述信息以解释你的标题</span>
</p>
</content>
</shape>
<shape width="485" height="85" topLeftX="62" topLeftY="294" type="text">
<content textType="headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="54" color="rgba(255, 255, 255, 1)" bold="true" lineSpacing="multiple:1.2">
<p lineSpacing="multiple:1.2" letterSpacing="2">
<strong>
<span color="rgba(255, 255, 255, 1)" fontSize="54">公司福利</span>
</strong>
</p>
</content>
</shape>
</data>
<note>
<content/>
</note>
</slide>
<slide>
<style>
<fill>
<fillColor color="rgba(43, 47, 54, 1)"/>
</fill>
</style>
<data>
<undefined type="fallback"/>
<shape width="255" height="92" topLeftX="341" topLeftY="386" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12">
<ul listStyle="circle-hollow-square">
<li>
<p>
<span color="rgba(255, 255, 255, 1)" fontSize="12">关键点 #1</span>
</p>
</li>
<li>
<p>
<span color="rgba(255, 255, 255, 1)" fontSize="12">关键点 #2</span>
</p>
</li>
<li>
<p>
<span color="rgba(255, 255, 255, 1)" fontSize="12">关键点 #3</span>
</p>
</li>
</ul>
</content>
</shape>
<shape width="260" height="74" topLeftX="52" topLeftY="386" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12" color="rgba(255, 255, 255, 1)">
<ul listStyle="circle-hollow-square">
<li>
<p>
<span color="rgba(255, 255, 255, 1)">关键点 #1</span>
</p>
</li>
<li>
<p>
<span color="rgba(255, 255, 255, 1)" fontSize="12">关键点 #</span>
<span color="rgba(255, 255, 255, 1)" fontSize="12">2</span>
</p>
</li>
<li>
<p>
<span color="rgba(255, 255, 255, 1)" fontSize="12">关键点 #3</span>
</p>
</li>
</ul>
</content>
</shape>
<shape width="260" height="92" topLeftX="634" topLeftY="386" type="text">
<content textType="caption" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="12">
<ul listStyle="circle-hollow-square">
<li>
<p>
<span color="rgba(255, 255, 255, 1)" fontSize="12">关键点 #1</span>
</p>
</li>
<li>
<p>
<span color="rgba(255, 255, 255, 1)" fontSize="12">关键点 #2</span>
</p>
</li>
<li>
<p>
<span color="rgba(255, 255, 255, 1)" fontSize="12">关键点 #3</span>
</p>
</li>
</ul>
</content>
</shape>
<shape width="487" height="68" topLeftX="236" topLeftY="46" type="text">
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="32" color="rgba(255, 255, 255, 1)" bold="true" textAlign="center">
<p>
<strong>
<span color="rgba(255, 255, 255, 1)" fontSize="32">公司福利</span>
</strong>
</p>
</content>
</shape>
</data>
<note>
<content/>
</note>
</slide>
<slide>
<style>
<fill>
<fillColor color="rgba(44, 40, 64, 1)"/>
</fill>
</style>
<data>
<shape width="404" height="116" topLeftX="278" topLeftY="202" type="text">
<content textType="sub-headline" paddingTop="10" paddingBottom="10" paddingLeft="10" paddingRight="10" fontSize="32" color="rgba(255, 255, 255, 1)" bold="true" textAlign="center">
<p>
<strong>
<span color="rgba(255, 255, 255, 1)" fontSize="32">愿你在这里度过愉快的时光</span>
</strong>
</p>
<p>
<strong>
<span color="rgba(255, 255, 255, 1)" fontSize="32">并在我们的团队中取得成功</span>
</strong>
</p>
</content>
</shape>
</data>
</slide>
</presentation>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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