use rusqlite::Connection;
use anyhow::Result;
use horae_core::model::backup::BackupData;
use horae_core::repo;
use std::path::Path;
pub fn run_export(conn: &Connection, file: Option<&str>) -> Result<()> {
let data = repo::backup::export_all(conn)?;
let json = repo::backup::to_json(&data)?;
let path = match file {
Some(p) => p.to_string(),
None => {
let date = horae_core::time::format_local(Some(horae_core::time::now_ms()))
.replace(' ', "T")
.replace(':', "");
format!("horae-backup-{}.json", &date[..10])
}
};
std::fs::write(&path, json)?;
println!(
"exported {} tasks / {} events / {} tags -> {}",
data.tasks.len(),
data.events.len(),
data.tags.len(),
path
);
Ok(())
}
pub fn run_import(conn: &Connection, file: &str, replace: bool) -> Result<()> {
if !Path::new(file).exists() {
anyhow::bail!("backup file not found: {}", file);
}
let content = std::fs::read_to_string(file)?;
let data: BackupData = repo::backup::from_json(&content)?;
if replace {
println!(
"replacing current database with backup ({:?}) ...",
Path::new(file)
);
}
let stats = repo::backup::import_all(conn, &data, replace)?;
println!(
"imported: {} tasks created, {} skipped, {} events, {} tags created, \
{} tag links, {} settings{}",
stats.tasks_created,
stats.tasks_skipped,
stats.events_imported,
stats.tags_created,
stats.task_links,
stats.settings_imported,
if stats.pomo_restored {
", pomodoro state restored"
} else {
""
}
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use horae_core::repo::state::set_test_override;
use horae_core::repo::{tags, tasks};
use horae_core::testutil::test_conn;
fn seed(conn: &Connection) -> (String, String) {
let t = tasks::create_capture(
conn,
&tasks::CaptureInput {
title: "买牛奶".into(),
status: horae_core::model::task::Status::Next,
tag_names: vec!["home".into(), "errands".into()],
..Default::default()
},
)
.unwrap();
tags::add_tag_to_task(conn, &t.id, "custom").unwrap();
let done = tasks::create_capture(
conn,
&tasks::CaptureInput {
title: "写周报".into(),
status: horae_core::model::task::Status::Next,
..Default::default()
},
)
.unwrap();
tasks::transition(conn, &done.id, horae_core::model::task::Status::Done).unwrap();
tasks::archive(conn, &done.id).unwrap();
(t.id, done.id)
}
fn export_roundtrip(conn: &Connection) -> BackupData {
let data = repo::backup::export_all(conn).unwrap();
repo::backup::from_json(&repo::backup::to_json(&data).unwrap()).unwrap()
}
#[test]
fn roundtrip_preserves_everything() {
let (_dir, conn) = test_conn();
set_test_override();
let (live, archived) = seed(&conn);
let data = export_roundtrip(&conn);
let (_dir2, conn2) = test_conn();
set_test_override();
let stats = repo::backup::import_all(&conn2, &data, false).unwrap();
assert_eq!(
stats.tasks_skipped, 1,
"空库合并不应跳过除系统内置(__journal__)外的任何任务"
);
assert_eq!(stats.events_imported, data.events.len());
let live_t = tasks::get(&conn2, &live).unwrap();
assert_eq!(live_t.title, "买牛奶");
let tags = tags::get_task_tags(&conn2, &live).unwrap();
let names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
assert!(names.contains(&"home") && names.contains(&"custom"));
let archived_t = tasks::get(&conn2, &archived).unwrap();
assert_eq!(archived_t.status, horae_core::model::task::Status::Done);
assert_eq!(archived_t.archive_reason.as_deref(), Some("completed"));
let events = tasks::events(&conn2, &archived).unwrap();
assert!(
events
.iter()
.any(|e| e.event_type == horae_core::model::event::EV_COMPLETED),
"completed 事件被还原"
);
assert!(events
.iter()
.any(|e| e.event_type == horae_core::model::event::EV_ARCHIVED));
}
#[test]
fn merge_skips_existing_and_adds_new() {
let (_dir, conn) = test_conn();
set_test_override();
seed(&conn);
let data = export_roundtrip(&conn);
let extra = tasks::create_capture(
&conn,
&tasks::CaptureInput {
title: "额外任务".into(),
status: horae_core::model::task::Status::Inbox,
..Default::default()
},
)
.unwrap();
let stats = repo::backup::import_all(&conn, &data, false).unwrap();
assert_eq!(stats.tasks_created, 0);
assert_eq!(stats.tasks_skipped, data.tasks.len());
let all = tasks::list(
&conn,
&tasks::ListFilter {
status: None,
tags: vec![],
query: None,
review_stale: false,
},
)
.unwrap();
let titles: Vec<&str> = all.iter().map(|t| t.title.as_str()).collect();
assert!(titles.contains(&"买牛奶") && titles.contains(&extra.title.as_str()));
assert!(!titles.contains(&"写周报"), "归档任务不出现,未被重复导入");
}
#[test]
fn replace_restores_exactly() {
let (_dir, conn) = test_conn();
set_test_override();
let (live, _archived) = seed(&conn);
let data = export_roundtrip(&conn);
tasks::archive(&conn, &live).unwrap();
tasks::purge(&conn, &live).unwrap();
assert!(tasks::get(&conn, &live).is_err(), "当前库已删除该任务");
let stats = repo::backup::import_all(&conn, &data, true).unwrap();
assert_eq!(stats.tasks_skipped, 0, "replace 模式下无任务可跳过");
assert_eq!(stats.tasks_created, data.tasks.len());
assert!(tasks::get(&conn, &live).is_ok(), "任务被还原");
assert_eq!(
tasks::get(&conn, &live).unwrap().status,
horae_core::model::task::Status::Next,
"还原到备份时的状态"
);
let tags = tags::get_task_tags(&conn, &live).unwrap();
let names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
assert!(names.contains(&"home") && names.contains(&"custom"));
}
#[test]
fn rejects_unknown_version() {
let (_dir, conn) = test_conn();
let mut data = export_roundtrip(&conn);
data.version = 999;
let err = repo::backup::import_all(&conn, &data, false).unwrap_err();
assert!(err.to_string().contains("version"), "{}", err);
let mut data2 = export_roundtrip(&conn);
data2.format = "nope".into();
let err2 = repo::backup::import_all(&conn, &data2, false).unwrap_err();
assert!(err2.to_string().contains("format"), "{}", err2);
}
#[test]
fn import_missing_file_errors() {
let (_dir, conn) = test_conn();
let err = run_import(&conn, "/nonexistent/horae.json", false).unwrap_err();
assert!(err.to_string().contains("not found"), "{}", err);
}
}