use crate::config::Config;
use crate::constants::mps_file_name_regexp;
use anyhow::{Context, Result};
use chrono::NaiveDate;
use colored::Colorize;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
pub struct LegacyFile {
pub path: PathBuf,
pub stem: String,
pub ext: String,
pub date: NaiveDate,
pub content: String,
}
pub fn collect_legacy_files(mps_dir: &Path, storage_dir: &Path) -> Result<Vec<LegacyFile>> {
let re = mps_file_name_regexp();
let mut files: Vec<LegacyFile> = Vec::new();
if let Ok(rd) = std::fs::read_dir(mps_dir) {
for entry in rd.filter_map(|e| e.ok()) {
let path = entry.path();
if !path.is_file() {
continue;
}
let fname = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
if fname.ends_with(".imported") {
continue;
}
if path.extension().and_then(|e| e.to_str()) == Some("ms") {
if let Some(lf) = read_legacy(&path, &fname) {
files.push(lf);
}
}
}
}
if let Ok(rd) = std::fs::read_dir(storage_dir) {
for entry in rd.filter_map(|e| e.ok()) {
let path = entry.path();
if !path.is_file() {
continue;
}
let fname = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
if fname.ends_with(".imported") {
continue;
}
if path.extension().and_then(|e| e.to_str()) == Some("mps") && !re.is_match(&fname) {
if let Some(lf) = read_legacy(&path, &fname) {
files.push(lf);
}
}
}
}
files.sort_by(|a, b| a.date.cmp(&b.date).then(a.stem.cmp(&b.stem)));
Ok(files)
}
fn read_legacy(path: &Path, fname: &str) -> Option<LegacyFile> {
let (stem, ext) = match fname.rsplit_once('.') {
Some((s, e)) => (s.to_string(), e.to_string()),
None => (fname.to_string(), String::new()),
};
let date = mtime_date(path).unwrap_or_else(|_| chrono::Local::now().date_naive());
let content = std::fs::read_to_string(path).unwrap_or_default();
Some(LegacyFile {
path: path.to_path_buf(),
stem,
ext,
date,
content,
})
}
fn mtime_date(path: &Path) -> Result<NaiveDate> {
let meta = std::fs::metadata(path).with_context(|| format!("stat {}", path.display()))?;
let mtime = meta.modified().context("mtime not available")?;
let secs = mtime
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let dt = chrono::DateTime::from_timestamp(secs as i64, 0).context("invalid timestamp")?;
Ok(dt.date_naive())
}
pub fn normalise_tag(stem: &str) -> String {
stem.to_lowercase().replace('_', "-")
}
pub fn format_as_note(stem: &str, content: &str) -> String {
let indented: String = content
.lines()
.map(|l| {
if l.trim().is_empty() {
String::new()
} else {
format!(" {l}")
}
})
.collect::<Vec<_>>()
.join("\n");
format!(
"@note[{}]{{\n {stem}\n\n{indented}\n}}\n",
normalise_tag(stem)
)
}
pub fn run(config: &Config, dry_run: bool, yes: bool, move_: bool) -> Result<()> {
let legacy = collect_legacy_files(&config.mps_dir, &config.storage_dir)?;
if legacy.is_empty() {
println!("{}", "mps import — no legacy files found.".white());
println!(" Looked for:");
println!(" .ms files in {}", config.mps_dir.display());
println!(
" non-standard .mps files in {}",
config.storage_dir.display()
);
return Ok(());
}
let mut groups: BTreeMap<NaiveDate, Vec<&LegacyFile>> = BTreeMap::new();
for lf in &legacy {
groups.entry(lf.date).or_default().push(lf);
}
let epoch = std::time::SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let output_paths: BTreeMap<NaiveDate, PathBuf> = groups
.keys()
.enumerate()
.map(|(i, &date)| {
let fname = format!("{}.{}.mps", date.format("%Y%m%d"), epoch + i as u64);
(date, config.storage_dir.join(fname))
})
.collect();
let mode_label = if dry_run || (!yes && !move_) {
"dry run (pass --yes to execute)"
} else if move_ {
"execute — originals will be deleted"
} else {
"execute — originals will be renamed to .imported"
};
println!(
"\n{} {}\n",
"mps import —".white().bold(),
mode_label.white()
);
let col_w = legacy
.iter()
.map(|f| {
f.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.len()
})
.max()
.unwrap_or(20)
.max(20);
println!(
" {:<col_w$} {:<10} \u{2192} output file",
"source file", "date"
);
println!(" {}", "─".repeat(col_w + 28));
let mut prev_date: Option<NaiveDate> = None;
for lf in &legacy {
let fname = lf.path.file_name().and_then(|n| n.to_str()).unwrap_or("");
let out = output_paths[&lf.date]
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
let same_note = if prev_date == Some(lf.date) {
" (same)"
} else {
""
};
println!(
" {:<col_w$} {:<10} → {}{}",
fname,
lf.date.format("%Y-%m-%d"),
out,
same_note
);
prev_date = Some(lf.date);
}
let file_count = legacy.len();
let group_count = groups.len();
println!(
"\n {} {} → {} output {}",
file_count,
if file_count == 1 { "file" } else { "files" },
group_count,
if group_count == 1 { "file" } else { "files" }
);
if dry_run || (!yes && !move_) {
println!(" {}", "run with --yes to execute".cyan());
println!();
return Ok(());
}
println!();
for (date, files) in &groups {
let out_path = &output_paths[date];
let content: String = files
.iter()
.map(|lf| format_as_note(&lf.stem, &lf.content))
.collect::<Vec<_>>()
.join("\n");
let tmp = out_path.with_extension("tmp");
std::fs::write(&tmp, &content).with_context(|| format!("write {}", tmp.display()))?;
std::fs::rename(&tmp, out_path)
.with_context(|| format!("rename to {}", out_path.display()))?;
println!(
" {} written {} ({} element{})",
"✓".green().bold(),
out_path.file_name().and_then(|n| n.to_str()).unwrap_or(""),
files.len(),
if files.len() == 1 { "" } else { "s" }
);
}
for lf in &legacy {
if move_ {
std::fs::remove_file(&lf.path)
.with_context(|| format!("delete {}", lf.path.display()))?;
} else {
let new_name = format!("{}.{}.imported", lf.stem, lf.ext);
let new_path = lf.path.parent().unwrap_or(Path::new(".")).join(&new_name);
std::fs::rename(&lf.path, &new_path)
.with_context(|| format!("rename {}", lf.path.display()))?;
}
}
println!();
println!(" {} {} imported", "✓".green().bold(), file_count);
if !move_ {
println!(" Originals renamed to *.imported — they will be skipped on re-run.");
}
println!();
Ok(())
}