Download src/book_parser/finalize_utils.rs from Haruka041/fqnd: direct link, hf CLI and curl.
- Browser
- Download file 31.8 kB
-
https://huggingface.co/spaces/Haruka041/fqnd/resolve/main/src/book_parser/finalize_utils.rs
- Command line
-
hf download hf://spaces/Haruka041/fqnd/src/book_parser/finalize_utils.rs
-
curl -L -o finalize_utils.rs https://huggingface.co/spaces/Haruka041/fqnd/resolve/main/src/book_parser/finalize_utils.rs
31.8 kB
| //! 导出收尾(finalize)与后处理。 | |
| //! | |
| //! 包括写入最终文件、自动打开产物等"完成后"逻辑。 | |
| //! 具体子模块:`finalize_epub`、`html_utils`、`image_utils`、`segment_comments`、`segment_shared`。 | |
| use std::fs::{self, File}; | |
| use std::io::{self, BufRead, Write}; | |
| use std::path::{Path, PathBuf}; | |
| use std::process::Command; | |
| use std::{collections::HashMap, collections::HashSet}; | |
| use serde_json::Value; | |
| use tracing::{error, info, warn}; | |
| use super::parser::ContentParser; | |
| use crossterm::event::EnableMouseCapture; | |
| use crossterm::terminal::enable_raw_mode; | |
| use super::audio_generator::generate_audiobook; | |
| use super::book_manager::BookManager; | |
| use super::finalize_epub::finalize_epub; | |
| use super::finalize_pdf::finalize_pdf; | |
| use crate::base_system::context::safe_fs_name; | |
| use crate::download::downloader; | |
| /// 生成最终输出;返回是否需要延迟清理缓存。 | |
| pub fn run_finalize( | |
| manager: &mut BookManager, | |
| chapters: &[Value], | |
| _result: i32, | |
| directory_raw: Option<&Value>, | |
| reporter: Option<&mut crate::download::downloader::ProgressReporter>, | |
| cancel: Option<&std::sync::Arc<std::sync::atomic::AtomicBool>>, | |
| ) -> bool { | |
| info!(target: "book_manager", "finalize start: chapters={}", chapters.len()); | |
| let mut reporter = reporter; | |
| // "下载完后选择":在生成文件前询问用户使用哪个书名(仅 CLI 模式兜底) | |
| if manager.config.is_ask_after_download() | |
| && !manager.book_name_selected_after_download | |
| && let Some(chosen) = prompt_book_name_selection(manager) | |
| { | |
| info!(target: "book_manager", "用户选择书名: {}", chosen); | |
| let old_name = manager.book_name.clone(); | |
| manager.book_name = chosen; | |
| manager.book_name_selected_after_download = true; | |
| if old_name != manager.book_name { | |
| manager.remember_previous_book_name(&old_name); | |
| } | |
| } | |
| // "下载完后选择格式":在生成文件前询问用户选择输出格式(仅 CLI 模式兜底) | |
| if manager.config.ask_format_after_download | |
| && !manager.format_selected_after_download | |
| && let Some(chosen) = prompt_format_selection(manager) | |
| { | |
| info!(target: "book_manager", "用户选择输出格式: {}", chosen); | |
| if let Err(err) = manager.config.apply_output_format_choice(&chosen) { | |
| warn!(target: "book_manager", error = %err, "应用输出格式选择失败"); | |
| } | |
| manager.format_selected_after_download = true; | |
| } | |
| let fmt = manager.config.novel_format.to_lowercase(); | |
| let output_path = match prepare_output_path(manager, &fmt) { | |
| Ok(p) => p, | |
| Err(e) => { | |
| error!(target: "book_manager", error = ?e, "prepare output path failed"); | |
| return false; | |
| } | |
| }; | |
| let result: anyhow::Result<()> = if fmt == "txt" { | |
| finalize_txt(manager, chapters, &output_path, directory_raw) | |
| } else if fmt == "pdf" { | |
| finalize_pdf(manager, chapters, &output_path, directory_raw) | |
| } else { | |
| let reporter_ref = { | |
| reporter.as_deref_mut() | |
| }; | |
| finalize_epub(manager, chapters, &output_path, directory_raw, reporter_ref) | |
| }; | |
| if let Err(e) = result { | |
| error!(target: "book_manager", error = ?e, "finalize failed"); | |
| return false; | |
| } | |
| archive_previous_main_outputs(manager, &output_path); | |
| info!(target: "book_manager", "written: {}", output_path.display()); | |
| if manager.config.auto_open_downloaded_files { | |
| if let Err(e) = open_in_default_app(&output_path) { | |
| warn!(target: "book_manager", error = ?e, "auto open downloaded file failed"); | |
| } | |
| // Best-effort: re-assert TUI terminal modes after spawning external opener. | |
| if reporter.as_ref().is_some_and(|r| r.has_ui_callback()) { | |
| let _ = enable_raw_mode(); | |
| let mut out = std::io::stdout(); | |
| let _ = crossterm::execute!(&mut out, EnableMouseCapture); | |
| } | |
| } | |
| let audiobook_bar = reporter.as_ref().and_then(|r| r.cli_save_bar()); | |
| let quiet = reporter.as_ref().is_some_and(|r| r.has_ui_callback()); | |
| let reporter_ref = { | |
| reporter.as_deref_mut() | |
| }; | |
| let audiobook_ok = generate_audiobook( | |
| manager, | |
| chapters, | |
| audiobook_bar.as_ref(), | |
| quiet, | |
| reporter_ref, | |
| cancel, | |
| ); | |
| if !audiobook_ok { | |
| warn!(target: "book_manager", "audiobook generation failed"); | |
| } else if manager.config.enable_audiobook { | |
| archive_previous_audiobook_outputs(manager); | |
| } | |
| true | |
| } | |
| fn open_in_default_app(path: &Path) -> std::io::Result<()> { | |
| if cfg!(target_os = "windows") { | |
| Command::new("explorer").arg(path).spawn()?; | |
| return Ok(()); | |
| } | |
| if cfg!(target_os = "macos") { | |
| Command::new("open").arg(path).spawn()?; | |
| return Ok(()); | |
| } | |
| Command::new("xdg-open").arg(path).spawn()?; | |
| Ok(()) | |
| } | |
| fn prepare_output_path(manager: &BookManager, fmt: &str) -> std::io::Result<PathBuf> { | |
| let raw_name = if manager.book_name.is_empty() { | |
| "book" | |
| } else { | |
| manager.book_name.as_str() | |
| }; | |
| let safe_book = safe_fs_name(raw_name, "_", 120); | |
| let dir = manager.default_save_dir(); | |
| std::fs::create_dir_all(&dir)?; | |
| // bulk_files: TXT 每章一个文件,输出到"小说名"文件夹 | |
| if fmt == "txt" && manager.config.bulk_files { | |
| return Ok(dir.join(&safe_book)); | |
| } | |
| let suffix = match fmt { | |
| "epub" => "epub", | |
| "pdf" => "pdf", | |
| _ => "txt", | |
| }; | |
| let output_path = dir.join(format!("{}.{}", safe_book, suffix)); | |
| // 检查文件是否已存在且不允许覆盖 | |
| if !manager.config.allow_overwrite_files && output_path.exists() { | |
| return Err(std::io::Error::new( | |
| std::io::ErrorKind::AlreadyExists, | |
| format!("文件已存在且配置禁止覆盖: {}", output_path.display()), | |
| )); | |
| } | |
| Ok(output_path) | |
| } | |
| fn archive_previous_main_outputs(manager: &BookManager, output_path: &Path) { | |
| if manager.previous_book_names.is_empty() { | |
| return; | |
| } | |
| let Some(dir) = output_path.parent() else { | |
| return; | |
| }; | |
| let current_name = output_path | |
| .file_name() | |
| .and_then(|s| s.to_str()) | |
| .unwrap_or(""); | |
| let current_base = output_path | |
| .file_stem() | |
| .and_then(|s| s.to_str()) | |
| .unwrap_or(current_name); | |
| let output_path = output_path.to_path_buf(); | |
| let mut seen = HashSet::new(); | |
| for old_name in &manager.previous_book_names { | |
| let safe_old = safe_fs_name(old_name, "_", 120); | |
| if safe_old.is_empty() || safe_old == current_base || !seen.insert(safe_old.clone()) { | |
| continue; | |
| } | |
| let candidates = [ | |
| (dir.join(format!("{safe_old}.epub")), "旧 EPUB"), | |
| (dir.join(format!("{safe_old}.txt")), "旧 TXT"), | |
| (dir.join(format!("{safe_old}.pdf")), "旧 PDF"), | |
| (dir.join(&safe_old), "旧 bulk TXT 目录"), | |
| ]; | |
| for (candidate, label) in candidates { | |
| if candidate == output_path || (!candidate.is_file() && !candidate.is_dir()) { | |
| continue; | |
| } | |
| archive_candidate(dir, &candidate, label); | |
| } | |
| } | |
| } | |
| fn archive_previous_audiobook_outputs(manager: &BookManager) { | |
| if manager.previous_book_names.is_empty() { | |
| return; | |
| } | |
| let dir = manager.default_save_dir(); | |
| let current_name = if manager.book_name.trim().is_empty() { | |
| manager.book_id.as_str() | |
| } else { | |
| manager.book_name.as_str() | |
| }; | |
| let current_safe = safe_fs_name(current_name, "_", 120); | |
| let current_audio_dir = dir.join(format!("{current_safe}_audio")); | |
| let mut seen = HashSet::new(); | |
| for old_name in &manager.previous_book_names { | |
| let safe_old = safe_fs_name(old_name, "_", 120); | |
| if safe_old.is_empty() || safe_old == current_safe || !seen.insert(safe_old.clone()) { | |
| continue; | |
| } | |
| let candidate = dir.join(format!("{safe_old}_audio")); | |
| if candidate == current_audio_dir || !candidate.is_dir() { | |
| continue; | |
| } | |
| archive_candidate(&dir, &candidate, "旧有声书目录"); | |
| } | |
| } | |
| fn archive_candidate(base_dir: &Path, candidate: &Path, label: &str) { | |
| let olds_dir = base_dir.join("olds"); | |
| if candidate == olds_dir { | |
| return; | |
| } | |
| if let Err(e) = fs::create_dir_all(&olds_dir) { | |
| warn!(target: "book_manager", error = ?e, dir = %olds_dir.display(), "创建 olds 目录失败,跳过旧输出归档"); | |
| return; | |
| } | |
| let archive_path = unique_archive_path(&olds_dir, candidate); | |
| match fs::rename(candidate, &archive_path) { | |
| Ok(_) => info!( | |
| target: "book_manager", | |
| kind = label, | |
| old = %candidate.display(), | |
| archived = %archive_path.display(), | |
| "检测到书名变化,已归档旧输出" | |
| ), | |
| Err(e) => warn!( | |
| target: "book_manager", | |
| kind = label, | |
| error = ?e, | |
| old = %candidate.display(), | |
| archived = %archive_path.display(), | |
| "旧输出归档失败" | |
| ), | |
| } | |
| } | |
| fn unique_archive_path(olds_dir: &Path, src: &Path) -> PathBuf { | |
| let file_name = src | |
| .file_name() | |
| .and_then(|s| s.to_str()) | |
| .unwrap_or("old.epub"); | |
| let first = olds_dir.join(file_name); | |
| if !first.exists() { | |
| return first; | |
| } | |
| let stem = src.file_stem().and_then(|s| s.to_str()).unwrap_or("old"); | |
| let ext = src.extension().and_then(|s| s.to_str()); | |
| for idx in 1.. { | |
| let candidate = match ext { | |
| Some(ext) if !ext.is_empty() => olds_dir.join(format!("{stem}_{idx}.{ext}")), | |
| _ => olds_dir.join(format!("{stem}_{idx}")), | |
| }; | |
| if !candidate.exists() { | |
| return candidate; | |
| } | |
| } | |
| unreachable!() | |
| } | |
| fn finalize_txt( | |
| manager: &BookManager, | |
| chapters: &[Value], | |
| path: &Path, | |
| directory_raw: Option<&Value>, | |
| ) -> anyhow::Result<()> { | |
| let volume_title_by_chapter_id = volume_title_map_for_chapters(chapters, directory_raw); | |
| if manager.config.bulk_files { | |
| std::fs::create_dir_all(path)?; | |
| let mut inserted_volumes: HashSet<String> = HashSet::new(); | |
| // 书籍信息 | |
| let mut meta = File::create(path.join("0000_书籍信息.txt"))?; | |
| writeln!(meta, "书名:{}", manager.book_name)?; | |
| if !manager.author.trim().is_empty() { | |
| writeln!(meta, "作者:{}", manager.author)?; | |
| } | |
| writeln!(meta, "book_id={}", manager.book_id)?; | |
| let status_text = match manager.finished { | |
| Some(true) => "完结", | |
| Some(false) => "连载", | |
| None => "未知", | |
| }; | |
| writeln!(meta, "状态:{}", status_text)?; | |
| if let Some(score) = manager.score { | |
| writeln!(meta, "评分:{:.1}", score)?; | |
| } | |
| if let Some(word_count) = manager.word_count { | |
| writeln!(meta, "字数:{}", word_count)?; | |
| } | |
| if let Some(chapter_count) = manager.chapter_count { | |
| writeln!(meta, "章节:{}", chapter_count)?; | |
| } | |
| if let Some(category) = manager.category.as_deref() | |
| && !category.trim().is_empty() | |
| { | |
| writeln!(meta, "分类:{}", category.trim())?; | |
| } | |
| if !manager.tags.trim().is_empty() { | |
| writeln!(meta, "标签:{}", manager.tags)?; | |
| } | |
| if let Some(read_count_text) = manager.read_count_text.as_deref() | |
| && !read_count_text.trim().is_empty() | |
| { | |
| writeln!(meta, "在读:{}", read_count_text.trim())?; | |
| } | |
| if !manager.description.trim().is_empty() { | |
| writeln!(meta)?; | |
| writeln!(meta, "简介:")?; | |
| writeln!(meta, "{}", manager.description.trim())?; | |
| } | |
| // 章节拆分 | |
| let width = chapters.len().to_string().len().max(4); | |
| for (idx, ch) in chapters.iter().enumerate() { | |
| let chapter_id = ch.get("id").and_then(|v| v.as_str()).unwrap_or(""); | |
| let title = ch.get("title").and_then(|v| v.as_str()).unwrap_or("章节"); | |
| let content = ch.get("content").and_then(|v| v.as_str()).unwrap_or(""); | |
| // 缓存为 XHTML,写入 txt 时实时清洗为纯文本 | |
| let content = ContentParser::clean_plain(content, title); | |
| let safe_title = safe_fs_name(title, "_", 120); | |
| let filename = format!( | |
| "{num:0width$}_{title}.txt", | |
| num = idx + 1, | |
| width = width, | |
| title = safe_title | |
| ); | |
| let mut f = File::create(path.join(filename))?; | |
| if let Some(vol) = volume_title_by_chapter_id.get(chapter_id) | |
| && !vol.trim().is_empty() | |
| && inserted_volumes.insert(vol.trim().to_string()) | |
| { | |
| writeln!(f, "分卷:{}", vol.trim())?; | |
| writeln!(f)?; | |
| } | |
| writeln!(f, "{}", title)?; | |
| writeln!(f)?; | |
| writeln!(f, "{}", content.trim_end())?; | |
| } | |
| return Ok(()); | |
| } | |
| let mut f = File::create(path)?; | |
| writeln!(f, "书名:{}", manager.book_name)?; | |
| if !manager.author.trim().is_empty() { | |
| writeln!(f, "作者:{}", manager.author)?; | |
| } | |
| writeln!(f, "book_id={}", manager.book_id)?; | |
| let status_text = match manager.finished { | |
| Some(true) => "完结", | |
| Some(false) => "连载", | |
| None => "未知", | |
| }; | |
| writeln!(f, "状态:{}", status_text)?; | |
| if let Some(score) = manager.score { | |
| writeln!(f, "评分:{:.1}", score)?; | |
| } | |
| if let Some(word_count) = manager.word_count { | |
| writeln!(f, "字数:{}", word_count)?; | |
| } | |
| if let Some(chapter_count) = manager.chapter_count { | |
| writeln!(f, "章节:{}", chapter_count)?; | |
| } | |
| if let Some(category) = manager.category.as_deref() | |
| && !category.trim().is_empty() | |
| { | |
| writeln!(f, "分类:{}", category.trim())?; | |
| } | |
| if !manager.tags.trim().is_empty() { | |
| writeln!(f, "标签:{}", manager.tags)?; | |
| } | |
| if let Some(read_count_text) = manager.read_count_text.as_deref() | |
| && !read_count_text.trim().is_empty() | |
| { | |
| writeln!(f, "在读:{}", read_count_text.trim())?; | |
| } | |
| if !manager.description.trim().is_empty() { | |
| writeln!(f)?; | |
| writeln!(f, "简介:")?; | |
| writeln!(f, "{}", manager.description.trim())?; | |
| } | |
| writeln!(f)?; | |
| writeln!(f, "{}", "=".repeat(40))?; | |
| writeln!(f)?; | |
| let mut last_volume: Option<String> = None; | |
| for ch in chapters { | |
| let chapter_id = ch.get("id").and_then(|v| v.as_str()).unwrap_or(""); | |
| let title = ch.get("title").and_then(|v| v.as_str()).unwrap_or("章节"); | |
| let content = ch.get("content").and_then(|v| v.as_str()).unwrap_or(""); | |
| // 缓存为 XHTML,写入 txt 时实时清洗为纯文本 | |
| let content = ContentParser::clean_plain(content, title); | |
| if let Some(vol) = volume_title_by_chapter_id.get(chapter_id) | |
| && !vol.trim().is_empty() | |
| && last_volume.as_deref() != Some(vol.trim()) | |
| { | |
| writeln!(f, "【{}】\n", vol.trim())?; | |
| last_volume = Some(vol.trim().to_string()); | |
| } | |
| writeln!(f, "{}\n", title)?; | |
| writeln!(f, "{}\n", content.trim_end())?; | |
| writeln!(f, "\n----------------------------------------\n")?; | |
| } | |
| Ok(()) | |
| } | |
| pub(super) fn volume_title_map_for_chapters( | |
| chapters: &[Value], | |
| directory_raw: Option<&Value>, | |
| ) -> HashMap<String, String> { | |
| let Some(raw) = directory_raw else { | |
| return HashMap::new(); | |
| }; | |
| let known_chapter_ids: HashSet<String> = chapters | |
| .iter() | |
| .filter_map(|ch| ch.get("id").and_then(|v| v.as_str()).map(|s| s.to_string())) | |
| .collect(); | |
| if known_chapter_ids.is_empty() { | |
| return HashMap::new(); | |
| } | |
| let volumes = extract_volume_to_chapter_ids(raw, &known_chapter_ids); | |
| let mut volume_order: Vec<String> = Vec::new(); | |
| let mut volume_title_by_chapter_id: HashMap<String, String> = HashMap::new(); | |
| for (title, ids) in &volumes { | |
| let t = title.trim(); | |
| if t.is_empty() || ids.is_empty() { | |
| continue; | |
| } | |
| if !volume_order.contains(&t.to_string()) { | |
| volume_order.push(t.to_string()); | |
| } | |
| for id in ids { | |
| volume_title_by_chapter_id | |
| .entry(id.clone()) | |
| .or_insert_with(|| t.to_string()); | |
| } | |
| } | |
| let skip_default_single = | |
| volume_order.len() == 1 && is_default_volume_name(volume_order[0].as_str()); | |
| if skip_default_single { | |
| return HashMap::new(); | |
| } | |
| volume_title_by_chapter_id | |
| } | |
| fn is_default_volume_name(name: &str) -> bool { | |
| let s = name.trim(); | |
| if s.is_empty() { | |
| return true; | |
| } | |
| let normalized: String = s | |
| .chars() | |
| .map(|c| match c { | |
| ':' | ':' | '—' | '–' | '_' | '·' | '|' | '|' => ' ', | |
| _ => c, | |
| }) | |
| .collect(); | |
| let normalized = normalized.split_whitespace().collect::<Vec<_>>().join(" "); | |
| let lower = normalized.to_lowercase(); | |
| let exact = [ | |
| "默认", | |
| "默认卷", | |
| "默认分卷", | |
| "第一卷", | |
| "第1卷", | |
| "卷一", | |
| "卷1", | |
| "第一卷 默认", | |
| "第1卷 默认", | |
| ]; | |
| for e in exact { | |
| if lower == e { | |
| return true; | |
| } | |
| } | |
| let lower_ascii = lower.replace(['.', '_'], " "); | |
| let lower_ascii = lower_ascii.trim(); | |
| if lower_ascii == "volume 1" || lower_ascii == "vol 1" || lower_ascii == "vol1" { | |
| return true; | |
| } | |
| false | |
| } | |
| fn extract_volume_to_chapter_ids( | |
| directory_raw: &Value, | |
| known_chapter_ids: &HashSet<String>, | |
| ) -> Vec<(String, Vec<String>)> { | |
| fn pick_string_or_number(v: Option<&Value>) -> Option<String> { | |
| match v { | |
| Some(Value::String(s)) => { | |
| let t = s.trim(); | |
| if t.is_empty() { | |
| None | |
| } else { | |
| Some(t.to_string()) | |
| } | |
| } | |
| Some(Value::Number(n)) => Some(n.to_string()), | |
| _ => None, | |
| } | |
| } | |
| fn pick_volume_title(obj: &serde_json::Map<String, Value>) -> Option<String> { | |
| let candidates = [ | |
| "volume_title", | |
| "volume_name", | |
| "section_title", | |
| "section_name", | |
| "group_title", | |
| "group_name", | |
| ]; | |
| for k in candidates { | |
| if let Some(Value::String(s)) = obj.get(k) { | |
| let t = s.trim(); | |
| if !t.is_empty() { | |
| return Some(t.to_string()); | |
| } | |
| } | |
| } | |
| None | |
| } | |
| fn pick_title(obj: &serde_json::Map<String, Value>) -> Option<String> { | |
| let candidates = [ | |
| "volume_title", | |
| "volume_name", | |
| "catalog_name", | |
| "catalog_title", | |
| "section_title", | |
| "section_name", | |
| "group_title", | |
| "group_name", | |
| "title", | |
| "name", | |
| ]; | |
| for k in candidates { | |
| if let Some(Value::String(s)) = obj.get(k) { | |
| let t = s.trim(); | |
| if !t.is_empty() { | |
| return Some(t.to_string()); | |
| } | |
| } | |
| } | |
| None | |
| } | |
| fn looks_like_chapter_obj(obj: &serde_json::Map<String, Value>) -> Option<String> { | |
| let id = pick_string_or_number( | |
| obj.get("chapter_id") | |
| .or_else(|| obj.get("item_id")) | |
| .or_else(|| obj.get("catalog_id")) | |
| .or_else(|| obj.get("id")), | |
| )?; | |
| Some(id) | |
| } | |
| fn push_chapter( | |
| title: &str, | |
| chapter_id: &str, | |
| known_chapter_ids: &HashSet<String>, | |
| idx_by_title: &mut HashMap<String, usize>, | |
| out: &mut Vec<(String, Vec<String>)>, | |
| ) { | |
| let t = title.trim(); | |
| if t.is_empty() { | |
| return; | |
| } | |
| if !known_chapter_ids.contains(chapter_id) { | |
| return; | |
| } | |
| let i = if let Some(i) = idx_by_title.get(t) { | |
| *i | |
| } else { | |
| let i = out.len(); | |
| out.push((t.to_string(), Vec::new())); | |
| idx_by_title.insert(t.to_string(), i); | |
| i | |
| }; | |
| let ids = &mut out[i].1; | |
| if ids.last().is_some_and(|last| last == chapter_id) { | |
| return; | |
| } | |
| if !ids.contains(&chapter_id.to_string()) { | |
| ids.push(chapter_id.to_string()); | |
| } | |
| } | |
| fn visit( | |
| v: &Value, | |
| current_volume: Option<&str>, | |
| known_chapter_ids: &HashSet<String>, | |
| idx_by_title: &mut HashMap<String, usize>, | |
| out: &mut Vec<(String, Vec<String>)>, | |
| ) { | |
| match v { | |
| Value::Array(arr) => { | |
| for it in arr { | |
| visit(it, current_volume, known_chapter_ids, idx_by_title, out); | |
| } | |
| } | |
| Value::Object(obj) => { | |
| let is_chapter = looks_like_chapter_obj(obj).is_some(); | |
| if let Some(vol) = pick_volume_title(obj) | |
| && let Some(id) = looks_like_chapter_obj(obj) | |
| { | |
| push_chapter(&vol, &id, known_chapter_ids, idx_by_title, out); | |
| } | |
| if let Some(vol) = current_volume | |
| && let Some(id) = looks_like_chapter_obj(obj) | |
| { | |
| push_chapter(vol, &id, known_chapter_ids, idx_by_title, out); | |
| } | |
| let title_here = if is_chapter { | |
| pick_volume_title(obj) | |
| } else { | |
| pick_title(obj) | |
| }; | |
| let next_volume = title_here.as_deref().or(current_volume); | |
| let child_keys = [ | |
| "catalog_data", | |
| "item_data_list", | |
| "items", | |
| "item_list", | |
| "children", | |
| "child_list", | |
| "sub_items", | |
| "sub_item_list", | |
| "chapter_list", | |
| "chapters", | |
| "chapter_ids", | |
| ]; | |
| for k in child_keys { | |
| if let Some(arr) = obj.get(k).and_then(Value::as_array) { | |
| for child in arr { | |
| if let (Some(vol), Some(id)) = | |
| (next_volume, pick_string_or_number(Some(child))) | |
| { | |
| push_chapter(vol, &id, known_chapter_ids, idx_by_title, out); | |
| } else { | |
| visit(child, next_volume, known_chapter_ids, idx_by_title, out); | |
| } | |
| } | |
| } | |
| } | |
| for (k, vv) in obj { | |
| if child_keys.contains(&k.as_str()) { | |
| continue; | |
| } | |
| visit(vv, next_volume, known_chapter_ids, idx_by_title, out); | |
| } | |
| } | |
| _ => {} | |
| } | |
| } | |
| let mut out: Vec<(String, Vec<String>)> = Vec::new(); | |
| let mut idx_by_title: HashMap<String, usize> = HashMap::new(); | |
| if let Some(items) = directory_raw | |
| .get("item_data_list") | |
| .and_then(Value::as_array) | |
| { | |
| let mut current: Option<String> = None; | |
| for it in items { | |
| let Some(obj) = it.as_object() else { | |
| continue; | |
| }; | |
| if let Some(vol) = pick_volume_title(obj) { | |
| current = Some(vol); | |
| } | |
| let Some(id) = looks_like_chapter_obj(obj) else { | |
| continue; | |
| }; | |
| if let Some(vol) = current.as_deref() { | |
| push_chapter(vol, &id, known_chapter_ids, &mut idx_by_title, &mut out); | |
| } | |
| } | |
| if !out.is_empty() { | |
| return out; | |
| } | |
| } | |
| visit( | |
| directory_raw, | |
| None, | |
| known_chapter_ids, | |
| &mut idx_by_title, | |
| &mut out, | |
| ); | |
| out | |
| } | |
| /// 下载完后让用户选择使用哪个书名(仅 CLI 模式)。 | |
| /// | |
| /// 从 `BookManager` 收集所有可用的书名变体,展示编号让用户选择。 | |
| /// 返回 `Some(chosen_name)` 表示用户选了新名字,`None` 表示保持默认。 | |
| fn prompt_book_name_selection(manager: &BookManager) -> Option<String> { | |
| // 收集可用的书名选项(去重) | |
| let mut options: Vec<(&str, String)> = Vec::new(); | |
| let default_name = &manager.book_name; | |
| options.push(("默认书名", default_name.clone())); | |
| if let Some(orig) = &manager.original_book_name | |
| && !orig.is_empty() | |
| && orig != default_name | |
| { | |
| options.push(("原始书名", orig.clone())); | |
| } | |
| if let Some(short) = &manager.book_short_name | |
| && !short.is_empty() | |
| && short != default_name | |
| { | |
| // 也和 original_book_name 去重 | |
| let dup = manager | |
| .original_book_name | |
| .as_ref() | |
| .is_some_and(|o| o == short); | |
| if !dup { | |
| options.push(("短书名", short.clone())); | |
| } | |
| } | |
| // 如果只有一个选项就不需要询问 | |
| if options.len() <= 1 { | |
| return None; | |
| } | |
| println!("\n=== 选择书名 ==="); | |
| for (idx, (label, name)) in options.iter().enumerate() { | |
| let marker = if idx == 0 { " (当前)" } else { "" }; | |
| println!(" {}. {}: {}{}", idx + 1, label, name, marker); | |
| } | |
| print!("请选择 [1]: "); | |
| io::stdout().flush().ok(); | |
| let mut line = String::new(); | |
| if io::stdin().lock().read_line(&mut line).is_err() { | |
| return None; | |
| } | |
| let choice = line.trim(); | |
| let idx = if choice.is_empty() { | |
| 1 | |
| } else { | |
| match choice.parse::<usize>() { | |
| Ok(i) if i >= 1 && i <= options.len() => i, | |
| _ => return None, | |
| } | |
| }; | |
| let chosen = options[idx - 1].1.clone(); | |
| if chosen == *default_name { | |
| return None; | |
| } | |
| Some(chosen) | |
| } | |
| /// CLI 模式下询问用户选择输出格式。 | |
| /// 返回对应的格式值,`None` 表示保持默认。 | |
| fn prompt_format_selection(manager: &BookManager) -> Option<String> { | |
| let current = manager.config.configured_output_format_choice(); | |
| let options = downloader::collect_output_format_options(); | |
| println!("\n=== 选择输出格式 ==="); | |
| for (idx, opt) in options.iter().enumerate() { | |
| let marker = if opt.value == current { | |
| " (当前)" | |
| } else { | |
| "" | |
| }; | |
| println!(" {}. {}{}", idx + 1, opt.label, marker); | |
| } | |
| let default_idx = options | |
| .iter() | |
| .position(|opt| opt.value == current) | |
| .unwrap_or(0) | |
| + 1; | |
| print!("请选择 [{}]: ", default_idx); | |
| io::stdout().flush().ok(); | |
| let mut line = String::new(); | |
| if io::stdin().lock().read_line(&mut line).is_err() { | |
| return None; | |
| } | |
| let choice = line.trim(); | |
| let idx = if choice.is_empty() { | |
| default_idx | |
| } else { | |
| match choice.parse::<usize>() { | |
| Ok(i) if i >= 1 && i <= options.len() => i, | |
| _ => return None, | |
| } | |
| }; | |
| let chosen = options[idx - 1].value.clone(); | |
| if chosen == current { | |
| return None; | |
| } | |
| Some(chosen) | |
| } | |
| mod tests { | |
| use super::{ | |
| archive_previous_audiobook_outputs, archive_previous_main_outputs, prepare_output_path, | |
| }; | |
| use crate::base_system::context::Config; | |
| use crate::book_parser::book_manager::BookManager; | |
| fn archive_previous_named_outputs_when_book_name_changes() { | |
| let temp_dir = tempfile::tempdir().unwrap(); | |
| let mut config = Config::default(); | |
| config.save_path = temp_dir.path().display().to_string(); | |
| config.novel_format = "epub".to_string(); | |
| let mut manager = BookManager::new(config, "123", "新书名").unwrap(); | |
| manager.book_id = "123".to_string(); | |
| manager.book_name = "新书名".to_string(); | |
| manager.remember_previous_book_name("旧书名"); | |
| let old_epub = temp_dir.path().join("旧书名.epub"); | |
| let old_txt = temp_dir.path().join("旧书名.txt"); | |
| let old_pdf = temp_dir.path().join("旧书名.pdf"); | |
| let old_bulk = temp_dir.path().join("旧书名"); | |
| std::fs::write(&old_epub, b"old").unwrap(); | |
| std::fs::write(&old_txt, b"old txt").unwrap(); | |
| std::fs::write(&old_pdf, b"old pdf").unwrap(); | |
| std::fs::create_dir_all(&old_bulk).unwrap(); | |
| std::fs::write(old_bulk.join("0001.txt"), b"old bulk").unwrap(); | |
| let output_path = prepare_output_path(&manager, "epub").unwrap(); | |
| assert_eq!(output_path, temp_dir.path().join("新书名.epub")); | |
| std::fs::write(&output_path, b"new").unwrap(); | |
| archive_previous_main_outputs(&manager, &output_path); | |
| assert!(output_path.exists()); | |
| assert!(!old_epub.exists()); | |
| assert!(!old_txt.exists()); | |
| assert!(!old_pdf.exists()); | |
| assert!(!old_bulk.exists()); | |
| assert!(temp_dir.path().join("olds").join("旧书名.epub").exists()); | |
| assert!(temp_dir.path().join("olds").join("旧书名.txt").exists()); | |
| assert!(temp_dir.path().join("olds").join("旧书名.pdf").exists()); | |
| assert!(temp_dir.path().join("olds").join("旧书名").exists()); | |
| } | |
| fn keep_same_name_outputs_on_normal_overwrite_path() { | |
| let temp_dir = tempfile::tempdir().unwrap(); | |
| let mut config = Config::default(); | |
| config.save_path = temp_dir.path().display().to_string(); | |
| config.novel_format = "epub".to_string(); | |
| let mut manager = BookManager::new(config, "123", "同书名").unwrap(); | |
| manager.book_id = "123".to_string(); | |
| manager.book_name = "同书名".to_string(); | |
| manager.remember_previous_book_name("同书名"); | |
| let output_path = prepare_output_path(&manager, "epub").unwrap(); | |
| std::fs::write(&output_path, b"new").unwrap(); | |
| archive_previous_main_outputs(&manager, &output_path); | |
| assert!(output_path.exists()); | |
| assert!(!temp_dir.path().join("olds").exists()); | |
| } | |
| fn archive_previous_audiobook_dir_when_book_name_changes() { | |
| let temp_dir = tempfile::tempdir().unwrap(); | |
| let mut config = Config::default(); | |
| config.save_path = temp_dir.path().display().to_string(); | |
| config.enable_audiobook = true; | |
| let mut manager = BookManager::new(config, "123", "新书名").unwrap(); | |
| manager.book_id = "123".to_string(); | |
| manager.book_name = "新书名".to_string(); | |
| manager.remember_previous_book_name("旧书名"); | |
| let old_audio = temp_dir.path().join("旧书名_audio"); | |
| let current_audio = temp_dir.path().join("新书名_audio"); | |
| std::fs::create_dir_all(&old_audio).unwrap(); | |
| std::fs::write(old_audio.join("0001.mp3"), b"old audio").unwrap(); | |
| std::fs::create_dir_all(¤t_audio).unwrap(); | |
| archive_previous_audiobook_outputs(&manager); | |
| assert!(current_audio.exists()); | |
| assert!(!old_audio.exists()); | |
| assert!(temp_dir.path().join("olds").join("旧书名_audio").exists()); | |
| } | |
| } | |