1use tracing::{debug, info};
4
5use crate::auth::TokenManager;
6use crate::error::{Result, WeChatError};
7use crate::http::WeChatHttpClient;
8use crate::markdown::{MarkdownContent, MarkdownParser};
9use crate::mermaid::MermaidProcessor;
10use crate::theme::ThemeManager;
11use crate::upload::{Article, DraftInfo, DraftManager, ImageUploader};
12use crate::utils;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15
16#[derive(Debug, Clone)]
18pub struct UploadOptions {
19 pub theme: String,
21 pub title: Option<String>,
23 pub author: Option<String>,
25 pub cover_image: Option<String>,
27 pub show_cover: bool,
29 pub enable_comments: bool,
31 pub fans_only_comments: bool,
33 pub source_url: Option<String>,
35}
36
37impl Default for UploadOptions {
38 fn default() -> Self {
39 Self {
40 theme: "default".to_string(),
41 title: None,
42 author: None,
43 cover_image: None,
44 show_cover: true,
45 enable_comments: false,
46 fans_only_comments: false,
47 source_url: None,
48 }
49 }
50}
51
52impl UploadOptions {
53 pub fn with_theme(theme: impl Into<String>) -> Self {
55 Self {
56 theme: theme.into(),
57 ..Default::default()
58 }
59 }
60
61 pub fn title(mut self, title: impl Into<String>) -> Self {
63 self.title = Some(title.into());
64 self
65 }
66
67 pub fn author(mut self, author: impl Into<String>) -> Self {
69 self.author = Some(author.into());
70 self
71 }
72
73 pub fn cover_image(mut self, path: impl Into<String>) -> Self {
75 self.cover_image = Some(path.into());
76 self
77 }
78
79 pub fn show_cover(mut self, show: bool) -> Self {
81 self.show_cover = show;
82 self
83 }
84
85 pub fn comments(mut self, enable: bool, fans_only: bool) -> Self {
87 self.enable_comments = enable;
88 self.fans_only_comments = fans_only;
89 self
90 }
91
92 pub fn source_url(mut self, url: impl Into<String>) -> Self {
94 self.source_url = Some(url.into());
95 self
96 }
97}
98
99#[derive(Debug)]
101pub struct WeChatClient {
102 http_client: Arc<WeChatHttpClient>,
103 token_manager: Arc<TokenManager>,
104 image_uploader: ImageUploader,
105 draft_manager: DraftManager,
106 markdown_parser: MarkdownParser,
107 theme_manager: ThemeManager,
108}
109
110impl WeChatClient {
111 pub async fn new(app_id: impl Into<String>, app_secret: impl Into<String>) -> Result<Self> {
113 let app_id = app_id.into();
114 let app_secret = app_secret.into();
115
116 utils::validate_app_credentials(&app_id, &app_secret).map_err(WeChatError::config_error)?;
118
119 let http_client = Arc::new(WeChatHttpClient::new()?);
121
122 let token_manager = Arc::new(TokenManager::new(
124 app_id,
125 app_secret,
126 Arc::clone(&http_client),
127 ));
128
129 let image_uploader =
131 ImageUploader::new(Arc::clone(&http_client), Arc::clone(&token_manager));
132
133 let draft_manager = DraftManager::new(Arc::clone(&http_client), Arc::clone(&token_manager));
134
135 let markdown_parser = MarkdownParser::new();
136 let theme_manager = ThemeManager::new();
137
138 Ok(Self {
139 http_client,
140 token_manager,
141 image_uploader,
142 draft_manager,
143 markdown_parser,
144 theme_manager,
145 })
146 }
147
148 pub async fn upload(&self, markdown_path: &str) -> Result<String> {
163 let options = UploadOptions::default();
164 self.upload_with_options(markdown_path, options).await
165 }
166
167 pub async fn upload_with_options(
176 &self,
177 markdown_path: &str,
178 options: UploadOptions,
179 ) -> Result<String> {
180 let markdown_path = Path::new(markdown_path);
181
182 self.validate_upload_input(markdown_path, &options).await?;
184
185 info!("Starting upload process for: {}", markdown_path.display());
186
187 let mut content = self.parse_markdown_file(markdown_path).await?;
189 debug!("Found {} images in content", content.images.len());
190
191 let base_dir = utils::get_base_directory(markdown_path).unwrap_or_else(|| Path::new("."));
193 let document_slug = MermaidProcessor::extract_slug_from_path(markdown_path);
194 let mermaid_processor = MermaidProcessor::new(base_dir.to_path_buf(), document_slug);
195
196 let (modified_content, mermaid_images) = mermaid_processor
197 .process_mermaid_content_with_source_path(
198 &content.content,
199 base_dir,
200 Some(markdown_path),
201 )
202 .await?;
203
204 content.content = modified_content;
206
207 content.images.extend(mermaid_images.clone());
209
210 debug!(
211 "Total images to upload (including Mermaid): {}",
212 content.images.len()
213 );
214
215 let upload_results = self
217 .image_uploader
218 .upload_images(content.images.clone(), base_dir)
219 .await?;
220 info!("Completed uploading {} images", upload_results.len());
221
222 let url_mapping = self.draft_manager.create_url_mapping(&upload_results);
224 content.replace_image_urls(&url_mapping)?;
225
226 let cover_path = options
228 .cover_image
229 .as_ref()
230 .or(content.cover.as_ref())
231 .expect("Cover image should be available from validation");
232
233 info!("Starting to upload cover image: {}", cover_path);
234 let cover_media_id = Some(self.upload_cover_image(cover_path, base_dir).await?);
235 info!("Completed uploading cover image");
236
237 let theme = content
239 .theme
240 .as_ref()
241 .or(Some(&options.theme))
242 .map(|t| t.as_str())
243 .unwrap_or("default");
244
245 if !self.theme_manager.has_theme(theme) {
247 return Err(WeChatError::ThemeNotFound {
248 theme: theme.to_string(),
249 });
250 }
251
252 let html_content = self.render_content(&content, theme, &options)?;
253
254 let article = self.create_article(&content, &options, html_content, cover_media_id);
256 let draft_id = self.draft_manager.create_draft(vec![article]).await?;
257
258 info!("Successfully created draft with ID: {draft_id}");
259 Ok(draft_id)
260 }
261
262 pub async fn get_draft(&self, media_id: &str) -> Result<DraftInfo> {
264 self.draft_manager.get_draft(media_id).await
265 }
266
267 pub async fn update_draft(&self, media_id: &str, markdown_path: &str) -> Result<()> {
269 let options = UploadOptions::default();
270 self.update_draft_with_options(media_id, markdown_path, options)
271 .await
272 }
273
274 pub async fn update_draft_with_options(
276 &self,
277 media_id: &str,
278 markdown_path: &str,
279 options: UploadOptions,
280 ) -> Result<()> {
281 let markdown_path = Path::new(markdown_path);
282 self.validate_upload_input(markdown_path, &options).await?;
283
284 info!(
285 "Updating draft {} with: {}",
286 media_id,
287 markdown_path.display()
288 );
289
290 let mut content = self.parse_markdown_file(markdown_path).await?;
292 let base_dir = utils::get_base_directory(markdown_path).unwrap_or_else(|| Path::new("."));
293
294 let document_slug = MermaidProcessor::extract_slug_from_path(markdown_path);
296 let mermaid_processor = MermaidProcessor::new(base_dir.to_path_buf(), document_slug);
297
298 let (modified_content, mermaid_images) = mermaid_processor
299 .process_mermaid_content_with_source_path(
300 &content.content,
301 base_dir,
302 Some(markdown_path),
303 )
304 .await?;
305
306 content.content = modified_content;
308
309 content.images.extend(mermaid_images);
311
312 let upload_results = self
313 .image_uploader
314 .upload_images(content.images.clone(), base_dir)
315 .await?;
316
317 let url_mapping = self.draft_manager.create_url_mapping(&upload_results);
318 content.replace_image_urls(&url_mapping)?;
319
320 let cover_path = options
321 .cover_image
322 .as_ref()
323 .or(content.cover.as_ref())
324 .expect("Cover image should be available from validation");
325
326 let cover_media_id = Some(self.upload_cover_image(cover_path, base_dir).await?);
327
328 let theme = content
329 .theme
330 .as_ref()
331 .or(Some(&options.theme))
332 .map(|t| t.as_str())
333 .unwrap_or("default");
334
335 if !self.theme_manager.has_theme(theme) {
337 return Err(WeChatError::ThemeNotFound {
338 theme: theme.to_string(),
339 });
340 }
341
342 let html_content = self.render_content(&content, theme, &options)?;
343 let article = self.create_article(&content, &options, html_content, cover_media_id);
344
345 self.draft_manager
346 .update_draft(media_id, vec![article])
347 .await?;
348
349 info!("Successfully updated draft: {media_id}");
350 Ok(())
351 }
352
353 pub async fn delete_draft(&self, media_id: &str) -> Result<()> {
355 self.draft_manager.delete_draft(media_id).await
356 }
357
358 pub async fn list_drafts(&self, offset: u32, count: u32) -> Result<Vec<DraftInfo>> {
360 self.draft_manager.list_drafts(offset, count).await
361 }
362
363 pub async fn upload_image(&self, image_path: &str) -> Result<String> {
365 let image_path = Path::new(image_path);
366
367 if !utils::file_exists(image_path).await {
368 return Err(WeChatError::FileNotFound {
369 path: image_path.display().to_string(),
370 });
371 }
372
373 if !utils::is_image_file(image_path) {
374 return Err(WeChatError::config_error(
375 "File is not a supported image format",
376 ));
377 }
378
379 let image_ref = crate::markdown::ImageRef::new(
381 "Uploaded image".to_string(),
382 image_path.display().to_string(),
383 (0, 0),
384 );
385
386 let base_dir = utils::get_base_directory(image_path).unwrap_or_else(|| Path::new("."));
387
388 let results = self
389 .image_uploader
390 .upload_images(vec![image_ref], base_dir)
391 .await?;
392
393 Ok(results.into_iter().next().unwrap().url)
394 }
395
396 pub async fn create_draft(&self, articles: Vec<Article>) -> Result<String> {
398 self.draft_manager.create_draft(articles).await
399 }
400
401 pub fn available_themes(&self) -> Vec<&String> {
403 self.theme_manager.available_themes()
404 }
405
406 pub fn has_theme(&self, theme: &str) -> bool {
408 self.theme_manager.has_theme(theme)
409 }
410
411 pub async fn get_token_info(&self) -> Option<crate::auth::TokenInfo> {
413 self.token_manager.get_token_info().await
414 }
415
416 pub async fn refresh_token(&self) -> Result<String> {
418 self.token_manager.force_refresh().await
419 }
420
421 pub fn http_client(&self) -> &WeChatHttpClient {
423 &self.http_client
424 }
425
426 async fn validate_upload_input(
429 &self,
430 markdown_path: &Path,
431 options: &UploadOptions,
432 ) -> Result<()> {
433 if !utils::file_exists(markdown_path).await {
435 return Err(WeChatError::FileNotFound {
436 path: markdown_path.display().to_string(),
437 });
438 }
439
440 if !utils::is_markdown_file(markdown_path) {
442 return Err(WeChatError::config_error(
443 "File is not a markdown file (.md or .markdown)",
444 ));
445 }
446
447 let content = self.parse_markdown_file(markdown_path).await?;
451
452 let has_cover_option = options.cover_image.is_some();
454 let has_cover_frontmatter = content.cover.is_some();
455
456 if !has_cover_option && !has_cover_frontmatter {
457 return Err(WeChatError::config_error(
458 "Cover image is required. Please provide via --cover-image option or 'cover:' in frontmatter",
459 ));
460 }
461
462 if let Some(cover_path) = &options.cover_image {
464 let base_dir =
465 utils::get_base_directory(markdown_path).unwrap_or_else(|| Path::new("."));
466
467 let resolved_cover_path = if Path::new(cover_path).is_absolute() {
468 PathBuf::from(cover_path)
469 } else {
470 base_dir.join(cover_path)
471 };
472
473 if !utils::file_exists(&resolved_cover_path).await {
474 return Err(WeChatError::FileNotFound {
475 path: resolved_cover_path.display().to_string(),
476 });
477 }
478
479 if !utils::is_image_file(&resolved_cover_path) {
480 return Err(WeChatError::config_error(
481 "Cover file is not a supported image format",
482 ));
483 }
484 }
485
486 if let Some(cover_path) = &content.cover {
488 let base_dir =
489 utils::get_base_directory(markdown_path).unwrap_or_else(|| Path::new("."));
490
491 let resolved_cover_path = if Path::new(cover_path).is_absolute() {
492 PathBuf::from(cover_path)
493 } else {
494 base_dir.join(cover_path)
495 };
496
497 if !utils::file_exists(&resolved_cover_path).await {
498 return Err(WeChatError::FileNotFound {
499 path: resolved_cover_path.display().to_string(),
500 });
501 }
502
503 if !utils::is_image_file(&resolved_cover_path) {
504 return Err(WeChatError::config_error(
505 "Cover file specified in frontmatter is not a supported image format",
506 ));
507 }
508 }
509
510 Ok(())
511 }
512
513 async fn parse_markdown_file(&self, path: &Path) -> Result<MarkdownContent> {
514 self.markdown_parser.parse_file(path).await
515 }
516
517 async fn upload_cover_image(&self, cover_path: &str, base_dir: &Path) -> Result<String> {
518 let cover_path = if Path::new(cover_path).is_absolute() {
519 PathBuf::from(cover_path)
520 } else {
521 base_dir.join(cover_path)
522 };
523
524 self.image_uploader.upload_cover_material(&cover_path).await
526 }
527
528 fn render_content(
529 &self,
530 content: &MarkdownContent,
531 theme: &str,
532 options: &UploadOptions,
533 ) -> Result<String> {
534 let mut metadata = content.metadata.clone();
535
536 if let Some(title) = content.title.as_ref() {
538 metadata.insert("title".to_string(), title.clone());
539 }
540 if let Some(author) = content.author.as_ref() {
541 metadata.insert("author".to_string(), author.clone());
542 }
543
544 if let Some(title) = &options.title {
546 metadata.insert("title".to_string(), title.clone());
547 }
548 if let Some(author) = &options.author {
549 metadata.insert("author".to_string(), author.clone());
550 }
551
552 self.theme_manager.render(
553 &content.content,
554 theme,
555 content.code.as_deref().unwrap_or("vscode"),
556 &metadata,
557 )
558 }
559
560 fn create_article(
561 &self,
562 content: &MarkdownContent,
563 options: &UploadOptions,
564 html_content: String,
565 cover_media_id: Option<String>,
566 ) -> Article {
567 let title = options
569 .title
570 .clone()
571 .or_else(|| content.title.clone())
572 .unwrap_or_else(|| "Untitled".to_string());
573
574 let author = options
575 .author
576 .clone()
577 .or_else(|| content.author.clone())
578 .unwrap_or_else(|| "Anonymous".to_string());
579
580 let digest = content
582 .description
583 .clone()
584 .unwrap_or_else(|| content.get_summary(120));
585
586 let mut article = Article::new(title, author, html_content)
588 .with_digest(digest)
589 .with_show_cover(options.show_cover)
590 .with_comments(options.enable_comments, options.fans_only_comments);
591
592 if let Some(media_id) = cover_media_id {
593 article = article.with_cover_image(media_id);
594 }
595
596 if let Some(source_url) = &options.source_url {
597 article = article.with_source_url(source_url.clone());
598 }
599
600 article
601 }
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607
608 #[test]
609 fn test_upload_options_builder() {
610 let options = UploadOptions::with_theme("github")
611 .title("Test Title")
612 .author("Test Author")
613 .cover_image("cover.jpg")
614 .show_cover(false)
615 .comments(true, true)
616 .source_url("https://example.com");
617
618 assert_eq!(options.theme, "github");
619 assert_eq!(options.title, Some("Test Title".to_string()));
620 assert_eq!(options.author, Some("Test Author".to_string()));
621 assert_eq!(options.cover_image, Some("cover.jpg".to_string()));
622 assert!(!options.show_cover);
623 assert!(options.enable_comments);
624 assert!(options.fans_only_comments);
625 assert_eq!(options.source_url, Some("https://example.com".to_string()));
626 }
627
628 #[test]
629 fn test_upload_options_default() {
630 let options = UploadOptions::default();
631
632 assert_eq!(options.theme, "default");
633 assert_eq!(options.title, None);
634 assert_eq!(options.author, None);
635 assert_eq!(options.cover_image, None);
636 assert!(options.show_cover);
637 assert!(!options.enable_comments);
638 assert!(!options.fans_only_comments);
639 assert_eq!(options.source_url, None);
640 }
641
642 #[tokio::test]
643 async fn test_client_creation_with_invalid_credentials() {
644 let result = WeChatClient::new("invalid", "12345678901234567890123456789012").await;
645 assert!(result.is_err());
646
647 let result = WeChatClient::new("wx1234567890123456", "short").await;
648 assert!(result.is_err());
649
650 let result = WeChatClient::new("", "").await;
651 assert!(result.is_err());
652 }
653
654 #[tokio::test]
655 async fn test_client_creation_with_valid_credentials() {
656 let result =
657 WeChatClient::new("wx1234567890123456", "12345678901234567890123456789012").await;
658 assert!(result.is_ok());
659
660 let client = result.unwrap();
661 assert!(client.available_themes().len() >= 4);
662 assert!(client.has_theme("default"));
663 assert!(client.has_theme("lapis"));
664 assert!(client.has_theme("maize"));
665 assert!(client.has_theme("orangeheart"));
666 }
667
668 #[tokio::test]
669 async fn test_cover_requirement_validation() {
670 use tempfile::Builder;
671
672 let client = WeChatClient::new("wx1234567890123456", "12345678901234567890123456789012")
673 .await
674 .unwrap();
675
676 let temp_file = Builder::new().suffix(".md").tempfile().unwrap();
678 let markdown_without_cover = r#"---
679title: Test Article
680author: Test Author
681---
682
683# Content
684Some article content here.
685"#;
686 tokio::fs::write(temp_file.path(), markdown_without_cover)
687 .await
688 .unwrap();
689
690 let options = UploadOptions::with_theme("default");
691 let result = client
692 .validate_upload_input(temp_file.path(), &options)
693 .await;
694 assert!(result.is_err());
695 let error_msg = result.unwrap_err().to_string();
696 assert!(error_msg.contains("Cover image is required"));
697
698 let temp_file2 = Builder::new().suffix(".md").tempfile().unwrap();
700 let markdown_with_cover = r#"---
701title: Test Article
702author: Test Author
703cover: ../fixtures/images/02-cover.png
704---
705
706# Content
707Some article content here.
708"#;
709 tokio::fs::write(temp_file2.path(), markdown_with_cover)
710 .await
711 .unwrap();
712
713 let options2 = UploadOptions::with_theme("default");
714 let result2 = client
715 .validate_upload_input(temp_file2.path(), &options2)
716 .await;
717 assert!(result2.is_err());
719 assert!(result2.unwrap_err().to_string().contains("02-cover.png"));
720
721 let temp_file3 = Builder::new().suffix(".md").tempfile().unwrap();
723 let markdown_no_frontmatter_cover = r#"---
724title: Test Article
725author: Test Author
726---
727
728# Content
729Some article content here.
730"#;
731 tokio::fs::write(temp_file3.path(), markdown_no_frontmatter_cover)
732 .await
733 .unwrap();
734
735 let options3 =
736 UploadOptions::with_theme("default").cover_image("../fixtures/images/02-cover.png");
737 let result3 = client
738 .validate_upload_input(temp_file3.path(), &options3)
739 .await;
740 assert!(result3.is_err());
742 assert!(result3.unwrap_err().to_string().contains("02-cover.png"));
743 }
744
745 #[tokio::test]
746 async fn test_fixture_file_parsing() {
747 let client = WeChatClient::new("wx1234567890123456", "12345678901234567890123456789012")
748 .await
749 .unwrap();
750
751 let content = client
753 .parse_markdown_file(std::path::Path::new("fixtures/example.md"))
754 .await
755 .unwrap();
756
757 assert_eq!(content.author, Some("陈小天".to_string()));
758 assert_eq!(
759 content.description,
760 Some("为了这壶醋,我包了这顿饺子(写了几千行 Rust,做了个工具)".to_string())
761 );
762 assert_eq!(content.cover, Some("images/02-cover.png".to_string()));
763
764 let options = UploadOptions::with_theme("default");
766 let result = client
767 .validate_upload_input(std::path::Path::new("fixtures/example.md"), &options)
768 .await;
769 assert!(
770 result.is_ok(),
771 "Validation should pass for fixture file with cover in frontmatter"
772 );
773 }
774}