diff --git a/src/main.rs b/src/main.rs index ac97051..fec9b4d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -214,29 +214,6 @@ struct DownloadPickerState { chat_id: i64, message_id: MessageId, links: Vec, - mode: DownloadPickerMode, -} - -#[derive(Clone, Debug)] -enum DownloadPickerMode { - Links, - Quality { - link_index: usize, - action: DownloadAction, - options: Vec, - }, -} - -#[derive(Clone, Debug, Copy)] -enum DownloadAction { - Send, - Save, -} - -#[derive(Clone, Debug)] -struct DownloadQualityOption { - label: String, - format_selector: String, } #[derive(Clone, Debug)] @@ -279,7 +256,6 @@ struct ListSession { view: ListView, seen_random: HashSet, message_id: Option, - sent_media_message_ids: Vec, } #[derive(Clone, Debug)] @@ -610,19 +586,34 @@ async fn handle_media_message( if let Some(document) = msg.document() { let mime = document.mime_type.as_ref().map(|m| m.essence_str()); - fs::create_dir_all(&media_dir) - .with_context(|| format!("create media dir {}", media_dir.display()))?; - let ext = mime.and_then(extension_from_mime); - let filename = if let Some(name) = document.file_name.as_deref() { - sanitize_filename_with_default(name, ext) + let is_media = if let Some(mime) = mime { + mime.starts_with("image/") || mime.starts_with("video/") } else { - format!("file-{}.{}", Uuid::new_v4(), ext.unwrap_or("bin")) + document + .file_name + .as_deref() + .and_then(|name| Path::new(name).extension().and_then(|ext| ext.to_str())) + .map(|ext| matches!( + ext.to_ascii_lowercase().as_str(), + "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "mp4" | "mov" | "mkv" + )) + .unwrap_or(false) }; - let dest_path = media_dir.join(&filename); - download_telegram_file(bot, &document.file.id, &dest_path).await?; - let entry_text = build_media_entry_text(&filename, caption.as_deref()); - handle_single_item(bot.clone(), chat_id, state.clone(), &entry_text, Some(msg.id)).await?; - return Ok(true); + if is_media { + fs::create_dir_all(&media_dir) + .with_context(|| format!("create media dir {}", media_dir.display()))?; + let ext = mime.and_then(extension_from_mime); + let filename = if let Some(name) = document.file_name.as_deref() { + sanitize_filename_with_default(name, ext) + } else { + format!("file-{}.{}", Uuid::new_v4(), ext.unwrap_or("bin")) + }; + let dest_path = media_dir.join(&filename); + download_telegram_file(bot, &document.file.id, &dest_path).await?; + let entry_text = build_media_entry_text(&filename, caption.as_deref()); + handle_single_item(bot.clone(), chat_id, state.clone(), &entry_text, Some(msg.id)).await?; + return Ok(true); + } } if let Some(video) = msg.video() { @@ -720,8 +711,7 @@ async fn handle_norm_message( match apply_user_op(state, &op).await? { UserOpOutcome::Applied(ApplyOutcome::Applied) => { session.entries[target_index] = normalized_entry; - let (text, kb) = - render_list_view(&session.id, &session, &peeked_snapshot, &state.config); + let (text, kb) = render_list_view(&session.id, &session, &peeked_snapshot); if let Some(message_id) = session.message_id { bot.edit_message_text(chat_id, message_id, text) .reply_markup(kb) @@ -730,12 +720,6 @@ async fn handle_norm_message( let sent = bot.send_message(chat_id, text).reply_markup(kb).await?; session.message_id = Some(sent.id); } - if let Err(err) = - refresh_embedded_media_for_view(bot, chat_id, state, &mut session, &peeked_snapshot) - .await - { - error!("send embedded media failed: {:#}", err); - } } UserOpOutcome::Applied(ApplyOutcome::NotFound) | UserOpOutcome::Applied(ApplyOutcome::Duplicate) => { @@ -821,8 +805,7 @@ async fn handle_instant_delete_message( } let _ = add_undo(state, UndoKind::Delete, op.entry.clone()).await?; normalize_peek_view(&mut session, &peeked_snapshot); - let (text, kb) = - render_list_view(&session.id, &session, &peeked_snapshot, &state.config); + let (text, kb) = render_list_view(&session.id, &session, &peeked_snapshot); if let Some(message_id) = session.message_id { bot.edit_message_text(chat_id, message_id, text) .reply_markup(kb) @@ -831,12 +814,6 @@ async fn handle_instant_delete_message( let sent = bot.send_message(chat_id, text).reply_markup(kb).await?; session.message_id = Some(sent.id); } - if let Err(err) = - refresh_embedded_media_for_view(bot, chat_id, state, &mut session, &peeked_snapshot) - .await - { - error!("send embedded media failed: {:#}", err); - } } UserOpOutcome::Applied(ApplyOutcome::NotFound) | UserOpOutcome::Applied(ApplyOutcome::Duplicate) => { @@ -911,7 +888,6 @@ async fn handle_list_command( view: ListView::Menu, seen_random: HashSet::new(), message_id: None, - sent_media_message_ids: Vec::new(), }; let (text, kb) = build_menu_view(&session_id, &session); @@ -961,16 +937,24 @@ async fn handle_search_command( }, seen_random: HashSet::new(), message_id: None, - sent_media_message_ids: Vec::new(), }; let peeked_snapshot = state.peeked.lock().await.clone(); - let (text, kb) = render_list_view(&session_id, &session, &peeked_snapshot, &state.config); + let displayed_indices = displayed_indices_for_view(&session, &peeked_snapshot); + let (text, kb) = render_list_view(&session_id, &session, &peeked_snapshot); let sent = bot .send_message(msg.chat.id, text) .reply_markup(kb) .await?; session.message_id = Some(sent.id); + if !displayed_indices.is_empty() { + let mut peeked = state.peeked.lock().await; + for idx in displayed_indices { + if let Some(entry) = session.entries.get(idx) { + peeked.insert(entry.block_string()); + } + } + } state .sessions .lock() @@ -1579,7 +1563,6 @@ async fn start_download_picker( chat_id: chat_id.0, message_id: sent.id, links, - mode: DownloadPickerMode::Links, }; state .download_pickers @@ -1611,7 +1594,7 @@ async fn handle_download_callback( None => return Ok(()), }; - let mut picker = { + let picker = { let mut pickers = state.download_pickers.lock().await; let picker = match pickers.remove(&picker_id) { Some(picker) => picker, @@ -1633,154 +1616,43 @@ async fn handle_download_callback( match action { "send" => { - if !matches!(picker.mode, DownloadPickerMode::Links) { - reinsert = true; - } else { - let index = parts.next().and_then(|p| p.parse::().ok()); - if let Some(index) = index { - if let Some(link) = picker.links.get(index).cloned() { - let link_for_probe = link.clone(); - let options = tokio::task::spawn_blocking(move || { - run_ytdlp_list_formats(&link_for_probe) - }) - .await - .context("yt-dlp formats task failed")?; - match options { - Ok(options) => { - let text = build_download_quality_text( - &link, - DownloadAction::Send, - &options, - ); - let kb = - build_download_quality_keyboard(&picker_id, &options); - bot.edit_message_text(message.chat.id, message.id, text) - .reply_markup(kb) - .await?; - picker.mode = DownloadPickerMode::Quality { - link_index: index, - action: DownloadAction::Send, - options, - }; - reinsert = true; - } - Err(err) => { - send_error(&bot, message.chat.id, &err.to_string()).await?; - reinsert = true; - } + let index = parts.next().and_then(|p| p.parse::().ok()); + if let Some(index) = index { + if let Some(link) = picker.links.get(index).cloned() { + match download_and_send_link(&bot, message.chat.id, &link).await { + Ok(()) => { + let _ = bot.delete_message(message.chat.id, message.id).await; + } + Err(err) => { + send_error(&bot, message.chat.id, &err.to_string()).await?; + reinsert = true; } - } else { - reinsert = true; } } else { reinsert = true; } + } else { + reinsert = true; } } "save" => { - if !matches!(picker.mode, DownloadPickerMode::Links) { - reinsert = true; - } else { - let index = parts.next().and_then(|p| p.parse::().ok()); - if let Some(index) = index { - if let Some(link) = picker.links.get(index).cloned() { - let link_for_probe = link.clone(); - let options = tokio::task::spawn_blocking(move || { - run_ytdlp_list_formats(&link_for_probe) - }) - .await - .context("yt-dlp formats task failed")?; - match options { - Ok(options) => { - let text = build_download_quality_text( - &link, - DownloadAction::Save, - &options, - ); - let kb = - build_download_quality_keyboard(&picker_id, &options); - bot.edit_message_text(message.chat.id, message.id, text) - .reply_markup(kb) - .await?; - picker.mode = DownloadPickerMode::Quality { - link_index: index, - action: DownloadAction::Save, - options, - }; - reinsert = true; - } - Err(err) => { - send_error(&bot, message.chat.id, &err.to_string()).await?; - reinsert = true; - } + let index = parts.next().and_then(|p| p.parse::().ok()); + if let Some(index) = index { + if let Some(link) = picker.links.get(index).cloned() { + match download_and_save_link(&state, &link).await { + Ok(path) => { + let note = format!("Saved to {}", path.display()); + let kb = InlineKeyboardMarkup::new(vec![vec![ + InlineKeyboardButton::callback("Delete message", "msgdel"), + ]]); + bot.send_message(message.chat.id, note) + .reply_markup(kb) + .await?; + let _ = bot.delete_message(message.chat.id, message.id).await; } - } else { - reinsert = true; - } - } else { - reinsert = true; - } - } - } - "quality" => { - let selected = parts.next().and_then(|p| p.parse::().ok()); - if let ( - Some(selected), - DownloadPickerMode::Quality { - link_index, - action, - options, - }, - ) = (selected, &picker.mode) - { - if let (Some(link), Some(option)) = - (picker.links.get(*link_index).cloned(), options.get(selected).cloned()) - { - match action { - DownloadAction::Send => { - match download_and_send_link( - &bot, - message.chat.id, - &link, - &option.format_selector, - ) - .await - { - Ok(()) => { - let _ = bot.delete_message(message.chat.id, message.id).await; - } - Err(err) => { - send_error(&bot, message.chat.id, &err.to_string()).await?; - reinsert = true; - } - } - } - DownloadAction::Save => { - match download_and_save_link( - &state, - &link, - &option.format_selector, - ) - .await - { - Ok(path) => { - let note = format!("Saved to {}", path.display()); - let kb = InlineKeyboardMarkup::new(vec![vec![ - InlineKeyboardButton::callback( - "Delete message", - "msgdel", - ), - ]]); - bot.send_message(message.chat.id, note) - .reply_markup(kb) - .await?; - let _ = bot.delete_message(message.chat.id, message.id).await; - } - Err(err) => { - send_error(&bot, message.chat.id, &err.to_string()).await?; - reinsert = true; - } - } + Err(err) => { + send_error(&bot, message.chat.id, &err.to_string()).await?; + reinsert = true; } } } else { @@ -1790,42 +1662,25 @@ async fn handle_download_callback( reinsert = true; } } - "back" => { - if matches!(picker.mode, DownloadPickerMode::Quality { .. }) { - let text = build_download_picker_text(&picker.links); - let kb = build_download_picker_keyboard(&picker_id, &picker.links); - bot.edit_message_text(message.chat.id, message.id, text) - .reply_markup(kb) - .await?; - picker.mode = DownloadPickerMode::Links; - reinsert = true; - } else { - reinsert = true; - } - } "add" => { - if matches!(picker.mode, DownloadPickerMode::Links) { - let prompt_text = "Send a link to add."; - let sent = bot.send_message(message.chat.id, prompt_text).await?; - let prompt = DownloadLinkPrompt { - links: picker.links.clone(), - prompt_message_id: sent.id, - expires_at: now_ts() + DOWNLOAD_PROMPT_TTL_SECS, - }; - let previous = state - .download_link_prompts - .lock() - .await - .insert(message.chat.id.0, prompt); - if let Some(previous) = previous { - let _ = bot - .delete_message(message.chat.id, previous.prompt_message_id) - .await; - } - let _ = bot.delete_message(message.chat.id, message.id).await; - } else { - reinsert = true; + let prompt_text = "Send a link to add."; + let sent = bot.send_message(message.chat.id, prompt_text).await?; + let prompt = DownloadLinkPrompt { + links: picker.links.clone(), + prompt_message_id: sent.id, + expires_at: now_ts() + DOWNLOAD_PROMPT_TTL_SECS, + }; + let previous = state + .download_link_prompts + .lock() + .await + .insert(message.chat.id.0, prompt); + if let Some(previous) = previous { + let _ = bot + .delete_message(message.chat.id, previous.prompt_message_id) + .await; } + let _ = bot.delete_message(message.chat.id, message.id).await; } "cancel" => { let _ = bot.delete_message(message.chat.id, message.id).await; @@ -1982,7 +1837,8 @@ async fn handle_finish_title_response( } let peeked_snapshot = state.peeked.lock().await.clone(); - let (text, kb) = render_list_view(&session.id, &session, &peeked_snapshot, &state.config); + let displayed_indices = displayed_indices_for_view(&session, &peeked_snapshot); + let (text, kb) = render_list_view(&session.id, &session, &peeked_snapshot); if let Some(list_message_id) = session.message_id { bot.edit_message_text(chat_id, list_message_id, text) .reply_markup(kb) @@ -1991,11 +1847,15 @@ async fn handle_finish_title_response( let sent = bot.send_message(chat_id, text).reply_markup(kb).await?; session.message_id = Some(sent.id); } - if let Err(err) = - refresh_embedded_media_for_view(bot, chat_id, state, &mut session, &peeked_snapshot).await - { - error!("send embedded media failed: {:#}", err); + if !displayed_indices.is_empty() { + let mut peeked = state.peeked.lock().await; + for idx in displayed_indices { + if let Some(entry) = session.entries.get(idx) { + peeked.insert(entry.block_string()); + } + } } + state .sessions .lock() @@ -2101,8 +1961,6 @@ async fn handle_list_callback( } "close" => { if matches!(&session.kind, SessionKind::Search { .. }) { - delete_embedded_media_messages(&bot, message.chat.id, &session.sent_media_message_ids) - .await; bot.delete_message(message.chat.id, message.id).await?; let mut active = state.active_sessions.lock().await; if active.get(&chat_id) == Some(&session.id) { @@ -2138,19 +1996,13 @@ async fn handle_list_callback( // Stay in place. session.view = ListView::Menu; } else { - let index = { - let mut rng = rand::thread_rng(); - remaining.shuffle(&mut rng); - remaining.first().copied() - }; - if let Some(index) = index { - session.seen_random.insert(index); - let return_to = Box::new(session.view.clone()); - session.view = ListView::Selected { return_to, index }; - if let Some(entry) = session.entries.get(index) { - state.peeked.lock().await.insert(entry.block_string()); - } - } + let mut rng = rand::thread_rng(); + remaining.shuffle(&mut rng); + if let Some(index) = remaining.first().copied() { + session.seen_random.insert(index); + let return_to = Box::new(session.view.clone()); + session.view = ListView::Selected { return_to, index }; + } } } } @@ -2169,11 +2021,6 @@ async fn handle_list_callback( return_to, index: entry_index, }; - if matches!(&session.kind, SessionKind::List) { - if let Some(entry) = session.entries.get(entry_index) { - state.peeked.lock().await.insert(entry.block_string()); - } - } } } } @@ -2376,26 +2223,33 @@ async fn handle_list_callback( } session.message_id = Some(message.id); - let (text, kb) = render_list_view(&session.id, &session, &peeked_snapshot, &state.config); - bot.edit_message_text(message.chat.id, message.id, text) - .reply_markup(kb) - .await?; - if let Err(err) = - refresh_embedded_media_for_view(&bot, message.chat.id, &state, &mut session, &peeked_snapshot) - .await - { - error!("send embedded media failed: {:#}", err); - } + let displayed_indices = displayed_indices_for_view(&session, &peeked_snapshot); + let (text, kb) = render_list_view(&session.id, &session, &peeked_snapshot); + let session_clone = session.clone(); state .sessions .lock() .await - .insert(session.id.clone(), session.clone()); + .insert(session.id.clone(), session); state .active_sessions .lock() .await - .insert(chat_id, session.id.clone()); + .insert(chat_id, session_clone.id.clone()); + bot.edit_message_text(message.chat.id, message.id, text) + .reply_markup(kb) + .await?; + if !displayed_indices.is_empty() { + let mut peeked = state.peeked.lock().await; + for idx in displayed_indices { + if let Some(entry) = session_clone.entries.get(idx) { + peeked.insert(entry.block_string()); + } + } + } + if let Err(err) = send_embedded_media_for_selected(&bot, message.chat.id, &state, &session_clone).await { + error!("send embedded media failed: {:#}", err); + } bot.answer_callback_query(q.id).await?; Ok(()) } @@ -3322,19 +3176,11 @@ fn split_items(text: &str) -> Vec { .collect() } -async fn download_and_send_link( - bot: &Bot, - chat_id: ChatId, - link: &str, - format_selector: &str, -) -> Result<()> { +async fn download_and_send_link(bot: &Bot, chat_id: ChatId, link: &str) -> Result<()> { let temp_dir = TempDir::new().context("create download temp dir")?; let target_dir = temp_dir.path().to_path_buf(); let link = link.to_string(); - let format_selector = format_selector.to_string(); - let path = tokio::task::spawn_blocking(move || { - run_ytdlp_download(&target_dir, &link, &format_selector) - }) + let path = tokio::task::spawn_blocking(move || run_ytdlp_download(&target_dir, &link)) .await .context("yt-dlp task failed")??; bot.send_document(chat_id, InputFile::file(path)).await?; @@ -3344,16 +3190,12 @@ async fn download_and_send_link( async fn download_and_save_link( state: &std::sync::Arc, link: &str, - format_selector: &str, ) -> Result { let target_dir = state.config.media_dir.clone(); fs::create_dir_all(&target_dir) .with_context(|| format!("create media dir {}", target_dir.display()))?; let link = link.to_string(); - let format_selector = format_selector.to_string(); - let path = tokio::task::spawn_blocking(move || { - run_ytdlp_download(&target_dir, &link, &format_selector) - }) + let path = tokio::task::spawn_blocking(move || run_ytdlp_download(&target_dir, &link)) .await .context("yt-dlp task failed")??; if !path.exists() { @@ -3362,148 +3204,10 @@ async fn download_and_save_link( Ok(path) } -fn run_ytdlp_list_formats(link: &str) -> Result> { - let output = Command::new("yt-dlp") - .arg("--no-playlist") - .arg("-J") - .arg(link) - .output() - .context("run yt-dlp")?; - if !output.status.success() { - return Err(anyhow!(format_ytdlp_error(&output))); - } - let value: serde_json::Value = - serde_json::from_slice(&output.stdout).context("parse yt-dlp json")?; - let mut options = vec![DownloadQualityOption { - label: "Best".to_string(), - format_selector: "bestvideo+bestaudio/best".to_string(), - }]; - - let Some(formats) = value.get("formats").and_then(|v| v.as_array()) else { - return Ok(options); - }; - - let mut by_height: HashMap, bool)> = HashMap::new(); - let mut best_audio: Option<(String, String, Option, Option)> = None; - - for format in formats { - let Some(format_id) = format.get("format_id").and_then(|v| v.as_str()) else { - continue; - }; - let vcodec = format.get("vcodec").and_then(|v| v.as_str()).unwrap_or("none"); - let acodec = format.get("acodec").and_then(|v| v.as_str()).unwrap_or("none"); - let ext = format - .get("ext") - .and_then(|v| v.as_str()) - .unwrap_or("unknown") - .to_string(); - let filesize = format - .get("filesize") - .and_then(|v| v.as_u64()) - .or_else(|| format.get("filesize_approx").and_then(|v| v.as_u64())); - - if vcodec == "none" && acodec != "none" { - let abr = format.get("abr").and_then(|v| v.as_f64()); - match &best_audio { - Some((_, _, existing_size, existing_abr)) => { - let better_abr = abr.unwrap_or(0.0) > existing_abr.unwrap_or(0.0); - let better_size = filesize.unwrap_or(0) > existing_size.unwrap_or(0); - if better_abr || better_size { - best_audio = Some((format_id.to_string(), ext, filesize, abr)); - } - } - None => { - best_audio = Some((format_id.to_string(), ext, filesize, abr)); - } - } - continue; - } - - if vcodec == "none" { - continue; - } - - let Some(height) = format.get("height").and_then(|v| v.as_i64()) else { - continue; - }; - if height <= 0 { - continue; - } - - let has_audio = acodec != "none"; - let selector = if has_audio { - format_id.to_string() - } else { - format!("{}+bestaudio/best", format_id) - }; - let candidate = (selector, ext, filesize, has_audio); - match by_height.get(&height) { - Some((_, _, existing_size, existing_has_audio)) => { - let better_audio = has_audio && !existing_has_audio; - let better_size = filesize.unwrap_or(0) > existing_size.unwrap_or(0); - if better_audio || better_size { - by_height.insert(height, candidate); - } - } - None => { - by_height.insert(height, candidate); - } - } - } - - let mut heights: Vec = by_height.keys().copied().collect(); - heights.sort_by(|a, b| b.cmp(a)); - for height in heights.into_iter().take(6) { - if let Some((selector, ext, size, has_audio)) = by_height.get(&height) { - let mut label = format!("{}p {}", height, ext); - if !has_audio { - label.push_str(" (video-only source)"); - } - if let Some(size) = size { - label.push_str(&format!(" ({})", human_size(*size))); - } - options.push(DownloadQualityOption { - label, - format_selector: selector.clone(), - }); - } - } - - if let Some((format_id, ext, size, _abr)) = best_audio { - let mut label = format!("Audio only ({})", ext); - if let Some(size) = size { - label.push_str(&format!(" ({})", human_size(size))); - } - options.push(DownloadQualityOption { - label, - format_selector: format_id, - }); - } - - Ok(options) -} - -fn human_size(bytes: u64) -> String { - const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; - let mut value = bytes as f64; - let mut unit = 0usize; - while value >= 1024.0 && unit < UNITS.len() - 1 { - value /= 1024.0; - unit += 1; - } - if unit == 0 { - format!("{} {}", bytes, UNITS[unit]) - } else { - format!("{:.1} {}", value, UNITS[unit]) - } -} - -fn run_ytdlp_download(target_dir: &Path, link: &str, format_selector: &str) -> Result { +fn run_ytdlp_download(target_dir: &Path, link: &str) -> Result { let template = target_dir.join("%(title).200B-%(id)s.%(ext)s"); let output = Command::new("yt-dlp") .arg("--no-playlist") - .arg("-f") - .arg(format_selector) .arg("--print") .arg("after_move:filepath") .arg("-o") @@ -3564,7 +3268,6 @@ fn matches_query(entry: &EntryBlock, query: &str) -> bool { .all(|term| haystack.contains(term)) } -#[cfg(test)] fn displayed_indices_for_view( session: &ListSession, peeked: &HashSet, @@ -3573,28 +3276,6 @@ fn displayed_indices_for_view( ListView::Peek { mode, page } => peek_indices_for_session(session, peeked, mode, page), ListView::Selected { index, .. } => vec![index], ListView::FinishConfirm { index, .. } => vec![index], - ListView::DeleteConfirm { index, .. } => vec![index], - _ => Vec::new(), - } -} - -fn embedded_lines_for_view(session: &ListSession, peeked: &HashSet) -> Vec { - match session.view { - ListView::Peek { mode, page } => peek_indices_for_session(session, peeked, mode, page) - .into_iter() - .filter_map(|index| session.entries.get(index)) - .flat_map(|entry| entry.preview_lines()) - .collect(), - ListView::Selected { index, .. } => session - .entries - .get(index) - .map(|entry| entry.display_lines()) - .unwrap_or_default(), - ListView::FinishConfirm { index, .. } | ListView::DeleteConfirm { index, .. } => session - .entries - .get(index) - .map(|entry| entry.preview_lines()) - .unwrap_or_default(), _ => Vec::new(), } } @@ -3863,22 +3544,6 @@ fn build_download_picker_text(links: &[String]) -> String { text.trim_end().to_string() } -fn build_download_quality_text( - link: &str, - action: DownloadAction, - options: &[DownloadQualityOption], -) -> String { - let action_label = match action { - DownloadAction::Send => "send", - DownloadAction::Save => "save", - }; - let mut text = format!("Choose quality to {}:\n{}\n\n", action_label, link); - for (idx, option) in options.iter().enumerate() { - text.push_str(&format!("{}: {}\n", idx + 1, option.label)); - } - text.trim_end().to_string() -} - fn build_download_picker_keyboard( picker_id: &str, links: &[String], @@ -3907,45 +3572,20 @@ fn build_download_picker_keyboard( InlineKeyboardMarkup::new(rows) } -fn build_download_quality_keyboard( - picker_id: &str, - options: &[DownloadQualityOption], -) -> InlineKeyboardMarkup { - let mut rows = Vec::new(); - for (idx, option) in options.iter().enumerate() { - rows.push(vec![InlineKeyboardButton::callback( - option.label.clone(), - format!("dl:{}:quality:{}", picker_id, idx), - )]); - } - rows.push(vec![InlineKeyboardButton::callback( - "Back", - format!("dl:{}:back", picker_id), - )]); - rows.push(vec![InlineKeyboardButton::callback( - "Cancel", - format!("dl:{}:cancel", picker_id), - )]); - InlineKeyboardMarkup::new(rows) -} - fn render_list_view( session_id: &str, session: &ListSession, peeked: &HashSet, - config: &Config, ) -> (String, InlineKeyboardMarkup) { match &session.view { ListView::Menu => build_menu_view(session_id, session), - ListView::Peek { mode, page } => { - build_peek_view(session_id, session, *mode, *page, peeked, config) - } - ListView::Selected { index, .. } => build_selected_view(session_id, session, *index, config), + ListView::Peek { mode, page } => build_peek_view(session_id, session, *mode, *page, peeked), + ListView::Selected { index, .. } => build_selected_view(session_id, session, *index), ListView::FinishConfirm { index, .. } => { - build_finish_confirm_view(session_id, session, *index, config) + build_finish_confirm_view(session_id, session, *index) } ListView::DeleteConfirm { step, index, .. } => { - build_delete_confirm_view(session_id, session, *index, *step, config) + build_delete_confirm_view(session_id, session, *index, *step) } } } @@ -4010,7 +3650,6 @@ fn build_peek_view( mode: ListMode, page: usize, peeked: &HashSet, - config: &Config, ) -> (String, InlineKeyboardMarkup) { let total_unpeeked = count_visible_entries(session, peeked); let indices = peek_indices_for_session(session, peeked, mode, page); @@ -4043,7 +3682,7 @@ fn build_peek_view( } else { for (display_index, entry_index) in indices.iter().enumerate() { if let Some(entry) = session.entries.get(*entry_index) { - let preview = format_embedded_references_for_lines(&entry.preview_lines(), config); + let preview = entry.preview_lines(); text.push_str(&format!("{}) ", display_index + 1)); if let Some(first) = preview.get(0) { text.push_str(first); @@ -4096,11 +3735,10 @@ fn build_selected_view( session_id: &str, session: &ListSession, index: usize, - config: &Config, ) -> (String, InlineKeyboardMarkup) { let entry = session.entries.get(index); let text = if let Some(entry) = entry { - let lines = format_embedded_references_for_lines(&entry.display_lines(), config); + let lines = entry.display_lines(); format!("Selected item:\n\n{}", lines.join("\n")) } else { "Selected item not found.".to_string() @@ -4196,12 +3834,9 @@ fn build_finish_confirm_view( session_id: &str, session: &ListSession, index: usize, - config: &Config, ) -> (String, InlineKeyboardMarkup) { let entry = session.entries.get(index); - let preview = entry - .map(|e| format_embedded_references_for_lines(&e.preview_lines(), config)) - .unwrap_or_default(); + let preview = entry.map(|e| e.preview_lines()).unwrap_or_default(); let mut text = String::from("Finish this item?\n\n"); if let Some(first) = preview.get(0) { text.push_str(first); @@ -4235,12 +3870,9 @@ fn build_delete_confirm_view( session: &ListSession, index: usize, step: u8, - config: &Config, ) -> (String, InlineKeyboardMarkup) { let entry = session.entries.get(index); - let preview = entry - .map(|e| format_embedded_references_for_lines(&e.preview_lines(), config)) - .unwrap_or_default(); + let preview = entry.map(|e| e.preview_lines()).unwrap_or_default(); let mut text = format!("Confirm delete ({}/2)?\n\n", step); if let Some(first) = preview.get(0) { text.push_str(first); @@ -4403,46 +4035,27 @@ async fn send_error(bot: &Bot, chat_id: ChatId, text: &str) -> Result<()> { Ok(()) } -async fn send_embedded_media_for_view( +async fn send_embedded_media_for_selected( bot: &Bot, chat_id: ChatId, state: &std::sync::Arc, session: &ListSession, - peeked: &HashSet, -) -> Result> { - let lines = embedded_lines_for_view(session, peeked); +) -> Result<()> { + let ListView::Selected { index, .. } = session.view else { + return Ok(()); + }; + let Some(entry) = session.entries.get(index) else { + return Ok(()); + }; + let lines = entry.display_lines(); let embeds = extract_embedded_paths(&lines, &state.config); - let mut sent_message_ids = Vec::new(); for path in embeds { if is_image_path(&path) { - let sent = bot.send_photo(chat_id, InputFile::file(path)).await?; - sent_message_ids.push(sent.id); - } else if is_video_path(&path) { - let sent = bot.send_video(chat_id, InputFile::file(path)).await?; - sent_message_ids.push(sent.id); + bot.send_photo(chat_id, InputFile::file(path)).await?; } else { - let sent = bot.send_document(chat_id, InputFile::file(path)).await?; - sent_message_ids.push(sent.id); + bot.send_document(chat_id, InputFile::file(path)).await?; } } - Ok(sent_message_ids) -} - -async fn delete_embedded_media_messages(bot: &Bot, chat_id: ChatId, message_ids: &[MessageId]) { - for message_id in message_ids { - let _ = bot.delete_message(chat_id, *message_id).await; - } -} - -async fn refresh_embedded_media_for_view( - bot: &Bot, - chat_id: ChatId, - state: &std::sync::Arc, - session: &mut ListSession, - peeked: &HashSet, -) -> Result<()> { - delete_embedded_media_messages(bot, chat_id, &session.sent_media_message_ids).await; - session.sent_media_message_ids = send_embedded_media_for_view(bot, chat_id, state, session, peeked).await?; Ok(()) } @@ -4900,58 +4513,6 @@ fn build_media_entry_text(filename: &str, caption: Option<&str>) -> String { text } -fn format_embedded_references_for_lines(lines: &[String], config: &Config) -> Vec { - let mut labels: HashMap = HashMap::new(); - let mut next_label = 1usize; - let mut output = Vec::with_capacity(lines.len()); - - for line in lines { - let mut formatted = String::with_capacity(line.len()); - let mut index = 0; - while let Some(start_rel) = line[index..].find("![[") { - let marker_start = index + start_rel; - formatted.push_str(&line[index..marker_start]); - - let marker_content_start = marker_start + 3; - let Some(end_rel) = line[marker_content_start..].find("]]") else { - formatted.push_str(&line[marker_start..]); - index = line.len(); - break; - }; - let marker_content_end = marker_content_start + end_rel; - let marker_end = marker_content_end + 2; - let marker_inner = &line[marker_content_start..marker_content_end]; - - if let Some(path) = resolve_embedded_path(marker_inner, config) { - let label = match labels.get(&path) { - Some(label) => *label, - None => { - let assigned = next_label; - labels.insert(path.clone(), assigned); - next_label += 1; - assigned - } - }; - if is_image_path(&path) { - formatted.push_str(&format!("image #{}", label)); - } else if is_video_path(&path) { - formatted.push_str(&format!("video #{}", label)); - } else { - formatted.push_str(&format!("file #{}", label)); - } - } else { - formatted.push_str(&line[marker_start..marker_end]); - } - - index = marker_end; - } - formatted.push_str(&line[index..]); - output.push(formatted); - } - - output -} - fn pick_best_photo(photos: &[teloxide::types::PhotoSize]) -> Option<&teloxide::types::PhotoSize> { photos.iter().max_by_key(|photo| { photo.file.size.max((photo.width * photo.height) as u32) as u64 @@ -4968,6 +4529,10 @@ async fn download_telegram_file(bot: &Bot, file_id: &str, dest_path: &Path) -> R fn extract_embedded_paths(lines: &[String], config: &Config) -> Vec { let mut paths = Vec::new(); let mut seen = HashSet::new(); + let vault_root = config + .read_later_path + .parent() + .unwrap_or_else(|| Path::new(".")); for line in lines { let mut index = 0; while let Some(start_rel) = line[index..].find("![[") { @@ -4976,11 +4541,23 @@ fn extract_embedded_paths(lines: &[String], config: &Config) -> Vec { break; }; let end = start + end_rel; - let inner = &line[start..end]; - if let Some(path) = resolve_embedded_path(inner, config) { - if seen.insert(path.clone()) { - paths.push(path); - } + let mut inner = line[start..end].trim(); + if let Some((path_part, _)) = inner.split_once('|') { + inner = path_part.trim(); + } + if inner.is_empty() { + index = end + 2; + continue; + } + let path = if Path::new(inner).is_absolute() { + PathBuf::from(inner) + } else if inner.contains('/') || inner.contains('\\') { + vault_root.join(inner) + } else { + config.media_dir.join(inner) + }; + if path.exists() && seen.insert(path.clone()) { + paths.push(path); } index = end + 2; } @@ -4988,34 +4565,6 @@ fn extract_embedded_paths(lines: &[String], config: &Config) -> Vec { paths } -fn resolve_embedded_path(inner: &str, config: &Config) -> Option { - let mut inner = inner.trim(); - if let Some((path_part, _)) = inner.split_once('|') { - inner = path_part.trim(); - } - if inner.is_empty() { - return None; - } - - let vault_root = config - .read_later_path - .parent() - .unwrap_or_else(|| Path::new(".")); - let path = if Path::new(inner).is_absolute() { - PathBuf::from(inner) - } else if inner.contains('/') || inner.contains('\\') { - vault_root.join(inner) - } else { - config.media_dir.join(inner) - }; - - if path.exists() { - Some(path) - } else { - None - } -} - fn is_image_path(path: &Path) -> bool { match path.extension().and_then(|ext| ext.to_str()) { Some(ext) => matches!( @@ -5026,16 +4575,6 @@ fn is_image_path(path: &Path) -> bool { } } -fn is_video_path(path: &Path) -> bool { - match path.extension().and_then(|ext| ext.to_str()) { - Some(ext) => matches!( - ext.to_ascii_lowercase().as_str(), - "mp4" | "mov" | "mkv" | "webm" | "avi" | "m4v" - ), - None => false, - } -} - fn parse_command(text: &str) -> Option<&str> { let first = text.split_whitespace().next()?; if !first.starts_with('/') { @@ -5112,20 +4651,6 @@ mod tests { EntryBlock::from_text(text) } - fn test_config() -> Config { - Config { - token: "token".to_string(), - user_id: 1, - read_later_path: PathBuf::from("/tmp/read-later.md"), - finished_path: PathBuf::from("/tmp/finished.md"), - resources_path: PathBuf::from("/tmp/resources"), - media_dir: PathBuf::from("/tmp/media"), - data_dir: PathBuf::from("/tmp/data"), - retry_interval_seconds: None, - sync: None, - } - } - #[test] fn normalize_markdown_links_replaces_single_link() { let input = "See [post](https://example.com/post) now"; @@ -5187,42 +4712,6 @@ mod tests { ); } - #[test] - fn search_peek_indices_ignore_peeked_entries() { - let entries: Vec = (0..4) - .map(|i| entry(&format!("match {}", i))) - .collect(); - let session = ListSession { - id: "session".to_string(), - chat_id: 0, - kind: SessionKind::Search { - query: "match".to_string(), - }, - entries: entries.clone(), - view: ListView::Peek { - mode: ListMode::Top, - page: 0, - }, - seen_random: HashSet::new(), - message_id: None, - sent_media_message_ids: Vec::new(), - }; - let mut peeked = HashSet::new(); - for entry in &entries { - peeked.insert(entry.block_string()); - } - - assert_eq!(count_visible_entries(&session, &peeked), 4); - assert_eq!( - peek_indices_for_session(&session, &peeked, ListMode::Top, 0), - vec![0, 1, 2] - ); - assert_eq!( - peek_indices_for_session(&session, &peeked, ListMode::Top, 1), - vec![3] - ); - } - #[test] fn build_peek_view_shows_all_peeked_message() { let entries = vec![entry("one"), entry("two")]; @@ -5237,104 +4726,15 @@ mod tests { }, seen_random: HashSet::new(), message_id: None, - sent_media_message_ids: Vec::new(), }; let mut peeked = HashSet::new(); for entry in &entries { peeked.insert(entry.block_string()); } - let config = test_config(); - let (text, _kb) = build_peek_view("session", &session, ListMode::Top, 0, &peeked, &config); + let (text, _kb) = build_peek_view("session", &session, ListMode::Top, 0, &peeked); assert!(text.contains("Everything's been peeked already.")); } - #[test] - fn format_embedded_references_labels_images_and_files() { - let temp = TempDir::new().unwrap(); - let media_dir = temp.path().join("media"); - fs::create_dir_all(&media_dir).unwrap(); - fs::write(media_dir.join("image-1.jpg"), b"x").unwrap(); - fs::write(media_dir.join("doc-1.pdf"), b"x").unwrap(); - - let mut config = test_config(); - config.media_dir = media_dir; - - let lines = vec![ - "![[image-1.jpg]] and ![[doc-1.pdf]]".to_string(), - "repeat ![[image-1.jpg]]".to_string(), - ]; - let rendered = format_embedded_references_for_lines(&lines, &config); - - assert_eq!(rendered[0], "image #1 and file #2"); - assert_eq!(rendered[1], "repeat image #1"); - } - - #[test] - fn format_embedded_references_labels_videos() { - let temp = TempDir::new().unwrap(); - let media_dir = temp.path().join("media"); - fs::create_dir_all(&media_dir).unwrap(); - fs::write(media_dir.join("clip.mp4"), b"x").unwrap(); - - let mut config = test_config(); - config.media_dir = media_dir; - - let lines = vec!["Watch ![[clip.mp4]]".to_string()]; - let rendered = format_embedded_references_for_lines(&lines, &config); - - assert_eq!(rendered[0], "Watch video #1"); - } - - #[test] - fn human_size_formats_units() { - assert_eq!(human_size(999), "999 B"); - assert_eq!(human_size(2048), "2.0 KB"); - assert_eq!(human_size(5 * 1024 * 1024), "5.0 MB"); - } - - #[test] - fn build_download_quality_text_lists_options() { - let options = vec![ - DownloadQualityOption { - label: "Best".to_string(), - format_selector: "bestvideo+bestaudio/best".to_string(), - }, - DownloadQualityOption { - label: "720p mp4".to_string(), - format_selector: "22".to_string(), - }, - ]; - let text = build_download_quality_text( - "https://example.com/video", - DownloadAction::Send, - &options, - ); - assert!(text.contains("Choose quality to send")); - assert!(text.contains("1: Best")); - assert!(text.contains("2: 720p mp4")); - } - - #[test] - fn embedded_lines_for_peek_use_preview_only() { - let entry = EntryBlock::from_text("first line\nsecond line\n![[image-2.jpg]]"); - let session = ListSession { - id: "session".to_string(), - chat_id: 0, - kind: SessionKind::List, - entries: vec![entry], - view: ListView::Peek { - mode: ListMode::Top, - page: 0, - }, - seen_random: HashSet::new(), - message_id: None, - sent_media_message_ids: Vec::new(), - }; - - let lines = embedded_lines_for_view(&session, &HashSet::new()); - assert_eq!(lines, vec!["first line".to_string(), "second line...".to_string()]); - } - #[test] fn build_undos_view_includes_labels_and_previews() { let record_one = UndoRecord { @@ -5371,7 +4771,6 @@ mod tests { }, seen_random: HashSet::new(), message_id: None, - sent_media_message_ids: Vec::new(), }; let peeked = HashSet::new(); assert_eq!(displayed_indices_for_view(&session, &peeked), vec![1]); @@ -5393,7 +4792,6 @@ mod tests { }, seen_random: HashSet::new(), message_id: None, - sent_media_message_ids: Vec::new(), }; assert_eq!(norm_target_index(&session, &peeked), Some(1));