audiobook_forge/ui/
prompts.rs

1//! Interactive prompts for metadata matching
2
3use crate::models::{AudibleMetadata, CurrentMetadata, MatchCandidate, MatchConfidence, AudibleAuthor};
4use anyhow::Result;
5use console::style;
6use inquire::{Confirm, CustomType, Select, Text};
7
8/// User's choice after viewing candidates
9pub enum UserChoice {
10    SelectMatch(usize), // Index into candidates
11    Skip,               // Leave file unchanged
12    ManualEntry,        // Enter metadata manually
13    CustomSearch,       // Search with different terms
14}
15
16/// Display match candidates and prompt for selection
17pub fn prompt_match_selection(
18    current: &CurrentMetadata,
19    candidates: &[MatchCandidate],
20) -> Result<UserChoice> {
21    println!("\n{}", style("Match Candidates:").bold().cyan());
22    println!(
23        "Current: {} by {}",
24        current.title.as_deref().unwrap_or("Unknown"),
25        current.author.as_deref().unwrap_or("Unknown")
26    );
27    println!();
28
29    // Build options for inquire::Select
30    let mut options = Vec::new();
31
32    for (i, candidate) in candidates.iter().enumerate() {
33        let percentage = (1.0 - candidate.distance.total_distance()) * 100.0;
34        let color_fn: fn(String) -> String = match candidate.confidence {
35            MatchConfidence::Strong => style_green,
36            MatchConfidence::Medium => style_yellow,
37            MatchConfidence::Low => style_red,
38            MatchConfidence::None => style_dim,
39        };
40
41        let label = format!(
42            "{}. [{:>5.1}%] {} by {} ({}, {})",
43            i + 1,
44            percentage,
45            candidate.metadata.title,
46            candidate
47                .metadata
48                .authors
49                .first()
50                .map(|a| a.name.as_str())
51                .unwrap_or("Unknown"),
52            candidate
53                .metadata
54                .published_year
55                .map(|y| y.to_string())
56                .unwrap_or_else(|| "N/A".to_string()),
57            format_duration(candidate.metadata.runtime_length_ms),
58        );
59
60        options.push(color_fn(label));
61    }
62
63    // Add action options
64    options.push(style("───────────────────────────────────").dim().to_string());
65    options.push(style("[S] Skip this file").yellow().to_string());
66    options.push(style("[M] Enter metadata manually").cyan().to_string());
67    options.push(style("[R] Search with different terms").blue().to_string());
68
69    // Show select menu
70    let selection = Select::new("Select an option:", options)
71        .with_page_size(15)
72        .prompt()?;
73
74    // Parse selection
75    // Find first digit in the selection string (skipping any ANSI color codes)
76    for (idx, ch) in selection.chars().enumerate() {
77        if ch.is_ascii_digit() {
78            let digit = ch.to_digit(10).unwrap() as usize;
79            // Validate it's a valid candidate index
80            if digit >= 1 && digit <= candidates.len() {
81                return Ok(UserChoice::SelectMatch(digit - 1));
82            }
83            // If we found a digit but it's not valid, stop searching
84            break;
85        }
86        // Stop searching after first 20 characters to avoid matching digits in titles
87        if idx >= 20 {
88            break;
89        }
90    }
91
92    // Check for action options
93    if selection.contains("[S]") {
94        Ok(UserChoice::Skip)
95    } else if selection.contains("[M]") {
96        Ok(UserChoice::ManualEntry)
97    } else if selection.contains("[R]") {
98        Ok(UserChoice::CustomSearch)
99    } else {
100        Ok(UserChoice::Skip) // Fallback
101    }
102}
103
104/// Show detailed comparison and confirm selection
105pub fn confirm_match(
106    current: &CurrentMetadata,
107    selected: &MatchCandidate,
108) -> Result<bool> {
109    println!("\n{}", style("Metadata Changes:").bold().cyan());
110    println!();
111
112    show_field_change(
113        "Title",
114        current.title.as_deref(),
115        Some(&selected.metadata.title),
116    );
117
118    show_field_change(
119        "Author",
120        current.author.as_deref(),
121        selected
122            .metadata
123            .authors
124            .first()
125            .map(|a| a.name.as_str()),
126    );
127
128    if let Some(subtitle) = &selected.metadata.subtitle {
129        show_field_change("Subtitle", None, Some(subtitle));
130    }
131
132    if let Some(narrator) = selected.metadata.narrators.first() {
133        show_field_change("Narrator", None, Some(narrator));
134    }
135
136    show_field_change(
137        "Year",
138        current.year.as_ref().map(|y| y.to_string()).as_deref(),
139        selected
140            .metadata
141            .published_year
142            .as_ref()
143            .map(|y| y.to_string())
144            .as_deref(),
145    );
146
147    if let Some(publisher) = &selected.metadata.publisher {
148        show_field_change("Publisher", None, Some(publisher));
149    }
150
151    println!();
152
153    Ok(Confirm::new("Apply these changes?")
154        .with_default(true)
155        .prompt()?)
156}
157
158/// Prompt for manual metadata entry
159pub fn prompt_manual_metadata() -> Result<AudibleMetadata> {
160    println!("\n{}", style("Enter Metadata Manually:").bold().cyan());
161
162    let title = Text::new("Title:").prompt()?;
163
164    let author_name = Text::new("Author:").prompt()?;
165
166    let narrator = Text::new("Narrator (optional):")
167        .with_default("")
168        .prompt()?;
169
170    let year: Option<u32> = CustomType::new("Year (optional):")
171        .with_error_message("Please enter a valid year or leave empty")
172        .prompt_skippable()?;
173
174    // Create minimal AudibleMetadata
175    Ok(AudibleMetadata {
176        asin: String::from("manual"),
177        title,
178        subtitle: None,
179        authors: vec![AudibleAuthor {
180            asin: None,
181            name: author_name,
182        }],
183        narrators: if narrator.is_empty() {
184            vec![]
185        } else {
186            vec![narrator]
187        },
188        publisher: None,
189        published_year: year,
190        description: None,
191        cover_url: None,
192        isbn: None,
193        genres: vec![],
194        tags: vec![],
195        series: vec![],
196        language: None,
197        runtime_length_ms: None,
198        rating: None,
199        is_abridged: None,
200    })
201}
202
203/// Prompt for custom search terms
204pub fn prompt_custom_search() -> Result<(Option<String>, Option<String>)> {
205    println!("\n{}", style("Custom Search:").bold().cyan());
206
207    let title = Text::new("Title (optional):")
208        .with_default("")
209        .prompt()?;
210
211    let author = Text::new("Author (optional):")
212        .with_default("")
213        .prompt()?;
214
215    let title_opt = if title.is_empty() {
216        None
217    } else {
218        Some(title)
219    };
220    let author_opt = if author.is_empty() {
221        None
222    } else {
223        Some(author)
224    };
225
226    Ok((title_opt, author_opt))
227}
228
229/// Helper: show field change
230fn show_field_change(field: &str, old: Option<&str>, new: Option<&str>) {
231    let old_display = old.unwrap_or("(none)");
232    let new_display = new.unwrap_or("(none)");
233
234    if old != new {
235        println!(
236            "  {}: {} → {}",
237            style(field).bold(),
238            style(old_display).dim(),
239            style(new_display).green()
240        );
241    } else {
242        println!(
243            "  {}: {}",
244            style(field).bold(),
245            style(new_display).dim()
246        );
247    }
248}
249
250/// Helper: format duration from milliseconds
251fn format_duration(ms: Option<u64>) -> String {
252    match ms {
253        Some(ms) => {
254            let hours = ms / 3_600_000;
255            let minutes = (ms % 3_600_000) / 60_000;
256            format!("{}h {}m", hours, minutes)
257        }
258        None => "N/A".to_string(),
259    }
260}
261
262/// Helper color functions
263fn style_green(s: String) -> String {
264    style(s).green().to_string()
265}
266fn style_yellow(s: String) -> String {
267    style(s).yellow().to_string()
268}
269fn style_red(s: String) -> String {
270    style(s).red().to_string()
271}
272fn style_dim(s: String) -> String {
273    style(s).dim().to_string()
274}