Skip to main content

palace/
split.rs

1//! Split concatenated Claude Code mega-transcript files into per-session files.
2//!
3//! Identifies true session starts by "Claude Code v" headers (not context restores),
4//! extracts timestamps, people names, and subject from first prompt,
5//! writes per-session files named: STEM__DATE_TIME_People_subject.txt
6//!
7//! Port of split_mega_files.py.
8
9use 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
223/// Run the split command.
224pub 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}