mps/commands/
import_cmd.rs1use crate::config::Config;
2use crate::constants::mps_file_name_regexp;
3use anyhow::{Context, Result};
4use chrono::NaiveDate;
5use colored::Colorize;
6use std::collections::BTreeMap;
7use std::path::{Path, PathBuf};
8use std::time::UNIX_EPOCH;
9
10pub struct LegacyFile {
12 pub path: PathBuf,
13 pub stem: String,
14 pub ext: String,
15 pub date: NaiveDate,
16 pub content: String,
17}
18
19pub fn collect_legacy_files(mps_dir: &Path, storage_dir: &Path) -> Result<Vec<LegacyFile>> {
22 let re = mps_file_name_regexp();
23 let mut files: Vec<LegacyFile> = Vec::new();
24
25 if let Ok(rd) = std::fs::read_dir(mps_dir) {
27 for entry in rd.filter_map(|e| e.ok()) {
28 let path = entry.path();
29 if !path.is_file() {
30 continue;
31 }
32 let fname = match path.file_name().and_then(|n| n.to_str()) {
33 Some(n) => n.to_string(),
34 None => continue,
35 };
36 if fname.ends_with(".imported") {
38 continue;
39 }
40 if path.extension().and_then(|e| e.to_str()) == Some("ms") {
41 if let Some(lf) = read_legacy(&path, &fname) {
42 files.push(lf);
43 }
44 }
45 }
46 }
47
48 if let Ok(rd) = std::fs::read_dir(storage_dir) {
50 for entry in rd.filter_map(|e| e.ok()) {
51 let path = entry.path();
52 if !path.is_file() {
53 continue;
54 }
55 let fname = match path.file_name().and_then(|n| n.to_str()) {
56 Some(n) => n.to_string(),
57 None => continue,
58 };
59 if fname.ends_with(".imported") {
60 continue;
61 }
62 if path.extension().and_then(|e| e.to_str()) == Some("mps") && !re.is_match(&fname) {
63 if let Some(lf) = read_legacy(&path, &fname) {
64 files.push(lf);
65 }
66 }
67 }
68 }
69
70 files.sort_by(|a, b| a.date.cmp(&b.date).then(a.stem.cmp(&b.stem)));
71 Ok(files)
72}
73
74fn read_legacy(path: &Path, fname: &str) -> Option<LegacyFile> {
75 let (stem, ext) = match fname.rsplit_once('.') {
77 Some((s, e)) => (s.to_string(), e.to_string()),
78 None => (fname.to_string(), String::new()),
79 };
80
81 let date = mtime_date(path).unwrap_or_else(|_| chrono::Local::now().date_naive());
82 let content = std::fs::read_to_string(path).unwrap_or_default();
83
84 Some(LegacyFile {
85 path: path.to_path_buf(),
86 stem,
87 ext,
88 date,
89 content,
90 })
91}
92
93fn mtime_date(path: &Path) -> Result<NaiveDate> {
94 let meta = std::fs::metadata(path).with_context(|| format!("stat {}", path.display()))?;
95 let mtime = meta.modified().context("mtime not available")?;
96 let secs = mtime
97 .duration_since(UNIX_EPOCH)
98 .unwrap_or_default()
99 .as_secs();
100 let dt = chrono::DateTime::from_timestamp(secs as i64, 0).context("invalid timestamp")?;
101 Ok(dt.date_naive())
102}
103
104pub fn normalise_tag(stem: &str) -> String {
106 stem.to_lowercase().replace('_', "-")
107}
108
109pub fn format_as_note(stem: &str, content: &str) -> String {
111 let indented: String = content
112 .lines()
113 .map(|l| {
114 if l.trim().is_empty() {
115 String::new()
116 } else {
117 format!(" {l}")
118 }
119 })
120 .collect::<Vec<_>>()
121 .join("\n");
122 format!(
123 "@note[{}]{{\n {stem}\n\n{indented}\n}}\n",
124 normalise_tag(stem)
125 )
126}
127
128pub fn run(config: &Config, dry_run: bool, yes: bool, move_: bool) -> Result<()> {
129 let legacy = collect_legacy_files(&config.mps_dir, &config.storage_dir)?;
130
131 if legacy.is_empty() {
132 println!("{}", "mps import — no legacy files found.".white());
133 println!(" Looked for:");
134 println!(" .ms files in {}", config.mps_dir.display());
135 println!(
136 " non-standard .mps files in {}",
137 config.storage_dir.display()
138 );
139 return Ok(());
140 }
141
142 let mut groups: BTreeMap<NaiveDate, Vec<&LegacyFile>> = BTreeMap::new();
144 for lf in &legacy {
145 groups.entry(lf.date).or_default().push(lf);
146 }
147
148 let epoch = std::time::SystemTime::now()
150 .duration_since(UNIX_EPOCH)
151 .unwrap_or_default()
152 .as_secs();
153
154 let output_paths: BTreeMap<NaiveDate, PathBuf> = groups
155 .keys()
156 .enumerate()
157 .map(|(i, &date)| {
158 let fname = format!("{}.{}.mps", date.format("%Y%m%d"), epoch + i as u64);
159 (date, config.storage_dir.join(fname))
160 })
161 .collect();
162
163 let mode_label = if dry_run || (!yes && !move_) {
165 "dry run (pass --yes to execute)"
166 } else if move_ {
167 "execute — originals will be deleted"
168 } else {
169 "execute — originals will be renamed to .imported"
170 };
171 println!(
172 "\n{} {}\n",
173 "mps import —".white().bold(),
174 mode_label.white()
175 );
176
177 let col_w = legacy
178 .iter()
179 .map(|f| {
180 f.path
181 .file_name()
182 .and_then(|n| n.to_str())
183 .unwrap_or("")
184 .len()
185 })
186 .max()
187 .unwrap_or(20)
188 .max(20);
189
190 println!(
191 " {:<col_w$} {:<10} \u{2192} output file",
192 "source file", "date"
193 );
194 println!(" {}", "─".repeat(col_w + 28));
195
196 let mut prev_date: Option<NaiveDate> = None;
197 for lf in &legacy {
198 let fname = lf.path.file_name().and_then(|n| n.to_str()).unwrap_or("");
199 let out = output_paths[&lf.date]
200 .file_name()
201 .and_then(|n| n.to_str())
202 .unwrap_or("");
203 let same_note = if prev_date == Some(lf.date) {
204 " (same)"
205 } else {
206 ""
207 };
208 println!(
209 " {:<col_w$} {:<10} → {}{}",
210 fname,
211 lf.date.format("%Y-%m-%d"),
212 out,
213 same_note
214 );
215 prev_date = Some(lf.date);
216 }
217
218 let file_count = legacy.len();
219 let group_count = groups.len();
220 println!(
221 "\n {} {} → {} output {}",
222 file_count,
223 if file_count == 1 { "file" } else { "files" },
224 group_count,
225 if group_count == 1 { "file" } else { "files" }
226 );
227
228 if dry_run || (!yes && !move_) {
229 println!(" {}", "run with --yes to execute".cyan());
230 println!();
231 return Ok(());
232 }
233
234 println!();
235
236 for (date, files) in &groups {
238 let out_path = &output_paths[date];
239 let content: String = files
240 .iter()
241 .map(|lf| format_as_note(&lf.stem, &lf.content))
242 .collect::<Vec<_>>()
243 .join("\n");
244
245 let tmp = out_path.with_extension("tmp");
246 std::fs::write(&tmp, &content).with_context(|| format!("write {}", tmp.display()))?;
247 std::fs::rename(&tmp, out_path)
248 .with_context(|| format!("rename to {}", out_path.display()))?;
249
250 println!(
251 " {} written {} ({} element{})",
252 "✓".green().bold(),
253 out_path.file_name().and_then(|n| n.to_str()).unwrap_or(""),
254 files.len(),
255 if files.len() == 1 { "" } else { "s" }
256 );
257 }
258
259 for lf in &legacy {
261 if move_ {
262 std::fs::remove_file(&lf.path)
263 .with_context(|| format!("delete {}", lf.path.display()))?;
264 } else {
265 let new_name = format!("{}.{}.imported", lf.stem, lf.ext);
267 let new_path = lf.path.parent().unwrap_or(Path::new(".")).join(&new_name);
268 std::fs::rename(&lf.path, &new_path)
269 .with_context(|| format!("rename {}", lf.path.display()))?;
270 }
271 }
272
273 println!();
274 println!(" {} {} imported", "✓".green().bold(), file_count);
275 if !move_ {
276 println!(" Originals renamed to *.imported — they will be skipped on re-run.");
277 }
278 println!();
279
280 Ok(())
281}