use crate::{config::AppConfig, paths::AppPaths};
use anyhow::{Context, Result};
use serde::de::DeserializeOwned;
use std::{
fs::{self, File, OpenOptions},
io::{ErrorKind, Write},
path::Path,
};
const MARKER: &str = ".legacy-migration-v1";
const CLEARED: &str = ".legacy-reading-cleared-v1";
pub fn clear(paths: &AppPaths) -> Result<()> {
directory(paths.root(), paths.root())?;
if file_type(&paths.data())?.is_some() {
directory(&paths.data(), paths.root())?;
}
let lock = lock(&paths.root().join(".migration.lock"))?;
lock.lock()?;
publish(
&paths.root().join(CLEARED),
b"legacy reading data cleared v1\n",
)?;
paths.clear()?;
Ok(())
}
pub async fn startup() {
let result = tokio::task::spawn_blocking(|| {
let paths = AppPaths::user_default()?;
migrate(&paths)
})
.await;
match result {
Ok(Ok(messages)) => {
for message in messages {
eprintln!("旧目录迁移:{message}(源文件保留,下次启动重试)");
}
}
Ok(Err(error)) => eprintln!("旧目录迁移失败:{error:#};源文件保留,继续启动"),
Err(error) => eprintln!("旧目录迁移未完成:{error};继续启动"),
}
}
fn migrate(paths: &AppPaths) -> Result<Vec<String>> {
let home = paths.root().parent().context("应用目录没有父目录")?;
let legacy = home.join(".novel");
let Some(kind) = file_type(&legacy)? else {
return Ok(Vec::new());
};
if !kind.is_dir() {
anyhow::bail!("跳过非普通旧目录 {}", legacy.display());
}
directory(paths.root(), paths.root())?;
let lock = lock(&paths.root().join(".migration.lock"))?;
lock.lock()?;
if file_type(&paths.root().join(MARKER))?.is_some() {
return Ok(Vec::new());
}
let mut messages = Vec::new();
let cleared = file_type(&paths.root().join(CLEARED))?.is_some();
for (source, destination) in [
("book_sources.json", paths.book_sources()),
("history.json", paths.history()),
("source-state", paths.source_state()),
("browser-profile", paths.browser_profile()),
("local", paths.local()),
("network", paths.network()),
("keybindings.toml", paths.keybindings()),
("toc_rules.json", paths.toc_rules()),
("gen-fontmap", paths.fontmap()),
] {
if cleared && matches!(source, "history.json" | "local" | "network" | "gen-fontmap") {
continue;
}
if let Err(error) = copy(&legacy.join(source), &destination, paths.root()) {
messages.push(format!("{source}: {error:#}"));
}
}
if let Err(error) = preferences(&legacy, paths) {
messages.push(format!("外观与阅读偏好: {error:#}"));
}
if messages.is_empty() {
publish(&paths.root().join(MARKER), b"legacy .novel migration v1\n")?;
}
Ok(messages)
}
fn file_type(path: &Path) -> std::io::Result<Option<fs::FileType>> {
match fs::symlink_metadata(path) {
Ok(metadata) => Ok(Some(metadata.file_type())),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
Err(error) => Err(error),
}
}
fn directory(path: &Path, root: &Path) -> Result<()> {
if path != root {
directory(path.parent().context("目录没有父目录")?, root)?;
}
match file_type(path)? {
Some(kind) if kind.is_dir() => Ok(()),
Some(_) => anyhow::bail!("目标不是普通目录 {}", path.display()),
None => match fs::create_dir(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == ErrorKind::AlreadyExists => directory(path, root),
Err(error) => Err(error.into()),
},
}
}
fn lock(path: &Path) -> Result<File> {
if file_type(path)?.is_some_and(|kind| !kind.is_file()) {
anyhow::bail!("锁不是普通文件 {}", path.display());
}
Ok(OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)?)
}
fn copy(source: &Path, destination: &Path, root: &Path) -> Result<()> {
let Some(kind) = file_type(source)? else {
return Ok(());
};
if kind.is_symlink() || (!kind.is_dir() && !kind.is_file()) {
eprintln!("旧目录迁移:跳过非普通文件 {}", source.display());
return Ok(());
}
if kind.is_dir() {
directory(destination.parent().context("目标没有父目录")?, root)?;
if file_type(destination)?.is_some_and(|kind| !kind.is_dir()) {
eprintln!(
"旧目录迁移:保留已有路径 {},跳过旧目录 {}",
destination.display(),
source.display()
);
return Ok(());
}
directory(destination, root)?;
let mut errors = Vec::new();
for entry in fs::read_dir(source)? {
match entry {
Ok(entry) => {
if let Err(error) =
copy(&entry.path(), &destination.join(entry.file_name()), root)
{
errors.push(format!("{}: {error:#}", entry.path().display()));
}
}
Err(error) => errors.push(error.to_string()),
}
}
if !errors.is_empty() {
anyhow::bail!("{}", errors.join(";"));
}
return Ok(());
}
let parent = destination.parent().context("目标没有父目录")?;
directory(parent, root)?;
if file_type(destination)?.is_some() {
eprintln!(
"旧目录迁移:保留新文件 {},未覆盖 {}",
destination.display(),
source.display()
);
return Ok(());
}
let mut source_file = File::open(source)?;
let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
std::io::copy(&mut source_file, temporary.as_file_mut())?;
if source
.extension()
.is_some_and(|extension| extension == "json")
{
validate_json(temporary.reopen()?, destination, root)
.with_context(|| format!("旧 JSON 损坏 {}", source.display()))?;
}
temporary
.as_file()
.set_permissions(source_file.metadata()?.permissions())?;
finish(temporary, destination)
}
fn validate_json(file: File, destination: &Path, root: &Path) -> Result<()> {
let value: serde_json::Value = serde_json::from_reader(file)?;
let data = root.join("data");
if destination == data.join("history.json") {
serde_json::from_value::<Vec<(String, crate::cache::HistoryItem)>>(
value
.get("histories")
.context("历史缺少 histories")?
.clone(),
)?;
if let Some(path) = value.get("local_path") {
serde_json::from_value::<Option<std::path::PathBuf>>(path.clone())?;
}
} else if destination == data.join("book_sources.json") {
let sources = value.get("book_sources").cloned().unwrap_or(value);
parse_book_source::BookSource::from_value_many(sources)
.map_err(parse_book_source::BookSourceError::from)?;
} else if destination.starts_with(data.join("local")) {
serde_json::from_value::<crate::cache::LocalNovelCache>(value)?;
} else if destination.starts_with(data.join("network")) {
serde_json::from_value::<crate::cache::NetworkNovelCache>(value)?;
}
Ok(())
}
fn finish(temporary: tempfile::NamedTempFile, destination: &Path) -> Result<()> {
temporary.as_file().sync_all()?;
match temporary.persist_noclobber(destination) {
Ok(_) => {
#[cfg(unix)]
File::open(destination.parent().context("目标没有父目录")?)?.sync_all()?;
Ok(())
}
Err(error) if error.error.kind() == ErrorKind::AlreadyExists => Ok(()),
Err(error) => Err(error.error.into()),
}
}
fn publish(destination: &Path, bytes: &[u8]) -> Result<()> {
let mut temporary =
tempfile::NamedTempFile::new_in(destination.parent().context("目标没有父目录")?)?;
temporary.write_all(bytes)?;
finish(temporary, destination)
}
fn section<T: DeserializeOwned>(path: &Path, names: &[(&str, &str)]) -> Result<Option<T>> {
let Some(kind) = file_type(path)? else {
return Ok(None);
};
if !kind.is_file() {
eprintln!("旧目录迁移:跳过非普通偏好文件 {}", path.display());
return Ok(None);
}
let mut value: serde_json::Value = serde_json::from_reader(File::open(path)?)
.with_context(|| format!("旧 JSON 损坏 {}", path.display()))?;
let fields = value.as_object_mut().context("旧偏好必须是 JSON 对象")?;
for (old, new) in names {
if let Some(value) = fields.remove(*old) {
fields.insert((*new).into(), value);
}
}
Ok(Some(serde_json::from_value(value)?))
}
fn preferences(legacy: &Path, paths: &AppPaths) -> Result<()> {
let config_lock = lock(&paths.config().with_extension("lock"))?;
config_lock.lock()?;
if file_type(&paths.config())?.is_some() {
eprintln!(
"旧目录迁移:保留已有配置 {},不合并旧偏好",
paths.config().display()
);
return Ok(());
}
let appearance = section(
&legacy.join("appearance.json"),
&[("themeSlug", "theme_slug"), ("showHeader", "show_header")],
)?;
let reader = section(
&legacy.join("reader-display.json"),
&[
("followTts", "follow_tts"),
("showTitle", "show_title"),
("pageOverlap", "page_overlap"),
("paragraphSpacing", "paragraph_spacing"),
],
)?;
let browser = file_type(&legacy.join("browser_assist.on"))?.is_some_and(|kind| kind.is_file());
if appearance.is_none() && reader.is_none() && !browser {
return Ok(());
}
let mut config = AppConfig::default();
if let Some(appearance) = appearance {
config.appearance = appearance;
}
if let Some(reader) = reader {
config.reader = reader;
}
config.reader.normalize();
config.browser.always_allow = browser;
publish(&paths.config(), toml::to_string_pretty(&config)?.as_bytes())
}
#[cfg(test)]
mod tests;