use anyhow::{Context, Result};
use gradatum_storage::{FileStorage, Storage as _};
use std::path::PathBuf;
use walkdir::WalkDir;
#[derive(Debug, Clone)]
pub struct DowngradeFromTrashArgs {
pub legacy_vault_path: PathBuf,
pub gradatum_root: PathBuf,
pub dry_run: bool,
pub limit: Option<usize>,
}
#[derive(Debug, Default)]
pub struct DowngradeStats {
pub trash_files_scanned: usize,
pub matched_in_gradatum: usize,
pub already_downgraded: usize,
pub downgraded: usize,
pub not_matched: usize,
}
fn strip_frontmatter(body: &str) -> &str {
if !body.starts_with("---\n") {
return body;
}
if let Some(end) = body[4..].find("\n---\n") {
return &body[4 + end + 5..];
}
body
}
pub async fn run(args: DowngradeFromTrashArgs) -> Result<DowngradeStats> {
let trash_dir = args.legacy_vault_path.join(".vault-trash");
if !trash_dir.exists() {
let stats = DowngradeStats::default();
eprintln!(
"info: .vault-trash absent ({}) — rien à migrer (idempotent)",
trash_dir.display()
);
eprintln!(
"downgrade-from-legacy-vault-trash: scanned={} matched={} already_downgraded={} downgraded={} not_matched={}",
stats.trash_files_scanned,
stats.matched_in_gradatum,
stats.already_downgraded,
stats.downgraded,
stats.not_matched
);
return Ok(stats);
}
let index_path = args.gradatum_root.join("vault/.gradatum/index.db");
if !index_path.exists() {
anyhow::bail!(
"index.db introuvable : {} — le worker doit avoir démarré au moins une fois",
index_path.display()
);
}
let trash_storage = FileStorage::new(&args.legacy_vault_path).with_context(|| {
format!(
"FileStorage init legacy_vault_path {}",
args.legacy_vault_path.display()
)
})?;
let mut stats = DowngradeStats::default();
let conn = rusqlite::Connection::open(&index_path).context("ouverture index.db")?;
let mut stmt = conn
.prepare("SELECT id, status FROM notes WHERE substr(body_text, 1, 200) = ?1 LIMIT 1")
.context("préparation requête match")?;
for entry in WalkDir::new(&trash_dir)
.min_depth(2)
.max_depth(10)
.into_iter()
.filter_map(|e| {
e.map_err(|err| {
eprintln!("[WARN] entrée inaccessible dans .vault-trash : {err}");
})
.ok()
})
{
if !entry.file_type().is_file() {
continue;
}
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
if let Some(limit) = args.limit {
if stats.downgraded >= limit {
break;
}
}
let date_dir_name = path
.strip_prefix(&trash_dir)
.unwrap_or(path)
.components()
.next()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.unwrap_or_else(|| "unknown".to_string());
let rel_path = match path.strip_prefix(&args.legacy_vault_path) {
Ok(r) => r.to_string_lossy().replace('\\', "/"),
Err(_) => {
eprintln!("[WARN] chemin hors legacy_vault_path : {}", path.display());
continue;
}
};
let body_bytes = match trash_storage.read(&rel_path).await {
Ok(b) => b,
Err(e) => {
eprintln!("[WARN] lecture impossible {} : {e}", path.display());
continue; }
};
let body = String::from_utf8_lossy(&body_bytes).into_owned();
stats.trash_files_scanned += 1;
let body_clean = strip_frontmatter(&body);
let needle: String = body_clean.chars().take(200).collect();
let row: Option<(String, String)> = stmt
.query_row(rusqlite::params![needle], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
})
.ok();
match row {
Some((id, status)) => {
stats.matched_in_gradatum += 1;
if status == "downgraded" {
stats.already_downgraded += 1;
continue;
}
if args.dry_run {
eprintln!(
"[DRY-RUN] would downgrade note_id={id} (file={})",
path.display()
);
stats.downgraded += 1;
continue;
}
let now = chrono::Utc::now().timestamp_millis();
let reason = format!("migrated from legacy-vault .vault-trash/{date_dir_name}/");
conn.execute(
"UPDATE notes \
SET status = 'downgraded', \
status_reason = ?2, \
status_changed = ?3, \
updated = ?3 \
WHERE id = ?1",
rusqlite::params![id, reason, now],
)
.context("UPDATE downgrade note")?;
stats.downgraded += 1;
}
None => {
stats.not_matched += 1;
}
}
}
eprintln!(
"downgrade-from-legacy-vault-trash: scanned={} matched={} already_downgraded={} downgraded={} not_matched={}",
stats.trash_files_scanned,
stats.matched_in_gradatum,
stats.already_downgraded,
stats.downgraded,
stats.not_matched
);
Ok(stats)
}