1use 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
17const WECHAT_COVER_ASPECT_RATIO: f64 = 2.35;
19
20pub use wechat_pub_rs::WeChatClient;
22
23use std::io::Write;
24use tempfile::NamedTempFile;
25
26pub struct TempUploadFiles {
28 pub markdown: NamedTempFile,
30 pub cover: NamedTempFile,
32}
33
34fn crop_cover_to_temp(image_path: &Path) -> Result<Option<NamedTempFile>> {
38 use image::ImageReader;
39 use std::io::Cursor;
40
41 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 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 let new_height = (width as f64 / WECHAT_COVER_ASPECT_RATIO).round() as u32;
65
66 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 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 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 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
115fn make_image_paths_absolute(body: &str, base_dir: &Path) -> String {
117 use regex::Regex;
118
119 let re = Regex::new(r"!\[([^\]]*)\]\(([^)]+)\)").unwrap();
121
122 re.replace_all(body, |caps: ®ex::Captures| {
123 let alt = &caps[1];
124 let path = &caps[2];
125
126 if path.starts_with("http://") || path.starts_with("https://") || path.starts_with('/') {
128 return caps[0].to_string();
129 }
130
131 let abs_path = base_dir.join(path);
133 format!("", alt, abs_path.display())
134 })
135 .to_string()
136}
137
138fn prepare_upload_files(
143 markdown_path: &Path,
144 frontmatter: &Frontmatter,
145 body: &str,
146) -> Result<Option<TempUploadFiles>> {
147 let Some(cover_filename) = &frontmatter.cover else {
149 return Ok(None);
150 };
151
152 let (cover_path, exists) = resolve_and_check_cover_path(markdown_path, cover_filename);
154 if !exists {
155 return Ok(None);
156 }
157
158 let Some(temp_cover) = crop_cover_to_temp(&cover_path)? else {
160 return Ok(None);
162 };
163
164 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 let mut temp_frontmatter = frontmatter.clone();
174 temp_frontmatter.set_cover(temp_cover.path().to_string_lossy().to_string());
175
176 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#[async_trait::async_trait]
207pub trait WeChatUploader {
208 async fn upload(&self, file_path: &str) -> Result<String>;
210}
211
212#[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#[derive(Debug)]
224enum ImageBackend {
225 Gemini(GeminiClient),
226 OpenAI(OpenAIClient),
227}
228
229impl ImageBackend {
230 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 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
273fn 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 Err(Error::config(format!("Unknown model '{}'", model)))
305 }
306 }
307}
308
309pub 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
334pub async fn upload_file(
336 client: &WeChatClient,
337 config: &Config,
338 path: &Path,
339 force: bool,
340 verbose: bool,
341) -> Result<()> {
342 let (mut frontmatter, body) = match parse_and_check_file(path, force, verbose).await {
344 Ok(result) => result,
345 Err(_) => return Ok(()), };
347
348 let cover_updated = process_cover_image(&mut frontmatter, path, config, verbose).await?;
350
351 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 let temp_files = prepare_upload_files(path, &frontmatter, &body)?;
362
363 let upload_path = temp_files
365 .as_ref()
366 .map(|tf| tf.markdown.path())
367 .unwrap_or(path);
368
369 let upload_result = execute_wechat_upload(client, upload_path, path, verbose).await;
371
372 drop(temp_files);
374
375 upload_result?;
377
378 update_published_status(path, verbose).await?;
380
381 Ok(())
382}
383
384async 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 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
405async fn process_cover_image(
407 frontmatter: &mut Frontmatter,
408 path: &Path,
409 config: &Config,
410 verbose: bool,
411) -> Result<bool> {
412 if !should_generate_cover(frontmatter, path, verbose).await {
414 return Ok(false);
415 }
416
417 let backend = match resolve_backend(frontmatter, config) {
419 Ok(backend) => backend,
420 Err(e) => {
421 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 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 match &frontmatter.cover {
443 None => {
444 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
511async 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
549async 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
591async 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
609pub 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 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 let md_file = temp_path.join("test.md");
641 fs::write(&md_file, "# Test").unwrap();
642
643 let existing_cover = temp_path.join("existing.png");
645 fs::write(&existing_cover, "fake image data").unwrap();
646
647 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 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 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 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(); 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(); 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 }
743 }
744 }
745}