Skip to main content

openlark_docs/common/
chain.rs

1//! # openlark-docs 链式调用入口(简化为仅配置获取)
2//!
3//! ## 设计理念
4//!
5//! openlark-docs 涵盖多个 bizTag/Project(ccm/base/bitable/baike/minutes 等),
6//! 提供简洁的配置获取入口,Request 构建仍使用各 `*RequestBuilder/*Request` 的 `new(config)` / `execute(...)`。
7//!
8//! ## 推荐入口
9//!
10//! **公开入口** (推荐用户使用):
11//! - `DocsClient` - 文档服务的唯一公开入口
12//! - 示例: `DocsClient::new(config).ccm.config().clone()` 用于获取配置
13//!
14//! ## 推荐调用方式
15//!
16//! ```rust,ignore
17//! use openlark_core::config::Config;
18//! use openlark_docs::DocsClient;
19//!
20//! // 创建客户端
21//! let config = Config::builder()
22//!     .app_id("app_id")
23//!     .app_secret("app_secret")
24//!     .build();
25//! let docs = DocsClient::new(config);
26//!
27//! // ✅ 推荐:获取配置后构建 Request
28//! // 访问云盘服务
29//! let config = docs.ccm.config().clone();
30//! // let file = UploadAllRequest::new(config, ...).execute().await?;
31//!
32//! // 访问多维表格
33//! let config = docs.base.bitable.config().clone();
34//! // let table = CreateTableRequest::new(config, ...).execute().await?;
35//!
36//! // 访问知识库
37//! let config = docs.ccm.wiki.config().clone();
38//! // let node = CreateNodeRequest::new(config, ...).execute().await?;
39//! ```
40
41#[cfg(any(feature = "ccm-core", feature = "bitable"))]
42use openlark_core::SDKResult;
43use openlark_core::config::Config;
44#[cfg(feature = "ccm-core")]
45use openlark_core::error::{CoreError, business_error, validation_error};
46use std::sync::Arc;
47
48/// 统一的 typed pagination 返回页。
49///
50/// 相比直接暴露各 API 的原始分页字段,该结构统一使用 `next_page_token` 命名,
51/// 方便后续在 Drive / Docs helper 中复用同一套分页范式。
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct TypedPage<T> {
54    /// 当前页结果项。
55    pub items: Vec<T>,
56    /// 是否还有下一页。
57    pub has_more: bool,
58    /// 下一页分页标记。
59    pub next_page_token: Option<String>,
60}
61
62impl<T> TypedPage<T> {
63    /// 创建新的实例。
64    pub fn new(items: Vec<T>, has_more: bool, next_page_token: Option<String>) -> Self {
65        Self {
66            items,
67            has_more,
68            next_page_token,
69        }
70    }
71
72    /// 提供 `empty` 能力。
73    pub fn empty() -> Self {
74        Self::new(Vec::new(), false, None)
75    }
76
77    /// 提供 `is_last_page` 能力。
78    pub fn is_last_page(&self) -> bool {
79        !self.has_more
80    }
81
82    /// 提供 `into_items` 能力。
83    pub fn into_items(self) -> Vec<T> {
84        self.items
85    }
86}
87
88#[cfg(feature = "ccm-core")]
89/// 公开项说明。
90pub type FolderChildrenPage = TypedPage<crate::ccm::explorer::v2::models::FileItem>;
91
92/// 电子表格范围 helper。
93///
94/// 统一 sheet 标识与 A1 范围表达,避免业务侧手工拼接
95/// `sheet_id!A1:C5` 之类的字符串。
96#[cfg(feature = "ccm-core")]
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct SheetRange {
99    /// 工作表标识。
100    pub sheet_id: String,
101    /// 起始单元格。
102    pub start_cell: String,
103    /// 结束单元格;为空时表示单格或单起点范围。
104    pub end_cell: Option<String>,
105}
106
107#[cfg(feature = "ccm-core")]
108impl SheetRange {
109    /// 从工作表 ID + 起始单元格创建范围。
110    pub fn new(sheet_id: impl Into<String>, start_cell: impl Into<String>) -> Self {
111        Self {
112            sheet_id: sheet_id.into(),
113            start_cell: start_cell.into(),
114            end_cell: None,
115        }
116    }
117
118    /// 补充结束单元格,形成 `A1:C5` 这类闭区间范围。
119    pub fn with_end_cell(mut self, end_cell: impl Into<String>) -> Self {
120        self.end_cell = Some(end_cell.into());
121        self
122    }
123
124    /// 从工作表 ID 与相对范围表达式创建范围。
125    ///
126    /// `range_expr` 仅应包含单元格部分,例如 `A1` 或 `A1:C5`。
127    pub fn from_range_expr(
128        sheet_id: impl Into<String>,
129        range_expr: impl AsRef<str>,
130    ) -> SDKResult<Self> {
131        let sheet_id = validate_sheet_range_part("sheet_id", sheet_id.into())?;
132        let expr = range_expr.as_ref().trim();
133        if expr.is_empty() {
134            return Err(validation_error("range_expr", "range_expr 不能为空"));
135        }
136        if expr.contains('!') {
137            return Err(validation_error(
138                "range_expr",
139                "range_expr 不应包含工作表前缀,请仅传入单元格范围",
140            ));
141        }
142
143        match expr.split_once(':') {
144            Some((start, end)) => Ok(Self::new(
145                sheet_id,
146                validate_sheet_range_part("start_cell", start)?,
147            )
148            .with_end_cell(validate_sheet_range_part("end_cell", end)?)),
149            None => Ok(Self::new(
150                sheet_id,
151                validate_sheet_range_part("start_cell", expr)?,
152            )),
153        }
154    }
155
156    /// 解析完整的 A1 表达式,例如 `sheet_id!A1:C5`。
157    pub fn parse(a1_notation: impl AsRef<str>) -> SDKResult<Self> {
158        let notation = a1_notation.as_ref().trim();
159        let (sheet_id, range_expr) = notation.split_once('!').ok_or_else(|| {
160            validation_error(
161                "a1_notation",
162                "A1 表达式必须包含工作表前缀,例如 sheet_id!A1:C5",
163            )
164        })?;
165
166        Self::from_range_expr(sheet_id, range_expr)
167    }
168
169    /// 返回不带工作表前缀的范围表达式。
170    pub fn range_expr(&self) -> String {
171        match &self.end_cell {
172            Some(end_cell) => format!("{}:{}", self.start_cell, end_cell),
173            None => self.start_cell.clone(),
174        }
175    }
176
177    /// 返回完整的 A1 表达式。
178    pub fn to_a1_notation(&self) -> String {
179        format!("{}!{}", self.sheet_id, self.range_expr())
180    }
181}
182
183#[cfg(feature = "ccm-core")]
184impl std::fmt::Display for SheetRange {
185    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186        f.write_str(&self.to_a1_notation())
187    }
188}
189
190/// 多维表格记录查询 helper。
191///
192/// 封装常见字段过滤场景,避免业务侧直接拼接 `FilterInfo` /
193/// `FilterCondition` 结构体。
194#[cfg(feature = "bitable")]
195#[derive(Debug, Clone, PartialEq)]
196pub struct BitableRecordQuery {
197    /// 多维表格 app_token。
198    pub app_token: String,
199    /// 数据表 table_id。
200    pub table_id: String,
201    conjunction: String,
202    filters: Vec<crate::base::bitable::v1::app::table::record::search::FilterCondition>,
203    field_names: Option<Vec<String>>,
204    automatic_fields: bool,
205}
206
207#[cfg(feature = "bitable")]
208impl BitableRecordQuery {
209    /// 创建一个新的记录查询 helper。
210    pub fn new(app_token: impl Into<String>, table_id: impl Into<String>) -> Self {
211        Self {
212            app_token: app_token.into(),
213            table_id: table_id.into(),
214            conjunction: "and".to_string(),
215            filters: Vec::new(),
216            field_names: None,
217            automatic_fields: true,
218        }
219    }
220
221    /// 指定返回字段名,减少无关字段回传。
222    pub fn field_names(mut self, field_names: Vec<String>) -> Self {
223        self.field_names = Some(field_names);
224        self
225    }
226
227    /// 控制是否返回自动字段。
228    pub fn automatic_fields(mut self, automatic_fields: bool) -> Self {
229        self.automatic_fields = automatic_fields;
230        self
231    }
232
233    /// 将条件组合方式切换为 `or`。
234    pub fn or(mut self) -> Self {
235        self.conjunction = "or".to_string();
236        self
237    }
238
239    /// 按字段精确匹配。
240    pub fn where_equals(mut self, field_name: impl Into<String>, value: impl Into<String>) -> Self {
241        self.filters.push(
242            crate::base::bitable::v1::app::table::record::search::FilterCondition {
243                field_name: field_name.into(),
244                operator: "is".to_string(),
245                value: Some(vec![value.into()]),
246            },
247        );
248        self
249    }
250
251    /// 按字段模糊包含匹配。
252    pub fn where_contains(
253        mut self,
254        field_name: impl Into<String>,
255        value: impl Into<String>,
256    ) -> Self {
257        self.filters.push(
258            crate::base::bitable::v1::app::table::record::search::FilterCondition {
259                field_name: field_name.into(),
260                operator: "contains".to_string(),
261                value: Some(vec![value.into()]),
262            },
263        );
264        self
265    }
266
267    /// 按字段命中多个候选值。
268    pub fn where_in(mut self, field_name: impl Into<String>, values: Vec<String>) -> Self {
269        self.filters.push(
270            crate::base::bitable::v1::app::table::record::search::FilterCondition {
271                field_name: field_name.into(),
272                operator: "isAnyOf".to_string(),
273                value: Some(values),
274            },
275        );
276        self
277    }
278
279    fn into_parts(
280        self,
281    ) -> (
282        String,
283        String,
284        Option<Vec<String>>,
285        bool,
286        Option<crate::base::bitable::v1::app::table::record::search::FilterInfo>,
287    ) {
288        let filter = if self.filters.is_empty() {
289            None
290        } else {
291            Some(
292                crate::base::bitable::v1::app::table::record::search::FilterInfo {
293                    conjunction: Some(self.conjunction),
294                    conditions: Some(self.filters),
295                },
296            )
297        };
298
299        (
300            self.app_token,
301            self.table_id,
302            self.field_names,
303            self.automatic_fields,
304            filter,
305        )
306    }
307}
308
309/// 批量写入单个范围的数据单元。
310#[cfg(feature = "ccm-core")]
311#[derive(Debug, Clone, PartialEq)]
312pub struct SheetWriteRange {
313    /// 目标范围。
314    pub range: SheetRange,
315    /// 主维度,默认按行写入。
316    pub major_dimension: String,
317    /// 单元格值。
318    pub values: Vec<Vec<serde_json::Value>>,
319}
320
321#[cfg(feature = "ccm-core")]
322impl SheetWriteRange {
323    /// 创建一条按行写入的范围数据。
324    pub fn new(range: SheetRange, values: Vec<Vec<serde_json::Value>>) -> Self {
325        Self {
326            range,
327            major_dimension: "ROWS".to_string(),
328            values,
329        }
330    }
331
332    /// 覆盖主维度,例如 `COLUMNS`。
333    pub fn major_dimension(mut self, major_dimension: impl Into<String>) -> Self {
334        self.major_dimension = major_dimension.into();
335        self
336    }
337}
338
339#[cfg(feature = "ccm-core")]
340impl From<SheetWriteRange> for crate::ccm::sheets_v2::v2::data_io::models::BatchWriteData {
341    fn from(value: SheetWriteRange) -> Self {
342        Self {
343            data_range: value.range.to_string(),
344            major_dimension: value.major_dimension,
345            values: value.values,
346        }
347    }
348}
349
350/// Drive 下载范围 helper。
351///
352/// 用于避免业务侧手工拼接 `bytes=0-1023` 这类 Range 头。
353#[cfg(feature = "ccm-core")]
354#[derive(Debug, Clone, PartialEq, Eq)]
355pub struct DriveDownloadRange {
356    /// 起始字节位置。
357    pub start: u64,
358    /// 结束字节位置;为空表示读取到文件尾部。
359    pub end: Option<u64>,
360}
361
362#[cfg(feature = "ccm-core")]
363impl DriveDownloadRange {
364    /// 创建从 `start` 开始的下载范围。
365    pub fn from_start(start: u64) -> Self {
366        Self { start, end: None }
367    }
368
369    /// 指定结束位置。
370    pub fn with_end(mut self, end: u64) -> Self {
371        self.end = Some(end);
372        self
373    }
374
375    /// 生成 HTTP Range 头。
376    pub fn to_header_value(&self) -> String {
377        match self.end {
378            Some(end) => format!("bytes={}-{}", self.start, end),
379            None => format!("bytes={}-", self.start),
380        }
381    }
382}
383
384#[cfg(feature = "ccm-core")]
385impl std::fmt::Display for DriveDownloadRange {
386    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
387        f.write_str(&self.to_header_value())
388    }
389}
390
391/// Wiki 节点路径 helper。
392///
393/// 统一处理 `产品文档/发布计划/周报` 这类按标题导航的路径表达。
394#[cfg(feature = "ccm-core")]
395#[derive(Debug, Clone, PartialEq, Eq)]
396pub struct WikiNodePath {
397    segments: Vec<String>,
398}
399
400#[cfg(feature = "ccm-core")]
401impl WikiNodePath {
402    /// 基于路径片段创建 Wiki 路径。
403    pub fn new(segments: Vec<String>) -> SDKResult<Self> {
404        let segments = segments
405            .into_iter()
406            .map(validate_wiki_path_segment)
407            .collect::<SDKResult<Vec<_>>>()?;
408        if segments.is_empty() {
409            return Err(validation_error(
410                "wiki_path",
411                "wiki_path 至少需要一个路径片段",
412            ));
413        }
414        Ok(Self { segments })
415    }
416
417    /// 从 `/` 分隔的路径字符串解析 Wiki 路径。
418    pub fn parse(path: impl AsRef<str>) -> SDKResult<Self> {
419        let raw = path.as_ref().trim().trim_matches('/');
420        if raw.is_empty() {
421            return Err(validation_error("wiki_path", "wiki_path 不能为空"));
422        }
423        Self::new(raw.split('/').map(str::to_string).collect())
424    }
425
426    /// 返回路径片段。
427    pub fn segments(&self) -> &[String] {
428        &self.segments
429    }
430}
431
432#[cfg(feature = "ccm-core")]
433impl std::fmt::Display for WikiNodePath {
434    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
435        f.write_str(&self.segments.join("/"))
436    }
437}
438
439/// Drive 上传文件 helper。
440///
441/// 统一封装文件名、字节内容与可选 checksum,并在 helper 层自动补全
442/// `parent_type=explorer` 与 `size=file.len()` 等默认策略。
443#[cfg(feature = "ccm-core")]
444#[derive(Debug, Clone, PartialEq, Eq)]
445pub struct DriveUploadFile {
446    /// 文件名。
447    pub file_name: String,
448    /// 文件内容。
449    pub content: Vec<u8>,
450    /// 可选的 Adler-32 checksum。
451    pub checksum: Option<String>,
452}
453
454#[cfg(feature = "ccm-core")]
455impl DriveUploadFile {
456    /// 创建上传文件。
457    pub fn new(file_name: impl Into<String>, content: Vec<u8>) -> Self {
458        Self {
459            file_name: file_name.into(),
460            content,
461            checksum: None,
462        }
463    }
464
465    /// 设置校验和。
466    pub fn checksum(mut self, checksum: impl Into<String>) -> Self {
467        self.checksum = Some(checksum.into());
468        self
469    }
470
471    /// 计算文件大小。
472    pub fn size(&self) -> usize {
473        self.content.len()
474    }
475
476    /// 构建底层上传请求。
477    pub fn into_request(
478        self,
479        config: Config,
480        folder_token: impl Into<String>,
481    ) -> crate::ccm::drive::v1::file::UploadAllRequest {
482        let mut request = crate::ccm::drive::v1::file::UploadAllRequest::new(
483            config,
484            self.file_name,
485            folder_token,
486            "explorer",
487            self.content.len(),
488            self.content,
489        );
490        if let Some(checksum) = self.checksum {
491            request = request.checksum(checksum);
492        }
493        request
494    }
495}
496
497#[cfg(feature = "ccm-core")]
498impl From<crate::ccm::explorer::v2::models::FolderChildrenData>
499    for TypedPage<crate::ccm::explorer::v2::models::FileItem>
500{
501    fn from(data: crate::ccm::explorer::v2::models::FolderChildrenData) -> Self {
502        Self::new(data.items, data.has_more, data.page_token)
503    }
504}
505
506/// 文件夹子项分页 helper。
507///
508/// 用于按页读取 Drive Explorer 文件夹内容,并统一分页返回形态。
509#[cfg(feature = "ccm-core")]
510#[derive(Debug, Clone)]
511pub struct FolderChildrenPager {
512    config: Arc<Config>,
513    folder_token: String,
514    doc_type: Option<String>,
515    page_size: i32,
516    next_page_token: Option<String>,
517    exhausted: bool,
518}
519
520#[cfg(feature = "ccm-core")]
521impl FolderChildrenPager {
522    fn new(config: Arc<Config>, folder_token: impl Into<String>) -> Self {
523        Self {
524            config,
525            folder_token: folder_token.into(),
526            doc_type: None,
527            page_size: crate::common::constants::DEFAULT_PAGE_SIZE,
528            next_page_token: None,
529            exhausted: false,
530        }
531    }
532
533    /// 设置文件类型过滤。
534    pub fn doc_type(mut self, doc_type: impl Into<String>) -> Self {
535        self.doc_type = Some(doc_type.into());
536        self
537    }
538
539    /// 设置分页大小,自动限制在 1..=MAX_PAGE_SIZE 范围内。
540    pub fn page_size(mut self, page_size: i32) -> Self {
541        self.page_size = page_size.clamp(1, crate::common::constants::MAX_PAGE_SIZE);
542        self
543    }
544
545    /// 从指定分页 token 恢复读取。
546    pub fn next_page_token(mut self, next_page_token: impl Into<String>) -> Self {
547        self.next_page_token = Some(next_page_token.into());
548        self
549    }
550
551    /// 查看当前即将请求的下一页 token。
552    pub fn pending_page_token(&self) -> Option<&str> {
553        self.next_page_token.as_deref()
554    }
555
556    /// 读取下一页结果。
557    pub async fn fetch_next_page(&mut self) -> SDKResult<FolderChildrenPage> {
558        use crate::ccm::explorer::v2::{GetFolderChildrenParams, GetFolderChildrenRequest};
559
560        if self.exhausted {
561            return Ok(TypedPage::empty());
562        }
563
564        let response = GetFolderChildrenRequest::new(
565            self.config.as_ref().clone(),
566            &self.folder_token,
567            Some(GetFolderChildrenParams {
568                page_size: Some(self.page_size),
569                page_token: self.next_page_token.clone(),
570                doc_type: self.doc_type.clone(),
571            }),
572        )
573        .execute()
574        .await?;
575
576        let page = response
577            .data
578            .map(TypedPage::from)
579            .unwrap_or_else(TypedPage::empty);
580        self.exhausted = !page.has_more;
581        self.next_page_token = if page.has_more {
582            page.next_page_token.clone()
583        } else {
584            None
585        };
586
587        Ok(page)
588    }
589
590    /// 收集当前 pager 剩余的所有结果。
591    pub async fn collect_all(
592        mut self,
593    ) -> SDKResult<Vec<crate::ccm::explorer::v2::models::FileItem>> {
594        let mut items = Vec::new();
595
596        loop {
597            let page = self.fetch_next_page().await?;
598            let is_last_page = page.is_last_page();
599            items.extend(page.into_items());
600            if is_last_page {
601                break;
602            }
603        }
604
605        Ok(items)
606    }
607}
608
609/// Docs 链式入口:`docs.ccm.config()` / `docs.base.bitable.config()`(按 feature 裁剪)
610#[derive(Debug, Clone)]
611pub struct DocsClient {
612    config: Arc<Config>,
613
614    #[cfg(feature = "ccm-core")]
615    /// 公开项说明。
616    pub ccm: CcmClient,
617
618    #[cfg(any(feature = "base", feature = "bitable"))]
619    /// 公开项说明。
620    pub base: BaseClient,
621
622    #[cfg(any(feature = "baike", feature = "lingo"))]
623    /// 公开项说明。
624    pub baike: BaikeClient,
625
626    #[cfg(feature = "minutes")]
627    /// 公开项说明。
628    pub minutes: MinutesClient,
629}
630
631impl DocsClient {
632    /// 创建新的实例。
633    pub fn new(config: Config) -> Self {
634        let config = Arc::new(config);
635        Self {
636            config: config.clone(),
637            #[cfg(feature = "ccm-core")]
638            ccm: CcmClient::new(config.clone()),
639            #[cfg(any(feature = "base", feature = "bitable"))]
640            base: BaseClient::new(config.clone()),
641            #[cfg(any(feature = "baike", feature = "lingo"))]
642            baike: BaikeClient::new(config.clone()),
643            #[cfg(feature = "minutes")]
644            minutes: MinutesClient::new(config),
645        }
646    }
647
648    /// 返回共享配置。
649    pub fn config(&self) -> &Config {
650        &self.config
651    }
652
653    /// 创建文件夹子项分页 helper。
654    #[cfg(feature = "ccm-core")]
655    pub fn folder_children_pager(&self, folder_token: impl Into<String>) -> FolderChildrenPager {
656        FolderChildrenPager::new(self.config.clone(), folder_token)
657    }
658
659    /// 获取文件夹下的全部子项,自动处理分页。
660    #[cfg(feature = "ccm-core")]
661    pub async fn list_folder_children_all(
662        &self,
663        folder_token: &str,
664        doc_type: Option<&str>,
665    ) -> SDKResult<Vec<crate::ccm::explorer::v2::models::FileItem>> {
666        let mut pager = self
667            .folder_children_pager(folder_token)
668            .page_size(crate::common::constants::MAX_PAGE_SIZE);
669        if let Some(doc_type) = doc_type {
670            pager = pager.doc_type(doc_type);
671        }
672
673        pager.collect_all().await
674    }
675
676    /// 读取多维表格全部记录,自动处理分页。
677    #[cfg(feature = "bitable")]
678    pub async fn search_bitable_records_all(
679        &self,
680        app_token: &str,
681        table_id: &str,
682    ) -> SDKResult<Vec<crate::base::bitable::v1::app::table::record::models::Record>> {
683        use crate::base::bitable::v1::app::table::record::search::SearchRecordRequest;
684
685        SearchRecordRequest::new(self.config().clone())
686            .app_token(app_token.to_string())
687            .table_id(table_id.to_string())
688            .automatic_fields(true)
689            .fetch_all()
690            .await
691    }
692
693    /// 使用 helper 风格执行常见多维表格过滤查询,并自动处理分页。
694    #[cfg(feature = "bitable")]
695    pub async fn query_bitable_records(
696        &self,
697        query: BitableRecordQuery,
698    ) -> SDKResult<Vec<crate::base::bitable::v1::app::table::record::models::Record>> {
699        use crate::base::bitable::v1::app::table::record::search::SearchRecordRequest;
700
701        let (app_token, table_id, field_names, automatic_fields, filter) = query.into_parts();
702        let mut request = SearchRecordRequest::new(self.config().clone())
703            .app_token(app_token)
704            .table_id(table_id)
705            .automatic_fields(automatic_fields);
706
707        if let Some(field_names) = field_names {
708            request = request.field_names(field_names);
709        }
710
711        if let Some(filter) = filter {
712            request = request.filter(filter);
713        }
714
715        request.fetch_all().await
716    }
717
718    /// 读取多个单元格范围,返回聚合后的范围数据。
719    #[cfg(feature = "ccm-core")]
720    pub async fn read_multiple_ranges(
721        &self,
722        spreadsheet_token: &str,
723        ranges: Vec<String>,
724    ) -> SDKResult<crate::ccm::sheets_v2::v2::data_io::models::MultipleRangeData> {
725        use crate::ccm::sheets_v2::v2::data_io::{
726            ReadMultipleRangesParams, read_multiple_ranges as read_multiple_ranges_api,
727        };
728
729        let response = read_multiple_ranges_api(
730            self.config(),
731            spreadsheet_token,
732            ReadMultipleRangesParams {
733                ranges,
734                value_render_option: None,
735                date_render_option: None,
736            },
737        )
738        .await?;
739
740        response
741            .data
742            .ok_or_else(|| CoreError::api_data_error("读取多个范围"))
743    }
744
745    /// 使用 typed `SheetRange` 批量读取多个范围。
746    #[cfg(feature = "ccm-core")]
747    pub async fn read_sheet_ranges(
748        &self,
749        spreadsheet_token: &str,
750        ranges: Vec<SheetRange>,
751    ) -> SDKResult<crate::ccm::sheets_v2::v2::data_io::models::MultipleRangeData> {
752        self.read_multiple_ranges(
753            spreadsheet_token,
754            ranges.into_iter().map(|range| range.to_string()).collect(),
755        )
756        .await
757    }
758
759    /// 批量写入多个单元格范围。
760    #[cfg(feature = "ccm-core")]
761    pub async fn write_multiple_ranges(
762        &self,
763        spreadsheet_token: &str,
764        data: Vec<crate::ccm::sheets_v2::v2::data_io::models::BatchWriteData>,
765    ) -> SDKResult<crate::ccm::sheets_v2::v2::data_io::models::BatchUpdateResult> {
766        use crate::ccm::sheets_v2::v2::data_io::{BatchWriteRangesParams, batch_write_ranges};
767
768        let response = batch_write_ranges(
769            self.config(),
770            spreadsheet_token,
771            BatchWriteRangesParams {
772                data,
773                include_style: None,
774            },
775        )
776        .await?;
777
778        response
779            .data
780            .ok_or_else(|| CoreError::api_data_error("批量写入多个范围"))
781    }
782
783    /// 使用 typed `SheetRange` 批量写入多个范围。
784    #[cfg(feature = "ccm-core")]
785    pub async fn write_sheet_ranges(
786        &self,
787        spreadsheet_token: &str,
788        ranges: Vec<SheetWriteRange>,
789    ) -> SDKResult<crate::ccm::sheets_v2::v2::data_io::models::BatchUpdateResult> {
790        self.write_multiple_ranges(
791            spreadsheet_token,
792            ranges.into_iter().map(Into::into).collect(),
793        )
794        .await
795    }
796
797    /// 使用 typed `SheetRange` 追加数据。
798    #[cfg(feature = "ccm-core")]
799    pub async fn append_sheet_range(
800        &self,
801        spreadsheet_token: &str,
802        range: SheetRange,
803        values: Vec<Vec<serde_json::Value>>,
804    ) -> SDKResult<crate::ccm::sheets_v2::v2::data_io::models::AppendResult> {
805        use crate::ccm::sheets_v2::v2::data_io::{AppendValuesParams, append_values};
806
807        let response = append_values(
808            self.config(),
809            spreadsheet_token,
810            AppendValuesParams {
811                range: range.to_string(),
812                major_dimension: None,
813                values,
814            },
815        )
816        .await?;
817
818        response
819            .data
820            .ok_or_else(|| CoreError::api_data_error("追加工作表范围"))
821    }
822
823    /// 使用默认的 `parent_type=explorer` 与自动 size 推断上传 Drive 文件。
824    #[cfg(feature = "ccm-core")]
825    pub async fn upload_drive_file(
826        &self,
827        folder_token: &str,
828        file: DriveUploadFile,
829    ) -> SDKResult<crate::ccm::drive::v1::file::UploadAllResponse> {
830        file.into_request(self.config().clone(), folder_token)
831            .execute()
832            .await
833    }
834
835    /// 下载完整 Drive 文件内容。
836    #[cfg(feature = "ccm-core")]
837    pub async fn download_drive_file(&self, file_token: &str) -> SDKResult<Vec<u8>> {
838        use crate::ccm::drive::v1::file::DownloadFileRequest;
839
840        DownloadFileRequest::new(self.config().clone(), file_token)
841            .execute()
842            .await?
843            .into_result()
844    }
845
846    /// 按范围下载 Drive 文件内容。
847    #[cfg(feature = "ccm-core")]
848    pub async fn download_drive_file_range(
849        &self,
850        file_token: &str,
851        range: DriveDownloadRange,
852    ) -> SDKResult<Vec<u8>> {
853        use crate::ccm::drive::v1::file::DownloadFileRequest;
854
855        DownloadFileRequest::new(self.config().clone(), file_token)
856            .range(range.to_string())
857            .execute()
858            .await?
859            .into_result()
860    }
861
862    /// 获取指定知识空间下的所有节点,自动处理分页。
863    #[cfg(feature = "ccm-core")]
864    pub async fn list_wiki_space_nodes_all(
865        &self,
866        space_id: &str,
867        parent_node_token: Option<&str>,
868    ) -> SDKResult<Vec<crate::ccm::wiki::v2::models::WikiSpaceNode>> {
869        use crate::ccm::wiki::v2::space::node::{
870            ListWikiSpaceNodesParams, ListWikiSpaceNodesRequest,
871        };
872
873        let mut items = Vec::new();
874        let mut page_token: Option<String> = None;
875
876        loop {
877            let response = ListWikiSpaceNodesRequest::new(self.config().clone())
878                .space_id(space_id)
879                .execute(Some(ListWikiSpaceNodesParams {
880                    parent_node_token: parent_node_token.map(str::to_string),
881                    page_size: Some(crate::common::constants::MAX_PAGE_SIZE),
882                    page_token: page_token.clone(),
883                }))
884                .await?;
885
886            items.extend(response.items);
887
888            if !response.has_more.unwrap_or(false) {
889                break;
890            }
891
892            page_token = response.page_token;
893        }
894
895        Ok(items)
896    }
897
898    /// 在指定层级下按标题查找单个 Wiki 节点。
899    #[cfg(feature = "ccm-core")]
900    pub async fn find_wiki_node_by_title(
901        &self,
902        space_id: &str,
903        title: &str,
904        parent_node_token: Option<&str>,
905    ) -> SDKResult<crate::ccm::wiki::v2::models::WikiSpaceNode> {
906        let items = self
907            .list_wiki_space_nodes_all(space_id, parent_node_token)
908            .await?;
909        find_unique_wiki_node_by_title(&items, title)
910    }
911
912    /// 通过路径逐级导航 Wiki 节点。
913    #[cfg(feature = "ccm-core")]
914    pub async fn find_wiki_node_by_path(
915        &self,
916        space_id: &str,
917        path: impl AsRef<str>,
918    ) -> SDKResult<crate::ccm::wiki::v2::models::WikiSpaceNode> {
919        let path = WikiNodePath::parse(path)?;
920        let mut parent_node_token: Option<String> = None;
921        let mut current_node = None;
922
923        for segment in path.segments() {
924            let node = self
925                .find_wiki_node_by_title(space_id, segment, parent_node_token.as_deref())
926                .await?;
927            parent_node_token = Some(node.node_token.clone());
928            current_node = Some(node);
929        }
930
931        current_node.ok_or_else(|| business_error(format!("未找到 Wiki 路径: {path}")))
932    }
933
934    /// 根据工作表标题查找工作表。
935    #[cfg(feature = "ccm-core")]
936    pub async fn find_sheet_by_title(
937        &self,
938        spreadsheet_token: &str,
939        title: &str,
940    ) -> SDKResult<crate::ccm::sheets_v2::v2::spreadsheet::models::SpreadsheetSheetInfo> {
941        let sheets = self.list_sheet_infos(spreadsheet_token).await?;
942
943        find_sheet_info(&sheets, title)
944            .ok_or_else(|| business_error(format!("未找到工作表: {title}")))
945    }
946
947    /// 按工作表标题解析单个范围,返回统一的 `SheetRange` 表达。
948    #[cfg(feature = "ccm-core")]
949    pub async fn resolve_sheet_range_by_title(
950        &self,
951        spreadsheet_token: &str,
952        title: &str,
953        range_expr: &str,
954    ) -> SDKResult<SheetRange> {
955        let sheet = self.find_sheet_by_title(spreadsheet_token, title).await?;
956        SheetRange::from_range_expr(sheet.sheet_id, range_expr)
957    }
958
959    #[cfg(feature = "ccm-core")]
960    /// 列出 sheet infos。
961    pub async fn list_sheet_infos(
962        &self,
963        spreadsheet_token: &str,
964    ) -> SDKResult<Vec<crate::ccm::sheets_v2::v2::spreadsheet::models::SpreadsheetSheetInfo>> {
965        use crate::ccm::sheets::v3::spreadsheet::sheet::query::query_sheets;
966        use crate::ccm::sheets_v2::v2::spreadsheet::models::SpreadsheetSheetInfo;
967
968        log::info!("[OPENLARK DEBUG] list_sheet_infos called with token: {spreadsheet_token}");
969
970        let response = query_sheets(self.config(), spreadsheet_token).await?;
971
972        log::info!(
973            "[OPENLARK DEBUG] query_sheets response count: {}",
974            response.sheets.len()
975        );
976
977        let sheets: Vec<SpreadsheetSheetInfo> =
978            response.sheets.into_iter().map(map_v3_sheet_info).collect();
979
980        if sheets.is_empty() {
981            return Err(CoreError::api_data_error("获取工作表列表"));
982        }
983
984        Ok(sheets)
985    }
986}
987
988#[cfg(feature = "ccm-core")]
989fn map_v3_sheet_info(
990    sheet: crate::ccm::sheets::v3::spreadsheet::Sheet,
991) -> crate::ccm::sheets_v2::v2::spreadsheet::models::SpreadsheetSheetInfo {
992    crate::ccm::sheets_v2::v2::spreadsheet::models::SpreadsheetSheetInfo {
993        sheet_id: sheet.sheet_id,
994        title: sheet.title,
995        sheet_type: sheet.resource_type,
996        row_count: sheet.grid_properties.row_count,
997        column_count: sheet.grid_properties.column_count,
998    }
999}
1000
1001#[cfg(feature = "ccm-core")]
1002fn find_sheet_info(
1003    sheets: &[crate::ccm::sheets_v2::v2::spreadsheet::models::SpreadsheetSheetInfo],
1004    title: &str,
1005) -> Option<crate::ccm::sheets_v2::v2::spreadsheet::models::SpreadsheetSheetInfo> {
1006    sheets.iter().find(|sheet| sheet.title == title).cloned()
1007}
1008
1009#[cfg(feature = "ccm-core")]
1010fn validate_sheet_range_part(field: &str, value: impl Into<String>) -> SDKResult<String> {
1011    let value = value.into().trim().to_string();
1012    if value.is_empty() {
1013        return Err(validation_error(field, &format!("{field} 不能为空")));
1014    }
1015    Ok(value)
1016}
1017
1018#[cfg(feature = "ccm-core")]
1019fn validate_wiki_path_segment(value: impl Into<String>) -> SDKResult<String> {
1020    let value = value.into().trim().to_string();
1021    if value.is_empty() {
1022        return Err(validation_error(
1023            "wiki_path_segment",
1024            "wiki_path_segment 不能为空",
1025        ));
1026    }
1027    Ok(value)
1028}
1029
1030#[cfg(feature = "ccm-core")]
1031fn find_unique_wiki_node_by_title(
1032    nodes: &[crate::ccm::wiki::v2::models::WikiSpaceNode],
1033    title: &str,
1034) -> SDKResult<crate::ccm::wiki::v2::models::WikiSpaceNode> {
1035    let title = title.trim();
1036    if title.is_empty() {
1037        return Err(validation_error("title", "title 不能为空"));
1038    }
1039
1040    let mut matches = nodes
1041        .iter()
1042        .filter(|node| node.title.as_deref() == Some(title))
1043        .cloned();
1044
1045    let first = matches
1046        .next()
1047        .ok_or_else(|| business_error(format!("未找到 Wiki 节点标题: {title}")))?;
1048
1049    if matches.next().is_some() {
1050        return Err(business_error(format!(
1051            "找到多个同名 Wiki 节点,请缩小范围: {title}"
1052        )));
1053    }
1054
1055    Ok(first)
1056}
1057
1058/// ccm:`docs.ccm`(云文档协同)
1059#[cfg(feature = "ccm-core")]
1060#[derive(Debug, Clone)]
1061pub struct CcmClient {
1062    config: Arc<Config>,
1063}
1064
1065#[cfg(feature = "ccm-core")]
1066impl CcmClient {
1067    fn new(config: Arc<Config>) -> Self {
1068        Self { config }
1069    }
1070
1071    /// 返回共享配置。
1072    pub fn config(&self) -> &Config {
1073        &self.config
1074    }
1075}
1076
1077/// base:`docs.base`(base/bitable 都归口在 base 模块下)
1078#[cfg(any(feature = "base", feature = "bitable"))]
1079#[derive(Debug, Clone)]
1080pub struct BaseClient {
1081    config: Arc<Config>,
1082}
1083
1084#[cfg(any(feature = "base", feature = "bitable"))]
1085impl BaseClient {
1086    fn new(config: Arc<Config>) -> Self {
1087        Self { config }
1088    }
1089
1090    /// 返回共享配置。
1091    pub fn config(&self) -> &Config {
1092        &self.config
1093    }
1094
1095    #[cfg(feature = "bitable")]
1096    /// 返回多维表格客户端。
1097    pub fn bitable(&self) -> BitableClient {
1098        BitableClient::new(self.config.clone())
1099    }
1100}
1101
1102/// bitable:`docs.base.bitable`(多维表格)
1103#[cfg(feature = "bitable")]
1104#[derive(Debug, Clone)]
1105pub struct BitableClient {
1106    config: Arc<Config>,
1107}
1108
1109#[cfg(feature = "bitable")]
1110impl BitableClient {
1111    fn new(config: Arc<Config>) -> Self {
1112        Self { config }
1113    }
1114
1115    /// 返回共享配置。
1116    pub fn config(&self) -> &Config {
1117        &self.config
1118    }
1119}
1120
1121/// baike:`docs.baike`(baike/lingo 相关)
1122#[cfg(any(feature = "baike", feature = "lingo"))]
1123#[derive(Debug, Clone)]
1124pub struct BaikeClient {
1125    config: Arc<Config>,
1126}
1127
1128#[cfg(any(feature = "baike", feature = "lingo"))]
1129impl BaikeClient {
1130    fn new(config: Arc<Config>) -> Self {
1131        Self { config }
1132    }
1133
1134    /// 返回共享配置。
1135    pub fn config(&self) -> &Config {
1136        &self.config
1137    }
1138}
1139
1140/// minutes:`docs.minutes`(会议纪要)
1141#[cfg(feature = "minutes")]
1142#[derive(Debug, Clone)]
1143pub struct MinutesClient {
1144    config: Arc<Config>,
1145}
1146
1147#[cfg(feature = "minutes")]
1148impl MinutesClient {
1149    fn new(config: Arc<Config>) -> Self {
1150        Self { config }
1151    }
1152
1153    /// 返回共享配置。
1154    pub fn config(&self) -> &Config {
1155        &self.config
1156    }
1157}
1158
1159#[cfg(test)]
1160mod tests {
1161    use super::*;
1162    use serde_json;
1163
1164    #[test]
1165    fn test_serialization_roundtrip() {
1166        // 基础序列化测试
1167        let json = r#"{"test": "value"}"#;
1168        assert!(serde_json::from_str::<serde_json::Value>(json).is_ok());
1169    }
1170
1171    #[test]
1172    fn test_deserialization_from_json() {
1173        // 基础反序列化测试
1174        let json = r#"{"field": "data"}"#;
1175        let value: serde_json::Value = serde_json::from_str(json).expect("JSON 反序列化失败");
1176        assert_eq!(value["field"], "data");
1177    }
1178
1179    #[test]
1180    fn test_typed_page_last_page_state() {
1181        let page = TypedPage::new(vec![1, 2], false, None);
1182        assert!(page.is_last_page());
1183        assert_eq!(page.into_items(), vec![1, 2]);
1184    }
1185
1186    #[cfg(feature = "ccm-core")]
1187    #[test]
1188    fn test_drive_download_range_formats_header() {
1189        let full = DriveDownloadRange::from_start(0).with_end(1023);
1190        let tail = DriveDownloadRange::from_start(2048);
1191
1192        assert_eq!(full.to_string(), "bytes=0-1023");
1193        assert_eq!(tail.to_string(), "bytes=2048-");
1194    }
1195
1196    #[cfg(feature = "ccm-core")]
1197    #[test]
1198    fn test_drive_upload_file_builds_default_request() {
1199        let upload = DriveUploadFile::new("report.csv", vec![1, 2, 3]).checksum("abc123");
1200        let request = upload.into_request(
1201            Config::builder()
1202                .app_id("test_app")
1203                .app_secret("test_secret")
1204                .build(),
1205            "folder_token",
1206        );
1207
1208        assert_eq!(request.file_name, "report.csv");
1209        assert_eq!(request.parent_node, "folder_token");
1210        assert_eq!(request.parent_type, "explorer");
1211        assert_eq!(request.size, 3);
1212        assert_eq!(request.checksum.as_deref(), Some("abc123"));
1213        assert_eq!(request.file, vec![1, 2, 3]);
1214    }
1215
1216    #[cfg(feature = "ccm-core")]
1217    #[test]
1218    fn test_wiki_node_path_parses_segments() {
1219        let path = WikiNodePath::parse("/产品文档/发布计划/周报/").unwrap();
1220
1221        assert_eq!(
1222            path.segments(),
1223            &vec![
1224                "产品文档".to_string(),
1225                "发布计划".to_string(),
1226                "周报".to_string()
1227            ]
1228        );
1229        assert_eq!(path.to_string(), "产品文档/发布计划/周报");
1230    }
1231
1232    #[cfg(feature = "ccm-core")]
1233    #[test]
1234    fn test_find_unique_wiki_node_by_title_rejects_duplicates() {
1235        let nodes = vec![
1236            crate::ccm::wiki::v2::models::WikiSpaceNode {
1237                space_id: "space_1".to_string(),
1238                node_token: "node_a".to_string(),
1239                obj_token: None,
1240                obj_type: None,
1241                parent_node_token: None,
1242                title: Some("周报".to_string()),
1243                url: None,
1244            },
1245            crate::ccm::wiki::v2::models::WikiSpaceNode {
1246                space_id: "space_1".to_string(),
1247                node_token: "node_b".to_string(),
1248                obj_token: None,
1249                obj_type: None,
1250                parent_node_token: None,
1251                title: Some("周报".to_string()),
1252                url: None,
1253            },
1254        ];
1255
1256        let error = find_unique_wiki_node_by_title(&nodes, "周报").unwrap_err();
1257        assert!(error.to_string().contains("多个同名"));
1258    }
1259
1260    #[cfg(feature = "bitable")]
1261    #[test]
1262    fn test_bitable_record_query_builds_default_and_filters() {
1263        let query = BitableRecordQuery::new("app_token", "table_id")
1264            .where_equals("状态", "进行中")
1265            .where_contains("负责人", "张三");
1266
1267        let (app_token, table_id, field_names, automatic_fields, filter) = query.into_parts();
1268        let filter = filter.expect("filter should exist");
1269
1270        assert_eq!(app_token, "app_token");
1271        assert_eq!(table_id, "table_id");
1272        assert!(field_names.is_none());
1273        assert!(automatic_fields);
1274        assert_eq!(filter.conjunction.as_deref(), Some("and"));
1275        assert_eq!(filter.conditions.as_ref().map(Vec::len), Some(2));
1276        assert_eq!(filter.conditions.as_ref().unwrap()[0].operator, "is");
1277        assert_eq!(filter.conditions.as_ref().unwrap()[1].operator, "contains");
1278    }
1279
1280    #[cfg(feature = "bitable")]
1281    #[test]
1282    fn test_bitable_record_query_supports_or_and_value_lists() {
1283        let query = BitableRecordQuery::new("app_token", "table_id")
1284            .or()
1285            .field_names(vec!["状态".to_string(), "负责人".to_string()])
1286            .automatic_fields(false)
1287            .where_in("状态", vec!["已完成".to_string(), "已归档".to_string()]);
1288
1289        let (_, _, field_names, automatic_fields, filter) = query.into_parts();
1290        let filter = filter.expect("filter should exist");
1291        let condition = filter.conditions.as_ref().unwrap().first().unwrap();
1292
1293        assert_eq!(field_names.unwrap().len(), 2);
1294        assert!(!automatic_fields);
1295        assert_eq!(filter.conjunction.as_deref(), Some("or"));
1296        assert_eq!(condition.operator, "isAnyOf");
1297        assert_eq!(
1298            condition.value.as_ref().expect("values should exist"),
1299            &vec!["已完成".to_string(), "已归档".to_string()]
1300        );
1301    }
1302
1303    #[cfg(feature = "ccm-core")]
1304    #[test]
1305    fn test_sheet_range_builds_a1_notation() {
1306        let range = SheetRange::from_range_expr("sheet_001", "A1:C5").unwrap();
1307
1308        assert_eq!(range.sheet_id, "sheet_001");
1309        assert_eq!(range.start_cell, "A1");
1310        assert_eq!(range.end_cell.as_deref(), Some("C5"));
1311        assert_eq!(range.to_string(), "sheet_001!A1:C5");
1312    }
1313
1314    #[cfg(feature = "ccm-core")]
1315    #[test]
1316    fn test_sheet_range_parses_single_cell_notation() {
1317        let range = SheetRange::parse("sheet_001!B2").unwrap();
1318
1319        assert_eq!(range.sheet_id, "sheet_001");
1320        assert_eq!(range.start_cell, "B2");
1321        assert!(range.end_cell.is_none());
1322        assert_eq!(range.range_expr(), "B2");
1323    }
1324
1325    #[cfg(feature = "ccm-core")]
1326    #[test]
1327    fn test_sheet_range_rejects_embedded_sheet_prefix() {
1328        let error = SheetRange::from_range_expr("sheet_001", "sheet_002!A1:C5").unwrap_err();
1329        assert!(error.to_string().contains("range_expr"));
1330    }
1331
1332    #[cfg(feature = "ccm-core")]
1333    #[test]
1334    fn test_sheet_write_range_maps_to_batch_write_data() {
1335        let write = SheetWriteRange::new(
1336            SheetRange::from_range_expr("sheet_001", "A1:B2").unwrap(),
1337            vec![vec![serde_json::json!("A1"), serde_json::json!("B1")]],
1338        )
1339        .major_dimension("ROWS");
1340
1341        let batch: crate::ccm::sheets_v2::v2::data_io::models::BatchWriteData = write.into();
1342        assert_eq!(batch.data_range, "sheet_001!A1:B2");
1343        assert_eq!(batch.major_dimension, "ROWS");
1344        assert_eq!(batch.values.len(), 1);
1345    }
1346
1347    #[cfg(feature = "ccm-core")]
1348    #[test]
1349    fn test_folder_children_page_maps_next_page_token() {
1350        let data = crate::ccm::explorer::v2::models::FolderChildrenData {
1351            items: vec![crate::ccm::explorer::v2::models::FileItem {
1352                file_token: "folder_a".to_string(),
1353                title: "Alpha".to_string(),
1354                doc_type: "folder".to_string(),
1355                is_folder: true,
1356                create_time: 1,
1357                update_time: 2,
1358            }],
1359            has_more: true,
1360            page_token: Some("page_2".to_string()),
1361        };
1362
1363        let page: FolderChildrenPage = TypedPage::from(data);
1364        assert_eq!(page.items.len(), 1);
1365        assert_eq!(page.items[0].title, "Alpha");
1366        assert!(page.has_more);
1367        assert_eq!(page.next_page_token.as_deref(), Some("page_2"));
1368    }
1369
1370    #[cfg(feature = "ccm-core")]
1371    #[test]
1372    fn test_folder_children_pager_resume_token() {
1373        let client = DocsClient::new(
1374            Config::builder()
1375                .app_id("test_app")
1376                .app_secret("test_secret")
1377                .build(),
1378        );
1379
1380        let pager = client
1381            .folder_children_pager("folder_token")
1382            .page_size(999)
1383            .doc_type("folder")
1384            .next_page_token("page_2");
1385
1386        assert_eq!(pager.pending_page_token(), Some("page_2"));
1387    }
1388}