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 #[cfg(feature = "ccm-core")]
615 pub ccm: CcmClient,
617
618 #[cfg(any(feature = "base", feature = "bitable"))]
619 pub base: BaseClient,
621
622 #[cfg(any(feature = "baike", feature = "lingo"))]
623 pub baike: BaikeClient,
625
626 #[cfg(feature = "minutes")]
627 pub minutes: MinutesClient,
629}
630
631impl DocsClient {
632 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 pub fn config(&self) -> &Config {
650 &self.config
651 }
652
653 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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#[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 pub fn config(&self) -> &Config {
1073 &self.config
1074 }
1075}
1076
1077#[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 pub fn config(&self) -> &Config {
1092 &self.config
1093 }
1094
1095 #[cfg(feature = "bitable")]
1096 pub fn bitable(&self) -> BitableClient {
1098 BitableClient::new(self.config.clone())
1099 }
1100}
1101
1102#[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 pub fn config(&self) -> &Config {
1117 &self.config
1118 }
1119}
1120
1121#[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 pub fn config(&self) -> &Config {
1136 &self.config
1137 }
1138}
1139
1140#[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 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 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 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}