1#[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#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct TypedPage<T> {
54 pub items: Vec<T>,
56 pub has_more: bool,
58 pub next_page_token: Option<String>,
60}
61
62impl<T> TypedPage<T> {
63 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 pub fn empty() -> Self {
74 Self::new(Vec::new(), false, None)
75 }
76
77 pub fn is_last_page(&self) -> bool {
79 !self.has_more
80 }
81
82 pub fn into_items(self) -> Vec<T> {
84 self.items
85 }
86}
87
88#[cfg(feature = "ccm-core")]
89pub type FolderChildrenPage = TypedPage<crate::ccm::explorer::v2::models::FileItem>;
91
92#[cfg(feature = "ccm-core")]
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct SheetRange {
99 pub sheet_id: String,
101 pub start_cell: String,
103 pub end_cell: Option<String>,
105}
106
107#[cfg(feature = "ccm-core")]
108impl SheetRange {
109 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 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 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 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 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 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#[cfg(feature = "bitable")]
195#[derive(Debug, Clone, PartialEq)]
196pub struct BitableRecordQuery {
197 pub app_token: String,
199 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 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 pub fn field_names(mut self, field_names: Vec<String>) -> Self {
223 self.field_names = Some(field_names);
224 self
225 }
226
227 pub fn automatic_fields(mut self, automatic_fields: bool) -> Self {
229 self.automatic_fields = automatic_fields;
230 self
231 }
232
233 pub fn or(mut self) -> Self {
235 self.conjunction = "or".to_string();
236 self
237 }
238
239 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 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 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#[cfg(feature = "ccm-core")]
311#[derive(Debug, Clone, PartialEq)]
312pub struct SheetWriteRange {
313 pub range: SheetRange,
315 pub major_dimension: String,
317 pub values: Vec<Vec<serde_json::Value>>,
319}
320
321#[cfg(feature = "ccm-core")]
322impl SheetWriteRange {
323 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 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#[cfg(feature = "ccm-core")]
354#[derive(Debug, Clone, PartialEq, Eq)]
355pub struct DriveDownloadRange {
356 pub start: u64,
358 pub end: Option<u64>,
360}
361
362#[cfg(feature = "ccm-core")]
363impl DriveDownloadRange {
364 pub fn from_start(start: u64) -> Self {
366 Self { start, end: None }
367 }
368
369 pub fn with_end(mut self, end: u64) -> Self {
371 self.end = Some(end);
372 self
373 }
374
375 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#[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 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 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 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#[cfg(feature = "ccm-core")]
444#[derive(Debug, Clone, PartialEq, Eq)]
445pub struct DriveUploadFile {
446 pub file_name: String,
448 pub content: Vec<u8>,
450 pub checksum: Option<String>,
452}
453
454#[cfg(feature = "ccm-core")]
455impl DriveUploadFile {
456 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 pub fn checksum(mut self, checksum: impl Into<String>) -> Self {
467 self.checksum = Some(checksum.into());
468 self
469 }
470
471 pub fn size(&self) -> usize {
473 self.content.len()
474 }
475
476 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#[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 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 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 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 pub fn pending_page_token(&self) -> Option<&str> {
553 self.next_page_token.as_deref()
554 }
555
556 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 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#[derive(Debug, Clone)]
611pub struct DocsClient {
612 config: Arc<Config>,
613}
614
615impl DocsClient {
616 pub fn new(config: Config) -> Self {
618 Self {
619 config: Arc::new(config),
620 }
621 }
622
623 pub fn config(&self) -> &Config {
625 &self.config
626 }
627
628 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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}