Skip to main content

wechat_pub_rs/
client.rs

1//! Main WeChat client implementation.
2
3use 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/// Upload options for customizing the upload behavior.
17#[derive(Debug, Clone)]
18pub struct UploadOptions {
19    /// Theme name to use for rendering
20    pub theme: String,
21    /// Custom title (overrides extracted title)
22    pub title: Option<String>,
23    /// Custom author (overrides extracted author)
24    pub author: Option<String>,
25    /// Path to cover image file
26    pub cover_image: Option<String>,
27    /// Whether to show cover image in content
28    pub show_cover: bool,
29    /// Whether to enable comments
30    pub enable_comments: bool,
31    /// Whether only fans can comment
32    pub fans_only_comments: bool,
33    /// Source URL for the article
34    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    /// Creates upload options with a specific theme.
54    pub fn with_theme(theme: impl Into<String>) -> Self {
55        Self {
56            theme: theme.into(),
57            ..Default::default()
58        }
59    }
60
61    /// Sets the title.
62    pub fn title(mut self, title: impl Into<String>) -> Self {
63        self.title = Some(title.into());
64        self
65    }
66
67    /// Sets the author.
68    pub fn author(mut self, author: impl Into<String>) -> Self {
69        self.author = Some(author.into());
70        self
71    }
72
73    /// Sets the cover image path.
74    pub fn cover_image(mut self, path: impl Into<String>) -> Self {
75        self.cover_image = Some(path.into());
76        self
77    }
78
79    /// Sets whether to show the cover image in content.
80    pub fn show_cover(mut self, show: bool) -> Self {
81        self.show_cover = show;
82        self
83    }
84
85    /// Sets comment options.
86    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    /// Sets the source URL.
93    pub fn source_url(mut self, url: impl Into<String>) -> Self {
94        self.source_url = Some(url.into());
95        self
96    }
97}
98
99/// Main WeChat Official Account client.
100#[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    /// Creates a new WeChat client with app credentials.
112    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        // Validate credentials format
117        utils::validate_app_credentials(&app_id, &app_secret).map_err(WeChatError::config_error)?;
118
119        // Create HTTP client
120        let http_client = Arc::new(WeChatHttpClient::new()?);
121
122        // Create token manager
123        let token_manager = Arc::new(TokenManager::new(
124            app_id,
125            app_secret,
126            Arc::clone(&http_client),
127        ));
128
129        // Create service components
130        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    /// Uploads a markdown file as a WeChat draft article.
149    ///
150    /// This is the main convenience method that handles the entire workflow:
151    /// 1. Parse markdown file
152    /// 2. Extract and upload images
153    /// 3. Replace image URLs in content
154    /// 4. Render content with theme (from frontmatter, options, or default)
155    /// 5. Create draft article
156    ///
157    /// # Arguments
158    /// * `markdown_path` - Path to the markdown file
159    ///
160    /// # Returns
161    /// Returns the media ID of the created draft
162    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    /// Uploads a markdown file with custom options.
168    ///
169    /// # Arguments
170    /// * `markdown_path` - Path to the markdown file
171    /// * `options` - Upload options for customization
172    ///
173    /// # Returns
174    /// Returns the media ID of the created draft
175    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        // Validate input
183        self.validate_upload_input(markdown_path, &options).await?;
184
185        info!("Starting upload process for: {}", markdown_path.display());
186
187        // Step 1: Parse markdown content
188        let mut content = self.parse_markdown_file(markdown_path).await?;
189        debug!("Found {} images in content", content.images.len());
190
191        // Step 1.5: Process Mermaid charts
192        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        // Update content with Mermaid-processed version
205        content.content = modified_content;
206
207        // Add Mermaid-generated images to the image list
208        content.images.extend(mermaid_images.clone());
209
210        debug!(
211            "Total images to upload (including Mermaid): {}",
212            content.images.len()
213        );
214
215        // Step 2: Upload images concurrently
216        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        // Step 3: Replace image URLs in content
223        let url_mapping = self.draft_manager.create_url_mapping(&upload_results);
224        content.replace_image_urls(&url_mapping)?;
225
226        // Step 4: Upload cover image (from options or frontmatter)
227        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        // Step 5: Render content with theme (from frontmatter, options, or default)
238        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        // Validate theme exists
246        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        // Step 6: Create article and draft
255        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    /// Gets a draft by media ID.
263    pub async fn get_draft(&self, media_id: &str) -> Result<DraftInfo> {
264        self.draft_manager.get_draft(media_id).await
265    }
266
267    /// Updates an existing draft with new content.
268    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    /// Updates an existing draft with custom options.
275    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        // Parse and process content (same as upload)
291        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        // Process Mermaid charts
295        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        // Update content with Mermaid-processed version
307        content.content = modified_content;
308
309        // Add Mermaid-generated images to the image list
310        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        // Validate theme exists
336        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    /// Deletes a draft by media ID.
354    pub async fn delete_draft(&self, media_id: &str) -> Result<()> {
355        self.draft_manager.delete_draft(media_id).await
356    }
357
358    /// Lists drafts with pagination.
359    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    /// Uploads a single image file and returns the WeChat URL.
364    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        // Create a dummy image reference for uploading
380        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    /// Creates a draft with custom articles.
397    pub async fn create_draft(&self, articles: Vec<Article>) -> Result<String> {
398        self.draft_manager.create_draft(articles).await
399    }
400
401    /// Gets the list of available themes.
402    pub fn available_themes(&self) -> Vec<&String> {
403        self.theme_manager.available_themes()
404    }
405
406    /// Checks if a theme exists.
407    pub fn has_theme(&self, theme: &str) -> bool {
408        self.theme_manager.has_theme(theme)
409    }
410
411    /// Gets access token information for debugging.
412    pub async fn get_token_info(&self) -> Option<crate::auth::TokenInfo> {
413        self.token_manager.get_token_info().await
414    }
415
416    /// Forces a token refresh.
417    pub async fn refresh_token(&self) -> Result<String> {
418        self.token_manager.force_refresh().await
419    }
420
421    /// Gets the underlying HTTP client for advanced usage.
422    pub fn http_client(&self) -> &WeChatHttpClient {
423        &self.http_client
424    }
425
426    // Private helper methods
427
428    async fn validate_upload_input(
429        &self,
430        markdown_path: &Path,
431        options: &UploadOptions,
432    ) -> Result<()> {
433        // Check if markdown file exists
434        if !utils::file_exists(markdown_path).await {
435            return Err(WeChatError::FileNotFound {
436                path: markdown_path.display().to_string(),
437            });
438        }
439
440        // Check if it's a markdown file
441        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        // Theme validation will happen later when we determine the actual theme to use
448
449        // Parse markdown to check for frontmatter cover
450        let content = self.parse_markdown_file(markdown_path).await?;
451
452        // Check that cover image is provided either via options or frontmatter
453        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        // Validate cover image from options if specified
463        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        // Validate cover image from frontmatter if specified
487        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        // Upload cover image as permanent material
525        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        // Use frontmatter values as defaults, override with options if provided
537        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        // Override with options if provided
545        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        // Determine title and author
568        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        // Use description from frontmatter if available, otherwise generate summary
581        let digest = content
582            .description
583            .clone()
584            .unwrap_or_else(|| content.get_summary(120));
585
586        // Create article
587        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        // Test 1: Markdown without cover in frontmatter or options should fail
677        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        // Test 2: Markdown with cover in frontmatter should work (if file exists)
699        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        // This will fail because the cover file doesn't exist, but it should fail with file not found, not cover required
718        assert!(result2.is_err());
719        assert!(result2.unwrap_err().to_string().contains("02-cover.png"));
720
721        // Test 3: Options with cover should work (if file exists)
722        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        // This will fail because the cover file doesn't exist, but should not be the "cover required" error
741        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        // Parse the fixture file to verify it has the expected frontmatter
752        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        // Verify that validation works with the fixture (should pass because cover exists in frontmatter and file exists)
765        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}