1use anyhow::Result;
10use once_cell::sync::Lazy;
11use regex::Regex;
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15static TS_RE: Lazy<Regex> = Lazy::new(|| {
16 Regex::new(r"⏺\s+(\d{1,2}:\d{2}\s+[AP]M)\s+\w+,\s+(\w+)\s+(\d{1,2}),\s+(\d{4})").unwrap()
17});
18
19static SKIP_RE: Lazy<Regex> = Lazy::new(|| {
20 Regex::new(r"^(\./|cd |ls |python|bash|git |cat |source |export |claude|./activate)").unwrap()
21});
22
23static MONTHS: Lazy<HashMap<&'static str, &'static str>> = Lazy::new(|| {
24 [
25 ("January", "01"),
26 ("February", "02"),
27 ("March", "03"),
28 ("April", "04"),
29 ("May", "05"),
30 ("June", "06"),
31 ("July", "07"),
32 ("August", "08"),
33 ("September", "09"),
34 ("October", "10"),
35 ("November", "11"),
36 ("December", "12"),
37 ]
38 .iter()
39 .cloned()
40 .collect()
41});
42
43fn is_true_session_start(lines: &[&str], idx: usize) -> bool {
44 let nearby: String = lines[idx..idx.saturating_add(6).min(lines.len())].join("");
45 !nearby.contains("Ctrl+E") && !nearby.contains("previous messages")
46}
47
48fn find_session_boundaries(lines: &[&str]) -> Vec<usize> {
49 lines
50 .iter()
51 .enumerate()
52 .filter(|(i, line)| line.contains("Claude Code v") && is_true_session_start(lines, *i))
53 .map(|(i, _)| i)
54 .collect()
55}
56
57fn extract_timestamp(lines: &[&str]) -> (Option<String>, Option<String>) {
58 for line in lines.iter().take(50) {
59 if let Some(cap) = TS_RE.captures(line) {
60 let time_str = cap.get(1).map_or("", |m| m.as_str());
61 let month = cap.get(2).map_or("", |m| m.as_str());
62 let day = cap.get(3).map_or("", |m| m.as_str());
63 let year = cap.get(4).map_or("", |m| m.as_str());
64 let mon = MONTHS.get(month).copied().unwrap_or("00");
65 let day_z = format!("{:02}", day.parse::<u32>().unwrap_or(1));
66 let time_safe = time_str.replace([':', ' '], "");
67 let iso = format!("{year}-{mon}-{day_z}");
68 let human = format!("{year}-{mon}-{day_z}_{time_safe}");
69 return (Some(human), Some(iso));
70 }
71 }
72 (None, None)
73}
74
75fn load_known_people(config_dir: &Path) -> Vec<String> {
76 let path = config_dir.join("known_names.json");
77 if path.exists() {
78 if let Ok(s) = std::fs::read_to_string(&path) {
79 if let Ok(v) = serde_json::from_str::<serde_json::Value>(&s) {
80 let names = if let Some(arr) = v.as_array() {
81 arr.iter()
82 .filter_map(|x| x.as_str().map(String::from))
83 .collect()
84 } else if let Some(obj) = v.as_object() {
85 obj.get("names")
86 .and_then(|n| n.as_array())
87 .map(|a| {
88 a.iter()
89 .filter_map(|x| x.as_str().map(String::from))
90 .collect()
91 })
92 .unwrap_or_default()
93 } else {
94 vec![]
95 };
96 if !names.is_empty() {
97 return names;
98 }
99 }
100 }
101 }
102 vec!["Alice", "Ben", "Riley", "Max", "Sam", "Devon", "Jordan"]
103 .into_iter()
104 .map(String::from)
105 .collect()
106}
107
108fn extract_people(lines: &[&str], known: &[String]) -> Vec<String> {
109 let text = lines
110 .iter()
111 .take(100)
112 .cloned()
113 .collect::<Vec<_>>()
114 .join("\n");
115 let mut found = std::collections::HashSet::new();
116 for person in known {
117 let re = Regex::new(&format!(r"(?i)\b{}\b", regex::escape(person))).unwrap();
118 if re.is_match(&text) {
119 found.insert(person.clone());
120 }
121 }
122 let mut sorted: Vec<String> = found.into_iter().collect();
123 sorted.sort();
124 sorted
125}
126
127fn extract_subject(lines: &[&str]) -> String {
128 for line in lines {
129 if let Some(stripped) = line.strip_prefix("> ") {
130 let prompt = stripped.trim();
131 if prompt.is_empty() || SKIP_RE.is_match(prompt) || prompt.len() <= 5 {
132 continue;
133 }
134 let subject: String = prompt
135 .chars()
136 .filter(|c| c.is_alphanumeric() || c.is_whitespace() || *c == '-')
137 .collect();
138 let subject = subject.split_whitespace().collect::<Vec<_>>().join("-");
139 let subject = &subject[..subject.len().min(60)];
140 return subject.to_string();
141 }
142 }
143 "session".to_string()
144}
145
146fn sanitize_filename(s: &str) -> String {
147 let s: String = s
148 .chars()
149 .map(|c| {
150 if c.is_alphanumeric() || c == '.' || c == '-' {
151 c
152 } else {
153 '_'
154 }
155 })
156 .collect();
157 let re = Regex::new(r"_+").unwrap();
158 re.replace_all(&s, "_").to_string()
159}
160
161fn split_file(
162 filepath: &Path,
163 output_dir: Option<&Path>,
164 dry_run: bool,
165 known_people: &[String],
166) -> Result<Vec<PathBuf>> {
167 let content = std::fs::read_to_string(filepath)?;
168 let lines: Vec<&str> = content.lines().collect();
169 let boundaries = find_session_boundaries(&lines);
170
171 if boundaries.len() < 2 {
172 return Ok(vec![]);
173 }
174
175 let mut boundaries = boundaries;
176 boundaries.push(lines.len());
177
178 let out_dir = output_dir.unwrap_or_else(|| filepath.parent().unwrap_or(Path::new(".")));
179 let stem = filepath.file_stem().unwrap_or_default().to_string_lossy();
180 let stem_safe = &sanitize_filename(&stem)[..stem.len().min(40)];
181
182 let mut written = Vec::new();
183
184 for (i, (start, end)) in boundaries.iter().zip(boundaries.iter().skip(1)).enumerate() {
185 let chunk: Vec<&str> = lines[*start..*end].to_vec();
186 if chunk.len() < 10 {
187 continue;
188 }
189
190 let (ts_human, _) = extract_timestamp(&chunk);
191 let people = extract_people(&chunk, known_people);
192 let subject = extract_subject(&chunk);
193
194 let ts_part = ts_human.unwrap_or_else(|| format!("part{:02}", i + 1));
195 let people_part = if people.is_empty() {
196 "unknown".to_string()
197 } else {
198 people.iter().take(3).cloned().collect::<Vec<_>>().join("-")
199 };
200
201 let name = sanitize_filename(&format!(
202 "{stem_safe}__{ts_part}_{people_part}_{subject}.txt"
203 ));
204 let out_path = out_dir.join(&name);
205
206 if dry_run {
207 println!(
208 " [{}/{}] {name} ({} lines)",
209 i + 1,
210 boundaries.len() - 1,
211 chunk.len()
212 );
213 } else {
214 std::fs::write(&out_path, chunk.join("\n"))?;
215 println!(" ✓ {name} ({} lines)", chunk.len());
216 }
217 written.push(out_path);
218 }
219
220 Ok(written)
221}
222
223pub fn run(
225 source: Option<&Path>,
226 output_dir: Option<&Path>,
227 min_sessions: usize,
228 dry_run: bool,
229 file: Option<&Path>,
230) -> Result<()> {
231 let home = directories::UserDirs::new()
232 .map(|u| u.home_dir().to_path_buf())
233 .unwrap_or_else(|| PathBuf::from("."));
234
235 let src_dir = source
236 .map(PathBuf::from)
237 .or_else(|| std::env::var("PALACE_SOURCE_DIR").ok().map(PathBuf::from))
238 .unwrap_or_else(|| home.join("Desktop/transcripts"));
239
240 let config_dir = home.join(".palace");
241 let known_people = load_known_people(&config_dir);
242
243 let files: Vec<PathBuf> = if let Some(f) = file {
244 vec![f.to_path_buf()]
245 } else {
246 let mut f: Vec<PathBuf> = std::fs::read_dir(&src_dir)?
247 .filter_map(|e| e.ok())
248 .map(|e| e.path())
249 .filter(|p| p.extension().is_some_and(|e| e == "txt"))
250 .collect();
251 f.sort();
252 f
253 };
254
255 let mega_files: Vec<(PathBuf, usize)> = files
256 .iter()
257 .filter_map(|f| {
258 let content = std::fs::read_to_string(f).ok()?;
259 let lines: Vec<&str> = content.lines().collect();
260 let n = find_session_boundaries(&lines).len();
261 if n >= min_sessions {
262 Some((f.clone(), n))
263 } else {
264 None
265 }
266 })
267 .collect();
268
269 if mega_files.is_empty() {
270 println!(
271 "No mega-files found in {} (min {} sessions).",
272 src_dir.display(),
273 min_sessions
274 );
275 return Ok(());
276 }
277
278 println!("\n{}", "=".repeat(60));
279 println!(
280 " Mega-file splitter — {}",
281 if dry_run { "DRY RUN" } else { "SPLITTING" }
282 );
283 println!("{}", "=".repeat(60));
284 println!(" Source: {}", src_dir.display());
285 println!(
286 " Output: {}",
287 output_dir
288 .map(|d| d.display().to_string())
289 .unwrap_or_else(|| "same dir as source".to_string())
290 );
291 println!(" Mega-files: {}", mega_files.len());
292 println!("{}\n", "-".repeat(60));
293
294 let mut total_written = 0usize;
295 for (f, n_sessions) in &mega_files {
296 println!(
297 " {} ({n_sessions} sessions, {}KB)",
298 f.file_name().unwrap_or_default().to_string_lossy(),
299 f.metadata().map(|m| m.len() / 1024).unwrap_or(0)
300 );
301 let written = split_file(f, output_dir, dry_run, &known_people)?;
302 total_written += written.len();
303
304 if !dry_run && !written.is_empty() {
305 let backup = f.with_extension("mega_backup");
306 std::fs::rename(f, &backup)?;
307 println!(
308 " → Original renamed to {}\n",
309 backup.file_name().unwrap_or_default().to_string_lossy()
310 );
311 } else {
312 println!();
313 }
314 }
315
316 println!("{}", "-".repeat(60));
317 if dry_run {
318 println!(
319 " DRY RUN — would create {total_written} files from {} mega-files",
320 mega_files.len()
321 );
322 } else {
323 println!(
324 " Done — created {total_written} files from {} mega-files",
325 mega_files.len()
326 );
327 }
328 println!();
329 Ok(())
330}