audiobook_forge/ui/
prompts.rs1use crate::models::{AudibleMetadata, CurrentMetadata, MatchCandidate, MatchConfidence, AudibleAuthor};
4use anyhow::Result;
5use console::style;
6use inquire::{Confirm, CustomType, Select, Text};
7
8pub enum UserChoice {
10 SelectMatch(usize), Skip, ManualEntry, CustomSearch, }
15
16pub 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 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 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 let selection = Select::new("Select an option:", options)
71 .with_page_size(15)
72 .prompt()?;
73
74 for (idx, ch) in selection.chars().enumerate() {
77 if ch.is_ascii_digit() {
78 let digit = ch.to_digit(10).unwrap() as usize;
79 if digit >= 1 && digit <= candidates.len() {
81 return Ok(UserChoice::SelectMatch(digit - 1));
82 }
83 break;
85 }
86 if idx >= 20 {
88 break;
89 }
90 }
91
92 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) }
102}
103
104pub 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
158pub 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 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
203pub 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
229fn 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
250fn 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
262fn 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}