Skip to main content

wx_uploader/
wechat.rs

1//! WeChat public account integration
2//!
3//! This module provides WeChat public account functionality for uploading
4//! markdown articles with automatic cover image generation and frontmatter management.
5
6use crate::error::{Error, Result};
7use crate::gemini::GeminiClient;
8use crate::markdown::{parse_markdown_file, update_frontmatter, write_markdown_file};
9use crate::models::{Config, Frontmatter};
10use crate::openai::OpenAIClient;
11use crate::output::{FORMATTER, FilePathFormatter, OutputFormatter};
12use image::GenericImageView;
13use std::path::{Path, PathBuf};
14use tracing::{info, warn};
15use walkdir::WalkDir;
16
17/// WeChat cover image aspect ratio (2.35:1)
18const WECHAT_COVER_ASPECT_RATIO: f64 = 2.35;
19
20// Re-export the WeChat client type
21pub use wechat_pub_rs::WeChatClient;
22
23use std::io::Write;
24use tempfile::NamedTempFile;
25
26/// Holds temporary files for upload, automatically cleaned up when dropped.
27pub struct TempUploadFiles {
28    /// Temp markdown file (in same dir as original for relative path resolution)
29    pub markdown: NamedTempFile,
30    /// Temp cropped cover image (in system temp dir)
31    pub cover: NamedTempFile,
32}
33
34/// Crops an image to WeChat cover aspect ratio (2.35:1) and saves to a temp file.
35///
36/// Returns a NamedTempFile handle, or None if no cropping was needed.
37fn crop_cover_to_temp(image_path: &Path) -> Result<Option<NamedTempFile>> {
38    use image::ImageReader;
39    use std::io::Cursor;
40
41    // Read the image file
42    let image_bytes = std::fs::read(image_path)
43        .map_err(|e| Error::generic(format!("Failed to read image file: {}", e)))?;
44
45    let img = ImageReader::new(Cursor::new(&image_bytes))
46        .with_guessed_format()
47        .map_err(|e| Error::generic(format!("Failed to read image format: {}", e)))?
48        .decode()
49        .map_err(|e| Error::generic(format!("Failed to decode image: {}", e)))?;
50
51    let (width, height) = img.dimensions();
52    let current_ratio = width as f64 / height as f64;
53
54    // If already at or wider than 2.35:1, no cropping needed
55    if current_ratio >= WECHAT_COVER_ASPECT_RATIO {
56        info!(
57            "Image already at or wider than 2.35:1 ratio (current: {:.2}:1), skipping crop",
58            current_ratio
59        );
60        return Ok(None);
61    }
62
63    // Calculate new height for 2.35:1 ratio, keeping width
64    let new_height = (width as f64 / WECHAT_COVER_ASPECT_RATIO).round() as u32;
65
66    // Center crop vertically
67    let y_offset = (height - new_height) / 2;
68
69    info!(
70        "Cropping cover image from {}x{} to {}x{} (2.35:1 ratio for WeChat)",
71        width, height, width, new_height
72    );
73
74    let cropped = img.crop_imm(0, y_offset, width, new_height);
75
76    // Determine output format and extension based on original file
77    let extension = image_path
78        .extension()
79        .and_then(|e| e.to_str())
80        .unwrap_or("jpg");
81    let format = match extension {
82        "png" => image::ImageFormat::Png,
83        "jpg" | "jpeg" => image::ImageFormat::Jpeg,
84        "webp" => image::ImageFormat::WebP,
85        _ => image::ImageFormat::Jpeg,
86    };
87
88    // Create temp file in system temp dir
89    let mut temp_file = tempfile::Builder::new()
90        .prefix("wx_cover_")
91        .suffix(&format!(".{}", extension))
92        .tempfile()
93        .map_err(|e| Error::generic(format!("Failed to create temp cover file: {}", e)))?;
94
95    // Encode and write to temp file
96    let mut output = Cursor::new(Vec::new());
97    cropped
98        .write_to(&mut output, format)
99        .map_err(|e| Error::generic(format!("Failed to encode cropped image: {}", e)))?;
100
101    temp_file
102        .write_all(&output.into_inner())
103        .map_err(|e| Error::generic(format!("Failed to write temp cropped image: {}", e)))?;
104    temp_file
105        .flush()
106        .map_err(|e| Error::generic(format!("Failed to flush temp file: {}", e)))?;
107
108    info!(
109        "Created temp cropped cover at: {}",
110        temp_file.path().display()
111    );
112    Ok(Some(temp_file))
113}
114
115/// Converts relative image paths in markdown body to absolute paths.
116fn make_image_paths_absolute(body: &str, base_dir: &Path) -> String {
117    use regex::Regex;
118
119    // Match markdown image syntax: ![alt](path)
120    let re = Regex::new(r"!\[([^\]]*)\]\(([^)]+)\)").unwrap();
121
122    re.replace_all(body, |caps: &regex::Captures| {
123        let alt = &caps[1];
124        let path = &caps[2];
125
126        // Skip URLs and already absolute paths
127        if path.starts_with("http://") || path.starts_with("https://") || path.starts_with('/') {
128            return caps[0].to_string();
129        }
130
131        // Convert relative path to absolute
132        let abs_path = base_dir.join(path);
133        format!("![{}]({})", alt, abs_path.display())
134    })
135    .to_string()
136}
137
138/// Prepares temp files for WeChat upload with cropped cover.
139///
140/// Both files are created in the system temp directory.
141/// Image paths in the markdown are converted to absolute paths.
142fn prepare_upload_files(
143    markdown_path: &Path,
144    frontmatter: &Frontmatter,
145    body: &str,
146) -> Result<Option<TempUploadFiles>> {
147    // Check if there's a cover to process
148    let Some(cover_filename) = &frontmatter.cover else {
149        return Ok(None);
150    };
151
152    // Resolve cover path
153    let (cover_path, exists) = resolve_and_check_cover_path(markdown_path, cover_filename);
154    if !exists {
155        return Ok(None);
156    }
157
158    // Crop cover to temp file (in system temp dir)
159    let Some(temp_cover) = crop_cover_to_temp(&cover_path)? else {
160        // No cropping needed, use original files
161        return Ok(None);
162    };
163
164    // Get absolute markdown directory for resolving relative image paths
165    let abs_markdown_path = markdown_path
166        .canonicalize()
167        .map_err(|e| Error::generic(format!("Failed to canonicalize markdown path: {}", e)))?;
168    let markdown_dir = abs_markdown_path
169        .parent()
170        .ok_or_else(|| Error::generic("Markdown file has no parent directory"))?;
171
172    // Create temp markdown in system temp dir with absolute image paths
173    let mut temp_frontmatter = frontmatter.clone();
174    temp_frontmatter.set_cover(temp_cover.path().to_string_lossy().to_string());
175
176    // Convert relative image paths to absolute
177    let body_with_abs_paths = make_image_paths_absolute(body, markdown_dir);
178
179    let mut temp_markdown = tempfile::Builder::new()
180        .prefix("wx_upload_")
181        .suffix(".md")
182        .tempfile()
183        .map_err(|e| Error::generic(format!("Failed to create temp markdown file: {}", e)))?;
184
185    let temp_content = crate::markdown::format_markdown(&temp_frontmatter, &body_with_abs_paths)?;
186    temp_markdown
187        .write_all(temp_content.as_bytes())
188        .map_err(|e| Error::generic(format!("Failed to write temp markdown: {}", e)))?;
189    temp_markdown
190        .flush()
191        .map_err(|e| Error::generic(format!("Failed to flush temp markdown: {}", e)))?;
192
193    info!(
194        "Created temp files - markdown: {}, cover: {}",
195        temp_markdown.path().display(),
196        temp_cover.path().display()
197    );
198
199    Ok(Some(TempUploadFiles {
200        markdown: temp_markdown,
201        cover: temp_cover,
202    }))
203}
204
205/// Trait for uploading content to WeChat
206#[async_trait::async_trait]
207pub trait WeChatUploader {
208    /// Uploads a file to WeChat and returns the draft ID
209    async fn upload(&self, file_path: &str) -> Result<String>;
210}
211
212/// Default implementation of WeChat uploader
213#[async_trait::async_trait]
214impl WeChatUploader for WeChatClient {
215    async fn upload(&self, file_path: &str) -> Result<String> {
216        self.upload(file_path)
217            .await
218            .map_err(|e| Error::wechat(e.to_string()))
219    }
220}
221
222/// Image generation backend, resolved per-file from frontmatter model field
223#[derive(Debug)]
224enum ImageBackend {
225    Gemini(GeminiClient),
226    OpenAI(OpenAIClient),
227}
228
229impl ImageBackend {
230    /// Generate a cover image with auto-generated filename
231    async fn generate_cover_image(
232        &self,
233        content: &str,
234        file_path: &Path,
235        base_filename: &str,
236    ) -> Result<String> {
237        match self {
238            Self::Gemini(client) => {
239                client
240                    .generate_cover_image(content, file_path, base_filename)
241                    .await
242            }
243            Self::OpenAI(client) => {
244                client
245                    .generate_cover_image(content, file_path, base_filename)
246                    .await
247            }
248        }
249    }
250
251    /// Generate a cover image to a specific path
252    async fn generate_cover_image_to_path(
253        &self,
254        content: &str,
255        markdown_file_path: &Path,
256        target_cover_path: &Path,
257    ) -> Result<()> {
258        match self {
259            Self::Gemini(client) => {
260                client
261                    .generate_cover_image_to_path(content, markdown_file_path, target_cover_path)
262                    .await
263            }
264            Self::OpenAI(client) => {
265                client
266                    .generate_cover_image_to_path(content, markdown_file_path, target_cover_path)
267                    .await
268            }
269        }
270    }
271}
272
273/// Resolve the image generation backend from frontmatter model and config
274fn resolve_backend(frontmatter: &Frontmatter, config: &Config) -> Result<ImageBackend> {
275    let model = frontmatter.effective_model();
276
277    match model {
278        "nb2" | "nb" => {
279            let Some(api_key) = config.gemini_api_key.as_ref() else {
280                return Err(Error::config(format!(
281                    "GEMINI_API_KEY required for model '{}'. Set GEMINI_API_KEY or use model: gpt in frontmatter.",
282                    model
283                )));
284            };
285            let model_id: &'static str = match model {
286                "nb" => "gemini-3-pro-image-preview",
287                _ => "gemini-3.1-flash-image-preview",
288            };
289            Ok(ImageBackend::Gemini(GeminiClient::new(
290                api_key.clone(),
291                model_id,
292            )))
293        }
294        "gpt" => {
295            let Some(api_key) = config.openai_api_key.as_ref() else {
296                return Err(Error::config(
297                    "OPENAI_API_KEY required for model 'gpt'. Set OPENAI_API_KEY or use model: nb2 in frontmatter.",
298                ));
299            };
300            Ok(ImageBackend::OpenAI(OpenAIClient::new(api_key.clone())))
301        }
302        _ => {
303            // Should not happen if frontmatter validation is correct
304            Err(Error::config(format!("Unknown model '{}'", model)))
305        }
306    }
307}
308
309/// Recursively processes all markdown files in a directory.
310pub async fn process_directory(
311    client: &WeChatClient,
312    config: &Config,
313    dir: &Path,
314    verbose: bool,
315) -> Result<()> {
316    let entries: Vec<_> = WalkDir::new(dir)
317        .into_iter()
318        .filter_map(|e| e.ok())
319        .filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("md"))
320        .collect();
321
322    if entries.is_empty() {
323        FORMATTER.print_info("No markdown files found in directory");
324        return Ok(());
325    }
326
327    for entry in entries {
328        upload_file(client, config, entry.path(), false, verbose).await?;
329    }
330
331    Ok(())
332}
333
334/// Uploads a single markdown file to WeChat public account.
335pub async fn upload_file(
336    client: &WeChatClient,
337    config: &Config,
338    path: &Path,
339    force: bool,
340    verbose: bool,
341) -> Result<()> {
342    // Parse the markdown file and check publication status
343    let (mut frontmatter, body) = match parse_and_check_file(path, force, verbose).await {
344        Ok(result) => result,
345        Err(_) => return Ok(()), // File was skipped
346    };
347
348    // Handle cover image processing if needed
349    let cover_updated = process_cover_image(&mut frontmatter, path, config, verbose).await?;
350
351    // Save frontmatter if cover was updated
352    if cover_updated {
353        write_markdown_file(path, &frontmatter, &body).await?;
354        if verbose {
355            info!("Updated frontmatter with cover in: {}", path.display());
356        }
357    }
358
359    // Prepare temp files with cropped cover for upload
360    // The TempUploadFiles struct keeps files alive until dropped
361    let temp_files = prepare_upload_files(path, &frontmatter, &body)?;
362
363    // Use temp markdown if available, otherwise use original
364    let upload_path = temp_files
365        .as_ref()
366        .map(|tf| tf.markdown.path())
367        .unwrap_or(path);
368
369    // Execute the WeChat upload (display original path, upload from temp)
370    let upload_result = execute_wechat_upload(client, upload_path, path, verbose).await;
371
372    // Temp files are automatically cleaned up when temp_files is dropped
373    drop(temp_files);
374
375    // Propagate upload error after cleanup
376    upload_result?;
377
378    // Update the file with published status
379    update_published_status(path, verbose).await?;
380
381    Ok(())
382}
383
384/// Parses markdown file and checks if it should be uploaded
385async fn parse_and_check_file(
386    path: &Path,
387    force: bool,
388    verbose: bool,
389) -> Result<(Frontmatter, String)> {
390    let (frontmatter, body) = parse_markdown_file(path).await?;
391
392    // Check if already published
393    if !force && frontmatter.is_published() {
394        if verbose {
395            info!("Skipping already published file: {}", path.display());
396        } else {
397            FORMATTER.print_skip(&FORMATTER.format_skip_published(path));
398        }
399        return Err(Error::generic("File already published"));
400    }
401
402    Ok((frontmatter, body))
403}
404
405/// Processes cover image generation and updating
406async fn process_cover_image(
407    frontmatter: &mut Frontmatter,
408    path: &Path,
409    config: &Config,
410    verbose: bool,
411) -> Result<bool> {
412    // Check if we need to generate at all
413    if !should_generate_cover(frontmatter, path, verbose).await {
414        return Ok(false);
415    }
416
417    // Resolve the backend based on frontmatter model
418    let backend = match resolve_backend(frontmatter, config) {
419        Ok(backend) => backend,
420        Err(e) => {
421            // Missing API key — warn and continue without cover
422            FORMATTER.print_warning(&e.to_string());
423            return Ok(false);
424        }
425    };
426
427    let model_name = frontmatter.effective_model();
428    if verbose {
429        info!("Using model '{}' for cover generation", model_name);
430    }
431
432    // Build content for image generation: title + description gives the best context
433    let image_content = match &frontmatter.title {
434        Some(title) => format!(
435            "Title: {}\n\nDescription: {}",
436            title, frontmatter.description
437        ),
438        None => frontmatter.description.clone(),
439    };
440
441    // Generate the cover image
442    match &frontmatter.cover {
443        None => {
444            // Generate with auto filename
445            let base_filename = path
446                .file_stem()
447                .and_then(|s| s.to_str())
448                .unwrap_or("article");
449
450            match backend
451                .generate_cover_image(&image_content, path, base_filename)
452                .await
453            {
454                Ok(cover_filename) => {
455                    frontmatter.set_cover(cover_filename.clone());
456                    if verbose {
457                        info!("Successfully generated cover image: {}", cover_filename);
458                    } else {
459                        FORMATTER
460                            .print_generation(&FORMATTER.format_cover_success(&cover_filename));
461                    }
462                    Ok(true)
463                }
464                Err(e) => {
465                    warn!(
466                        "Failed to generate cover image: {}. Continuing without cover.",
467                        e
468                    );
469                    if !verbose {
470                        FORMATTER.print_warning(&FORMATTER.format_cover_failure());
471                    }
472                    Ok(false)
473                }
474            }
475        }
476        Some(cover_filename) => {
477            let (target_cover_path, exists) = resolve_and_check_cover_path(path, cover_filename);
478
479            if exists {
480                return Ok(false);
481            }
482
483            match backend
484                .generate_cover_image_to_path(&image_content, path, &target_cover_path)
485                .await
486            {
487                Ok(()) => {
488                    if verbose {
489                        info!("Successfully generated cover image: {}", cover_filename);
490                    } else {
491                        FORMATTER.print_generation(&FORMATTER.format_cover_success(cover_filename));
492                    }
493                    Ok(true)
494                }
495                Err(e) => {
496                    warn!(
497                        "Failed to generate cover image to {}: {}. Continuing without cover.",
498                        target_cover_path.display(),
499                        e
500                    );
501                    if !verbose {
502                        FORMATTER.print_warning(&FORMATTER.format_cover_failure());
503                    }
504                    Ok(false)
505                }
506            }
507        }
508    }
509}
510
511/// Determines if a cover image should be generated
512async fn should_generate_cover(frontmatter: &Frontmatter, path: &Path, verbose: bool) -> bool {
513    match &frontmatter.cover {
514        None => {
515            if verbose {
516                info!("No cover image specified, generating one using AI...");
517            } else {
518                FORMATTER.print_generation(&FORMATTER.format_cover_generation(path));
519            }
520            true
521        }
522        Some(cover_filename) => {
523            let (cover_path, exists) = resolve_and_check_cover_path(path, cover_filename);
524            if !exists {
525                if verbose {
526                    info!(
527                        "Cover image specified ({}) but file not found at {}, generating using AI...",
528                        cover_filename,
529                        cover_path.display()
530                    );
531                } else {
532                    FORMATTER.print_generation(&format!(
533                        "cover missing ({}), generating: {}",
534                        cover_filename,
535                        path.display()
536                    ));
537                }
538                true
539            } else {
540                if verbose {
541                    info!("Cover image found at: {}", cover_path.display());
542                }
543                false
544            }
545        }
546    }
547}
548
549/// Executes the WeChat upload operation
550///
551/// `upload_path` is the actual file to upload (may be a temp file).
552/// `display_path` is the original file path shown to the user.
553async fn execute_wechat_upload(
554    client: &WeChatClient,
555    upload_path: &Path,
556    display_path: &Path,
557    verbose: bool,
558) -> Result<String> {
559    if verbose {
560        info!("Uploading file: {}", display_path.display());
561    } else {
562        FORMATTER.print_progress(&FORMATTER.format_file_operation("uploading", display_path));
563    }
564
565    let path_str = upload_path
566        .to_str()
567        .ok_or_else(|| Error::generic("Path contains invalid UTF-8"))?;
568
569    match client.upload(path_str).await {
570        Ok(draft_id) => {
571            if verbose {
572                info!("Successfully uploaded with draft ID: {}", draft_id);
573            } else {
574                FORMATTER.print_success(&FORMATTER.format_upload_success(display_path));
575            }
576            Ok(draft_id)
577        }
578        Err(e) => {
579            let error_msg = format!("WeChat upload failed: {}", e);
580            if verbose {
581                warn!("Failed to upload {}: {}", display_path.display(), error_msg);
582            } else {
583                FORMATTER.print_error(&FORMATTER.format_upload_failure(display_path));
584                eprintln!("Error: {}", error_msg);
585            }
586            Err(Error::wechat(error_msg))
587        }
588    }
589}
590
591/// Updates the frontmatter with published status after successful upload
592async fn update_published_status(path: &Path, verbose: bool) -> Result<()> {
593    update_frontmatter(path, |fm| {
594        fm.set_published("draft");
595        Ok(())
596    })
597    .await?;
598
599    if verbose {
600        info!(
601            "Updated frontmatter with draft status in: {}",
602            path.display()
603        );
604    }
605
606    Ok(())
607}
608
609/// Resolves a cover image path relative to the markdown file and checks if it exists
610pub fn resolve_and_check_cover_path(
611    markdown_file_path: &Path,
612    cover_filename: &str,
613) -> (PathBuf, bool) {
614    let cover_path = if Path::new(cover_filename).is_absolute() {
615        PathBuf::from(cover_filename)
616    } else {
617        // If cover filename is relative, resolve it relative to the markdown file's directory
618        markdown_file_path
619            .parent()
620            .unwrap_or_else(|| Path::new("."))
621            .join(cover_filename)
622    };
623
624    let exists = cover_path.exists();
625    (cover_path, exists)
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631    use std::fs;
632    use tempfile::TempDir;
633
634    #[test]
635    fn test_resolve_and_check_cover_path() {
636        let temp_dir = TempDir::new().unwrap();
637        let temp_path = temp_dir.path();
638
639        // Create a markdown file
640        let md_file = temp_path.join("test.md");
641        fs::write(&md_file, "# Test").unwrap();
642
643        // Create an existing cover image
644        let existing_cover = temp_path.join("existing.png");
645        fs::write(&existing_cover, "fake image data").unwrap();
646
647        // Test with existing file
648        let (resolved_path, exists) = resolve_and_check_cover_path(&md_file, "existing.png");
649        assert_eq!(resolved_path, existing_cover);
650        assert!(exists);
651
652        // Test with missing file
653        let (resolved_path, exists) = resolve_and_check_cover_path(&md_file, "missing.png");
654        assert_eq!(resolved_path, temp_path.join("missing.png"));
655        assert!(!exists);
656
657        // Test with absolute path
658        let abs_path = temp_path.join("absolute.png").to_string_lossy().to_string();
659        let (resolved_path, exists) = resolve_and_check_cover_path(&md_file, &abs_path);
660        assert_eq!(resolved_path, temp_path.join("absolute.png"));
661        assert!(!exists);
662
663        // Test with subdirectory path
664        let images_dir = temp_path.join("images");
665        fs::create_dir(&images_dir).unwrap();
666        let subdir_cover = images_dir.join("cover.png");
667        fs::write(&subdir_cover, "fake image data").unwrap();
668
669        let (resolved_path, exists) = resolve_and_check_cover_path(&md_file, "images/cover.png");
670        assert_eq!(resolved_path, subdir_cover);
671        assert!(exists);
672    }
673
674    #[test]
675    fn test_resolve_backend_gemini_nb2() {
676        let frontmatter = Frontmatter::new(); // defaults to nb2
677        let config = Config::new(
678            "app".into(),
679            "secret".into(),
680            None,
681            Some("gemini-key".into()),
682            false,
683        );
684
685        let backend = resolve_backend(&frontmatter, &config);
686        assert!(backend.is_ok());
687    }
688
689    #[test]
690    fn test_resolve_backend_gemini_missing_key() {
691        let frontmatter = Frontmatter::new(); // defaults to nb2
692        let config = Config::new("app".into(), "secret".into(), None, None, false);
693
694        let result = resolve_backend(&frontmatter, &config);
695        assert!(result.is_err());
696        assert!(result.unwrap_err().to_string().contains("GEMINI_API_KEY"));
697    }
698
699    #[test]
700    fn test_resolve_backend_gpt() {
701        let mut frontmatter = Frontmatter::new();
702        frontmatter.model = Some("gpt".into());
703        let config = Config::new(
704            "app".into(),
705            "secret".into(),
706            Some("openai-key".into()),
707            None,
708            false,
709        );
710
711        let backend = resolve_backend(&frontmatter, &config);
712        assert!(backend.is_ok());
713    }
714
715    #[test]
716    fn test_resolve_backend_gpt_missing_key() {
717        let mut frontmatter = Frontmatter::new();
718        frontmatter.model = Some("gpt".into());
719        let config = Config::new("app".into(), "secret".into(), None, None, false);
720
721        let result = resolve_backend(&frontmatter, &config);
722        assert!(result.is_err());
723        assert!(result.unwrap_err().to_string().contains("OPENAI_API_KEY"));
724    }
725
726    #[tokio::test]
727    async fn test_process_directory_empty() {
728        let temp_dir = TempDir::new().unwrap();
729
730        let client =
731            wechat_pub_rs::WeChatClient::new("test_id".to_string(), "test_secret".to_string())
732                .await;
733
734        match client {
735            Ok(client) => {
736                let config = Config::new("test_id".into(), "test_secret".into(), None, None, false);
737                let result = process_directory(&client, &config, temp_dir.path(), false).await;
738                assert!(result.is_ok());
739            }
740            Err(_) => {
741                // Expected to fail without real credentials
742            }
743        }
744    }
745}