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).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.config().clone();
30//! // let file = UploadAllRequest::new(config, ...).execute().await?;
31//!
32//! // 访问多维表格
33//! let config = docs.config().clone();
34//! // let table = CreateTableRequest::new(config, ...).execute().await?;
35//!
36//! // 访问知识库
37//! let config = docs.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/// Drive Explorer 文件夹子项的分页结果类型别名。
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.config()` 直路径访问(ADR 0001:5 个 config-holder 子客户端已砍,~15 个真 helper 全保留)。
610#[derive(Debug, Clone)]
611pub struct DocsClient {
612    config: Arc<Config>,
613}
614
615impl DocsClient {
616    /// 创建新的实例。
617    pub fn new(config: Config) -> Self {
618        Self {
619            config: Arc::new(config),
620        }
621    }
622
623    /// 返回共享配置。
624    pub fn config(&self) -> &Config {
625        &self.config
626    }
627
628    /// 创建文件夹子项分页 helper。
629    #[cfg(feature = "ccm-core")]
630    pub fn folder_children_pager(&self, folder_token: impl Into<String>) -> FolderChildrenPager {
631        FolderChildrenPager::new(self.config.clone(), folder_token)
632    }
633
634    /// 获取文件夹下的全部子项,自动处理分页。
635    #[cfg(feature = "ccm-core")]
636    pub async fn list_folder_children_all(
637        &self,
638        folder_token: &str,
639        doc_type: Option<&str>,
640    ) -> SDKResult<Vec<crate::ccm::explorer::v2::models::FileItem>> {
641        let mut pager = self
642            .folder_children_pager(folder_token)
643            .page_size(crate::common::constants::MAX_PAGE_SIZE);
644        if let Some(doc_type) = doc_type {
645            pager = pager.doc_type(doc_type);
646        }
647
648        pager.collect_all().await
649    }
650
651    /// 读取多维表格全部记录,自动处理分页。
652    #[cfg(feature = "bitable")]
653    pub async fn search_bitable_records_all(
654        &self,
655        app_token: &str,
656        table_id: &str,
657    ) -> SDKResult<Vec<crate::base::bitable::v1::app::table::record::models::Record>> {
658        use crate::base::bitable::v1::app::table::record::search::SearchRecordRequest;
659
660        SearchRecordRequest::new(self.config().clone())
661            .app_token(app_token.to_string())
662            .table_id(table_id.to_string())
663            .automatic_fields(true)
664            .fetch_all()
665            .await
666    }
667
668    /// 使用 helper 风格执行常见多维表格过滤查询,并自动处理分页。
669    #[cfg(feature = "bitable")]
670    pub async fn query_bitable_records(
671        &self,
672        query: BitableRecordQuery,
673    ) -> SDKResult<Vec<crate::base::bitable::v1::app::table::record::models::Record>> {
674        use crate::base::bitable::v1::app::table::record::search::SearchRecordRequest;
675
676        let (app_token, table_id, field_names, automatic_fields, filter) = query.into_parts();
677        let mut request = SearchRecordRequest::new(self.config().clone())
678            .app_token(app_token)
679            .table_id(table_id)
680            .automatic_fields(automatic_fields);
681
682        if let Some(field_names) = field_names {
683            request = request.field_names(field_names);
684        }
685
686        if let Some(filter) = filter {
687            request = request.filter(filter);
688        }
689
690        request.fetch_all().await
691    }
692
693    /// 读取多个单元格范围,返回聚合后的范围数据。
694    #[cfg(feature = "ccm-core")]
695    pub async fn read_multiple_ranges(
696        &self,
697        spreadsheet_token: &str,
698        ranges: Vec<String>,
699    ) -> SDKResult<crate::ccm::sheets_v2::v2::data_io::models::MultipleRangeData> {
700        use crate::ccm::sheets_v2::v2::data_io::{
701            ReadMultipleRangesParams, read_multiple_ranges as read_multiple_ranges_api,
702        };
703
704        let response = read_multiple_ranges_api(
705            self.config(),
706            spreadsheet_token,
707            ReadMultipleRangesParams {
708                ranges,
709                value_render_option: None,
710                date_render_option: None,
711            },
712        )
713        .await?;
714
715        response
716            .data
717            .ok_or_else(|| CoreError::api_data_error("读取多个范围"))
718    }
719
720    /// 使用 typed `SheetRange` 批量读取多个范围。
721    #[cfg(feature = "ccm-core")]
722    pub async fn read_sheet_ranges(
723        &self,
724        spreadsheet_token: &str,
725        ranges: Vec<SheetRange>,
726    ) -> SDKResult<crate::ccm::sheets_v2::v2::data_io::models::MultipleRangeData> {
727        self.read_multiple_ranges(
728            spreadsheet_token,
729            ranges.into_iter().map(|range| range.to_string()).collect(),
730        )
731        .await
732    }
733
734    /// 批量写入多个单元格范围。
735    #[cfg(feature = "ccm-core")]
736    pub async fn write_multiple_ranges(
737        &self,
738        spreadsheet_token: &str,
739        data: Vec<crate::ccm::sheets_v2::v2::data_io::models::BatchWriteData>,
740    ) -> SDKResult<crate::ccm::sheets_v2::v2::data_io::models::BatchUpdateResult> {
741        use crate::ccm::sheets_v2::v2::data_io::{BatchWriteRangesParams, batch_write_ranges};
742
743        let response = batch_write_ranges(
744            self.config(),
745            spreadsheet_token,
746            BatchWriteRangesParams {
747                data,
748                include_style: None,
749            },
750        )
751        .await?;
752
753        response
754            .data
755            .ok_or_else(|| CoreError::api_data_error("批量写入多个范围"))
756    }
757
758    /// 使用 typed `SheetRange` 批量写入多个范围。
759    #[cfg(feature = "ccm-core")]
760    pub async fn write_sheet_ranges(
761        &self,
762        spreadsheet_token: &str,
763        ranges: Vec<SheetWriteRange>,
764    ) -> SDKResult<crate::ccm::sheets_v2::v2::data_io::models::BatchUpdateResult> {
765        self.write_multiple_ranges(
766            spreadsheet_token,
767            ranges.into_iter().map(Into::into).collect(),
768        )
769        .await
770    }
771
772    /// 使用 typed `SheetRange` 追加数据。
773    #[cfg(feature = "ccm-core")]
774    pub async fn append_sheet_range(
775        &self,
776        spreadsheet_token: &str,
777        range: SheetRange,
778        values: Vec<Vec<serde_json::Value>>,
779    ) -> SDKResult<crate::ccm::sheets_v2::v2::data_io::models::AppendResult> {
780        use crate::ccm::sheets_v2::v2::data_io::{AppendValuesParams, append_values};
781
782        let response = append_values(
783            self.config(),
784            spreadsheet_token,
785            AppendValuesParams {
786                range: range.to_string(),
787                major_dimension: None,
788                values,
789            },
790        )
791        .await?;
792
793        response
794            .data
795            .ok_or_else(|| CoreError::api_data_error("追加工作表范围"))
796    }
797
798    /// 使用默认的 `parent_type=explorer` 与自动 size 推断上传 Drive 文件。
799    #[cfg(feature = "ccm-core")]
800    pub async fn upload_drive_file(
801        &self,
802        folder_token: &str,
803        file: DriveUploadFile,
804    ) -> SDKResult<crate::ccm::drive::v1::file::UploadAllResponse> {
805        file.into_request(self.config().clone(), folder_token)
806            .execute()
807            .await
808    }
809
810    /// 下载完整 Drive 文件内容。
811    #[cfg(feature = "ccm-core")]
812    pub async fn download_drive_file(&self, file_token: &str) -> SDKResult<Vec<u8>> {
813        use crate::ccm::drive::v1::file::DownloadFileRequest;
814
815        let resp = DownloadFileRequest::new(self.config().clone(), file_token)
816            .execute()
817            .await?;
818        resp.decode("下载 Drive 文件")
819    }
820
821    /// 按范围下载 Drive 文件内容。
822    #[cfg(feature = "ccm-core")]
823    pub async fn download_drive_file_range(
824        &self,
825        file_token: &str,
826        range: DriveDownloadRange,
827    ) -> SDKResult<Vec<u8>> {
828        use crate::ccm::drive::v1::file::DownloadFileRequest;
829
830        let resp = DownloadFileRequest::new(self.config().clone(), file_token)
831            .range(range.to_string())
832            .execute()
833            .await?;
834        resp.decode("按范围下载 Drive 文件")
835    }
836
837    /// 获取指定知识空间下的所有节点,自动处理分页。
838    #[cfg(feature = "ccm-core")]
839    pub async fn list_wiki_space_nodes_all(
840        &self,
841        space_id: &str,
842        parent_node_token: Option<&str>,
843    ) -> SDKResult<Vec<crate::ccm::wiki::v2::models::WikiSpaceNode>> {
844        use crate::ccm::wiki::v2::space::node::{
845            ListWikiSpaceNodesParams, ListWikiSpaceNodesRequest,
846        };
847
848        let mut items = Vec::new();
849        let mut page_token: Option<String> = None;
850
851        loop {
852            let response = ListWikiSpaceNodesRequest::new(self.config().clone())
853                .space_id(space_id)
854                .execute(Some(ListWikiSpaceNodesParams {
855                    parent_node_token: parent_node_token.map(str::to_string),
856                    page_size: Some(crate::common::constants::MAX_PAGE_SIZE),
857                    page_token: page_token.clone(),
858                }))
859                .await?;
860
861            items.extend(response.items);
862
863            if !response.has_more.unwrap_or(false) {
864                break;
865            }
866
867            page_token = response.page_token;
868        }
869
870        Ok(items)
871    }
872
873    /// 在指定层级下按标题查找单个 Wiki 节点。
874    #[cfg(feature = "ccm-core")]
875    pub async fn find_wiki_node_by_title(
876        &self,
877        space_id: &str,
878        title: &str,
879        parent_node_token: Option<&str>,
880    ) -> SDKResult<crate::ccm::wiki::v2::models::WikiSpaceNode> {
881        let items = self
882            .list_wiki_space_nodes_all(space_id, parent_node_token)
883            .await?;
884        find_unique_wiki_node_by_title(&items, title)
885    }
886
887    /// 通过路径逐级导航 Wiki 节点。
888    #[cfg(feature = "ccm-core")]
889    pub async fn find_wiki_node_by_path(
890        &self,
891        space_id: &str,
892        path: impl AsRef<str>,
893    ) -> SDKResult<crate::ccm::wiki::v2::models::WikiSpaceNode> {
894        let path = WikiNodePath::parse(path)?;
895        let mut parent_node_token: Option<String> = None;
896        let mut current_node = None;
897
898        for segment in path.segments() {
899            let node = self
900                .find_wiki_node_by_title(space_id, segment, parent_node_token.as_deref())
901                .await?;
902            parent_node_token = Some(node.node_token.clone());
903            current_node = Some(node);
904        }
905
906        current_node.ok_or_else(|| business_error(format!("未找到 Wiki 路径: {path}")))
907    }
908
909    /// 根据工作表标题查找工作表。
910    #[cfg(feature = "ccm-core")]
911    pub async fn find_sheet_by_title(
912        &self,
913        spreadsheet_token: &str,
914        title: &str,
915    ) -> SDKResult<crate::ccm::sheets_v2::v2::spreadsheet::models::SpreadsheetSheetInfo> {
916        let sheets = self.list_sheet_infos(spreadsheet_token).await?;
917
918        find_sheet_info(&sheets, title)
919            .ok_or_else(|| business_error(format!("未找到工作表: {title}")))
920    }
921
922    /// 按工作表标题解析单个范围,返回统一的 `SheetRange` 表达。
923    #[cfg(feature = "ccm-core")]
924    pub async fn resolve_sheet_range_by_title(
925        &self,
926        spreadsheet_token: &str,
927        title: &str,
928        range_expr: &str,
929    ) -> SDKResult<SheetRange> {
930        let sheet = self.find_sheet_by_title(spreadsheet_token, title).await?;
931        SheetRange::from_range_expr(sheet.sheet_id, range_expr)
932    }
933
934    #[cfg(feature = "ccm-core")]
935    /// 列出 sheet infos。
936    pub async fn list_sheet_infos(
937        &self,
938        spreadsheet_token: &str,
939    ) -> SDKResult<Vec<crate::ccm::sheets_v2::v2::spreadsheet::models::SpreadsheetSheetInfo>> {
940        use crate::ccm::sheets::v3::spreadsheet::sheet::query::query_sheets;
941        use crate::ccm::sheets_v2::v2::spreadsheet::models::SpreadsheetSheetInfo;
942
943        log::info!("[OPENLARK DEBUG] list_sheet_infos called with token: {spreadsheet_token}");
944
945        let response = query_sheets(self.config(), spreadsheet_token).await?;
946
947        log::info!(
948            "[OPENLARK DEBUG] query_sheets response count: {}",
949            response.sheets.len()
950        );
951
952        let sheets: Vec<SpreadsheetSheetInfo> =
953            response.sheets.into_iter().map(map_v3_sheet_info).collect();
954
955        if sheets.is_empty() {
956            return Err(CoreError::api_data_error("获取工作表列表"));
957        }
958
959        Ok(sheets)
960    }
961}
962
963#[cfg(feature = "ccm-core")]
964fn map_v3_sheet_info(
965    sheet: crate::ccm::sheets::v3::spreadsheet::Sheet,
966) -> crate::ccm::sheets_v2::v2::spreadsheet::models::SpreadsheetSheetInfo {
967    crate::ccm::sheets_v2::v2::spreadsheet::models::SpreadsheetSheetInfo {
968        sheet_id: sheet.sheet_id,
969        title: sheet.title,
970        sheet_type: sheet.resource_type,
971        row_count: sheet.grid_properties.row_count,
972        column_count: sheet.grid_properties.column_count,
973    }
974}
975
976#[cfg(feature = "ccm-core")]
977fn find_sheet_info(
978    sheets: &[crate::ccm::sheets_v2::v2::spreadsheet::models::SpreadsheetSheetInfo],
979    title: &str,
980) -> Option<crate::ccm::sheets_v2::v2::spreadsheet::models::SpreadsheetSheetInfo> {
981    sheets.iter().find(|sheet| sheet.title == title).cloned()
982}
983
984#[cfg(feature = "ccm-core")]
985fn validate_sheet_range_part(field: &str, value: impl Into<String>) -> SDKResult<String> {
986    let value = value.into().trim().to_string();
987    if value.is_empty() {
988        return Err(validation_error(field, &format!("{field} 不能为空")));
989    }
990    Ok(value)
991}
992
993#[cfg(feature = "ccm-core")]
994fn validate_wiki_path_segment(value: impl Into<String>) -> SDKResult<String> {
995    let value = value.into().trim().to_string();
996    if value.is_empty() {
997        return Err(validation_error(
998            "wiki_path_segment",
999            "wiki_path_segment 不能为空",
1000        ));
1001    }
1002    Ok(value)
1003}
1004
1005#[cfg(feature = "ccm-core")]
1006fn find_unique_wiki_node_by_title(
1007    nodes: &[crate::ccm::wiki::v2::models::WikiSpaceNode],
1008    title: &str,
1009) -> SDKResult<crate::ccm::wiki::v2::models::WikiSpaceNode> {
1010    let title = title.trim();
1011    if title.is_empty() {
1012        return Err(validation_error("title", "title 不能为空"));
1013    }
1014
1015    let mut matches = nodes
1016        .iter()
1017        .filter(|node| node.title.as_deref() == Some(title))
1018        .cloned();
1019
1020    let first = matches
1021        .next()
1022        .ok_or_else(|| business_error(format!("未找到 Wiki 节点标题: {title}")))?;
1023
1024    if matches.next().is_some() {
1025        return Err(business_error(format!(
1026            "找到多个同名 Wiki 节点,请缩小范围: {title}"
1027        )));
1028    }
1029
1030    Ok(first)
1031}
1032
1033#[cfg(test)]
1034mod tests {
1035    use super::*;
1036
1037    #[test]
1038    fn test_typed_page_last_page_state() {
1039        let page = TypedPage::new(vec![1, 2], false, None);
1040        assert!(page.is_last_page());
1041        assert_eq!(page.into_items(), vec![1, 2]);
1042    }
1043
1044    #[cfg(feature = "ccm-core")]
1045    #[test]
1046    fn test_drive_download_range_formats_header() {
1047        let full = DriveDownloadRange::from_start(0).with_end(1023);
1048        let tail = DriveDownloadRange::from_start(2048);
1049
1050        assert_eq!(full.to_string(), "bytes=0-1023");
1051        assert_eq!(tail.to_string(), "bytes=2048-");
1052    }
1053
1054    #[cfg(feature = "ccm-core")]
1055    #[test]
1056    fn test_drive_upload_file_builds_default_request() {
1057        let upload = DriveUploadFile::new("report.csv", vec![1, 2, 3]).checksum("abc123");
1058        let request = upload.into_request(
1059            Config::builder()
1060                .app_id("test_app")
1061                .app_secret("test_secret")
1062                .build(),
1063            "folder_token",
1064        );
1065
1066        assert_eq!(request.file_name, "report.csv");
1067        assert_eq!(request.parent_node, "folder_token");
1068        assert_eq!(request.parent_type, "explorer");
1069        assert_eq!(request.size, 3);
1070        assert_eq!(request.checksum.as_deref(), Some("abc123"));
1071        assert_eq!(request.file, vec![1, 2, 3]);
1072    }
1073
1074    #[cfg(feature = "ccm-core")]
1075    #[test]
1076    fn test_wiki_node_path_parses_segments() {
1077        let path = WikiNodePath::parse("/产品文档/发布计划/周报/").unwrap();
1078
1079        assert_eq!(
1080            path.segments(),
1081            &vec![
1082                "产品文档".to_string(),
1083                "发布计划".to_string(),
1084                "周报".to_string()
1085            ]
1086        );
1087        assert_eq!(path.to_string(), "产品文档/发布计划/周报");
1088    }
1089
1090    #[cfg(feature = "ccm-core")]
1091    #[test]
1092    fn test_find_unique_wiki_node_by_title_rejects_duplicates() {
1093        let nodes = vec![
1094            crate::ccm::wiki::v2::models::WikiSpaceNode {
1095                space_id: "space_1".to_string(),
1096                node_token: "node_a".to_string(),
1097                obj_token: None,
1098                obj_type: None,
1099                parent_node_token: None,
1100                title: Some("周报".to_string()),
1101                url: None,
1102            },
1103            crate::ccm::wiki::v2::models::WikiSpaceNode {
1104                space_id: "space_1".to_string(),
1105                node_token: "node_b".to_string(),
1106                obj_token: None,
1107                obj_type: None,
1108                parent_node_token: None,
1109                title: Some("周报".to_string()),
1110                url: None,
1111            },
1112        ];
1113
1114        let error = find_unique_wiki_node_by_title(&nodes, "周报").unwrap_err();
1115        assert!(error.to_string().contains("多个同名"));
1116    }
1117
1118    #[cfg(feature = "bitable")]
1119    #[test]
1120    fn test_bitable_record_query_builds_default_and_filters() {
1121        let query = BitableRecordQuery::new("app_token", "table_id")
1122            .where_equals("状态", "进行中")
1123            .where_contains("负责人", "张三");
1124
1125        let (app_token, table_id, field_names, automatic_fields, filter) = query.into_parts();
1126        let filter = filter.expect("filter should exist");
1127
1128        assert_eq!(app_token, "app_token");
1129        assert_eq!(table_id, "table_id");
1130        assert!(field_names.is_none());
1131        assert!(automatic_fields);
1132        assert_eq!(filter.conjunction.as_deref(), Some("and"));
1133        assert_eq!(filter.conditions.as_ref().map(Vec::len), Some(2));
1134        assert_eq!(filter.conditions.as_ref().unwrap()[0].operator, "is");
1135        assert_eq!(filter.conditions.as_ref().unwrap()[1].operator, "contains");
1136    }
1137
1138    #[cfg(feature = "bitable")]
1139    #[test]
1140    fn test_bitable_record_query_supports_or_and_value_lists() {
1141        let query = BitableRecordQuery::new("app_token", "table_id")
1142            .or()
1143            .field_names(vec!["状态".to_string(), "负责人".to_string()])
1144            .automatic_fields(false)
1145            .where_in("状态", vec!["已完成".to_string(), "已归档".to_string()]);
1146
1147        let (_, _, field_names, automatic_fields, filter) = query.into_parts();
1148        let filter = filter.expect("filter should exist");
1149        let condition = filter.conditions.as_ref().unwrap().first().unwrap();
1150
1151        assert_eq!(field_names.unwrap().len(), 2);
1152        assert!(!automatic_fields);
1153        assert_eq!(filter.conjunction.as_deref(), Some("or"));
1154        assert_eq!(condition.operator, "isAnyOf");
1155        assert_eq!(
1156            condition.value.as_ref().expect("values should exist"),
1157            &vec!["已完成".to_string(), "已归档".to_string()]
1158        );
1159    }
1160
1161    #[cfg(feature = "ccm-core")]
1162    #[test]
1163    fn test_sheet_range_builds_a1_notation() {
1164        let range = SheetRange::from_range_expr("sheet_001", "A1:C5").unwrap();
1165
1166        assert_eq!(range.sheet_id, "sheet_001");
1167        assert_eq!(range.start_cell, "A1");
1168        assert_eq!(range.end_cell.as_deref(), Some("C5"));
1169        assert_eq!(range.to_string(), "sheet_001!A1:C5");
1170    }
1171
1172    #[cfg(feature = "ccm-core")]
1173    #[test]
1174    fn test_sheet_range_parses_single_cell_notation() {
1175        let range = SheetRange::parse("sheet_001!B2").unwrap();
1176
1177        assert_eq!(range.sheet_id, "sheet_001");
1178        assert_eq!(range.start_cell, "B2");
1179        assert!(range.end_cell.is_none());
1180        assert_eq!(range.range_expr(), "B2");
1181    }
1182
1183    #[cfg(feature = "ccm-core")]
1184    #[test]
1185    fn test_sheet_range_rejects_embedded_sheet_prefix() {
1186        let error = SheetRange::from_range_expr("sheet_001", "sheet_002!A1:C5").unwrap_err();
1187        assert!(error.to_string().contains("range_expr"));
1188    }
1189
1190    #[cfg(feature = "ccm-core")]
1191    #[test]
1192    fn test_sheet_write_range_maps_to_batch_write_data() {
1193        let write = SheetWriteRange::new(
1194            SheetRange::from_range_expr("sheet_001", "A1:B2").unwrap(),
1195            vec![vec![serde_json::json!("A1"), serde_json::json!("B1")]],
1196        )
1197        .major_dimension("ROWS");
1198
1199        let batch: crate::ccm::sheets_v2::v2::data_io::models::BatchWriteData = write.into();
1200        assert_eq!(batch.data_range, "sheet_001!A1:B2");
1201        assert_eq!(batch.major_dimension, "ROWS");
1202        assert_eq!(batch.values.len(), 1);
1203    }
1204
1205    #[cfg(feature = "ccm-core")]
1206    #[test]
1207    fn test_folder_children_page_maps_next_page_token() {
1208        let data = crate::ccm::explorer::v2::models::FolderChildrenData {
1209            items: vec![crate::ccm::explorer::v2::models::FileItem {
1210                file_token: "folder_a".to_string(),
1211                title: "Alpha".to_string(),
1212                doc_type: "folder".to_string(),
1213                is_folder: true,
1214                create_time: 1,
1215                update_time: 2,
1216            }],
1217            has_more: true,
1218            page_token: Some("page_2".to_string()),
1219        };
1220
1221        let page: FolderChildrenPage = TypedPage::from(data);
1222        assert_eq!(page.items.len(), 1);
1223        assert_eq!(page.items[0].title, "Alpha");
1224        assert!(page.has_more);
1225        assert_eq!(page.next_page_token.as_deref(), Some("page_2"));
1226    }
1227
1228    #[cfg(feature = "ccm-core")]
1229    #[test]
1230    fn test_folder_children_pager_resume_token() {
1231        let client = DocsClient::new(
1232            Config::builder()
1233                .app_id("test_app")
1234                .app_secret("test_secret")
1235                .build(),
1236        );
1237
1238        let pager = client
1239            .folder_children_pager("folder_token")
1240            .page_size(999)
1241            .doc_type("folder")
1242            .next_page_token("page_2");
1243
1244        assert_eq!(pager.pending_page_token(), Some("page_2"));
1245    }
1246}