// cli/journal.rs — journal subcommand handlers pub fn cmd_tail(n: usize, full: bool, provenance: Option<&str>, dedup: bool) -> Result<(), String> { let path = crate::store::nodes_path(); if !path.exists() { return Err("No node log found".into()); } use std::io::BufReader; let file = std::fs::File::open(&path) .map_err(|e| format!("open {}: {}", path.display(), e))?; let mut reader = BufReader::new(file); // Read all entries, keep last N let mut entries: Vec = Vec::new(); while let Ok(msg) = capnp::serialize::read_message(&mut reader, capnp::message::ReaderOptions::new()) { let log = msg.get_root::() .map_err(|e| format!("read log: {}", e))?; for node_reader in log.get_nodes() .map_err(|e| format!("get nodes: {}", e))? { let node = crate::store::Node::from_capnp_migrate(node_reader)?; entries.push(node); } } // Filter by provenance if specified (substring match) if let Some(prov) = provenance { entries.retain(|n| n.provenance.contains(prov)); } // Dedup: keep only the latest version of each key if dedup { let mut seen = std::collections::HashSet::new(); // Walk backwards so we keep the latest entries = entries.into_iter().rev() .filter(|n| seen.insert(n.key.clone())) .collect(); entries.reverse(); } let start = entries.len().saturating_sub(n); for node in &entries[start..] { let ts = if node.timestamp > 0 && node.timestamp < 4_000_000_000 { crate::store::format_datetime(node.timestamp) } else { format!("(raw:{})", node.timestamp) }; let del = if node.deleted { " [DELETED]" } else { "" }; if full { println!("--- {} (v{}) {} via {} w={:.3}{} ---", node.key, node.version, ts, node.provenance, node.weight, del); println!("{}\n", node.content); } else { let preview = crate::util::first_n_chars(&node.content, 100).replace('\n', "\\n"); println!(" {} v{} w={:.2}{}", ts, node.version, node.weight, del); println!(" {} via {}", node.key, node.provenance); if !preview.is_empty() { println!(" {}", preview); } println!(); } } Ok(()) } pub fn find_current_transcript() -> Option { let projects = crate::config::get().projects_dir.clone(); if !projects.exists() { return None; } let mut newest: Option<(std::time::SystemTime, std::path::PathBuf)> = None; if let Ok(dirs) = std::fs::read_dir(&projects) { for dir_entry in dirs.filter_map(|e| e.ok()) { if !dir_entry.path().is_dir() { continue; } if let Ok(files) = std::fs::read_dir(dir_entry.path()) { for f in files.filter_map(|e| e.ok()) { let p = f.path(); if p.extension().map(|x| x == "jsonl").unwrap_or(false) && let Ok(meta) = p.metadata() && let Ok(mtime) = meta.modified() && newest.as_ref().is_none_or(|(t, _)| mtime > *t) { newest = Some((mtime, p)); } } } } } newest.map(|(_, p)| p.to_string_lossy().to_string()) } fn journal_tail_entries(store: &crate::store::Store, n: usize, full: bool) -> Result<(), String> { let date_re = regex::Regex::new(r"(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2})").unwrap(); let key_date_re = regex::Regex::new(r"j-(\d{4}-\d{2}-\d{2}[t-]\d{2}-\d{2})").unwrap(); let normalize_date = |s: &str| -> String { let s = s.replace('t', "T"); if s.len() >= 16 { format!("{}T{}", &s[..10], s[11..].replace('-', ":")) } else { s } }; let extract_sort = |node: &crate::store::Node| -> (i64, String) { if node.created_at > 0 { return (node.created_at, crate::store::format_datetime(node.created_at)); } if let Some(caps) = key_date_re.captures(&node.key) { return (0, normalize_date(&caps[1])); } if let Some(caps) = date_re.captures(&node.content) { return (0, normalize_date(&caps[1])); } (node.timestamp, crate::store::format_datetime(node.timestamp)) }; let mut journal: Vec<_> = store.nodes.values() .filter(|node| node.node_type == crate::store::NodeType::EpisodicSession) .collect(); journal.sort_by(|a, b| { let (at, as_) = extract_sort(a); let (bt, bs) = extract_sort(b); if at > 0 && bt > 0 { at.cmp(&bt) } else { as_.cmp(&bs) } }); let skip = if journal.len() > n { journal.len() - n } else { 0 }; for node in journal.iter().skip(skip) { let (_, ts) = extract_sort(node); let title = extract_title(&node.content); if full { println!("--- [{}] {} ---\n{}\n", ts, title, node.content); } else { println!("[{}] {}", ts, title); } } Ok(()) } fn journal_tail_digests(store: &crate::store::Store, node_type: crate::store::NodeType, n: usize, full: bool) -> Result<(), String> { let mut digests: Vec<_> = store.nodes.values() .filter(|node| node.node_type == node_type) .collect(); digests.sort_by(|a, b| { if a.timestamp > 0 && b.timestamp > 0 { a.timestamp.cmp(&b.timestamp) } else { a.key.cmp(&b.key) } }); let skip = if digests.len() > n { digests.len() - n } else { 0 }; for node in digests.iter().skip(skip) { let label = &node.key; let title = extract_title(&node.content); if full { println!("--- [{}] {} ---\n{}\n", label, title, node.content); } else { println!("[{}] {}", label, title); } } Ok(()) } pub fn cmd_journal_tail(n: usize, full: bool, level: u8) -> Result<(), String> { let store = crate::store::Store::load()?; if level == 0 { journal_tail_entries(&store, n, full) } else { let node_type = match level { 1 => crate::store::NodeType::EpisodicDaily, 2 => crate::store::NodeType::EpisodicWeekly, _ => crate::store::NodeType::EpisodicMonthly, }; journal_tail_digests(&store, node_type, n, full) } } pub fn cmd_journal_write(name: &str, text: &[String]) -> Result<(), String> { if text.is_empty() { return Err("journal write requires text".into()); } super::check_dry_run(); let text = text.join(" "); let timestamp = crate::store::format_datetime(crate::store::now_epoch()); let content = format!("## {} — {}\n\n{}", timestamp, name, text); let key: String = name.split_whitespace() .map(|w| w.to_lowercase() .chars().filter(|c| c.is_alphanumeric() || *c == '-') .collect::()) .filter(|s| !s.is_empty()) .collect::>() .join("-"); let source_ref = find_current_transcript(); let mut store = crate::store::Store::load()?; let mut node = crate::store::new_node(&key, &content); node.node_type = crate::store::NodeType::EpisodicSession; node.provenance = "journal".to_string(); if let Some(src) = source_ref { node.source_ref = src; } store.upsert_node(node)?; store.save()?; let word_count = text.split_whitespace().count(); println!("Appended entry at {} ({} words)", timestamp, word_count); Ok(()) } fn extract_title(content: &str) -> String { let date_re = regex::Regex::new(r"(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2})").unwrap(); for line in content.lines() { let stripped = line.trim(); if stripped.is_empty() { continue; } if date_re.is_match(stripped) && stripped.len() < 25 { continue; } if let Some(h) = stripped.strip_prefix("## ") { return h.to_string(); } else if let Some(h) = stripped.strip_prefix("# ") { return h.to_string(); } else { return crate::util::truncate(stripped, 67, "..."); } } String::from("(untitled)") }