nirc-rs/src/core/history.rs

130 lines
4.7 KiB
Rust

/// Scrollback persistence -- saves/loads per-tab message history as JSONL files.
///
/// History files live in `~/.nirc/history/`. Each file is named
/// `<sanitised tab-id>.log` (e.g. `IRC_#nirc.log`). The first line is a
/// comment bearing the original tab id (`# tab_id: IRC:#nirc`); subsequent
/// lines are JSON-serialised `ChatMessage` objects (one per line).
use crate::core::message::ChatMessage;
use crate::core::protocol::ProtocolType;
use std::fs;
use std::io::{BufRead, BufWriter, Write};
use std::path::PathBuf;
use tracing::{debug, warn};
fn history_dir() -> PathBuf {
dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("nirc")
.join("history")
}
fn sanitise(tab_id: &str) -> String {
tab_id.replace(':', "_").replace('/', "_").replace('\\', "_").replace('\0', "")
}
fn history_path(tab_id: &str) -> PathBuf {
history_dir().join(format!("{}.log", sanitise(tab_id)))
}
fn ensure_dir() {
let _ = fs::create_dir_all(history_dir());
}
pub fn save_tab(tab_id: &str, messages: &[ChatMessage], max_scrollback: usize) {
let start = messages.len().saturating_sub(max_scrollback);
let to_save = &messages[start..];
if to_save.is_empty() {
return;
}
ensure_dir();
let path = history_path(tab_id);
match fs::File::create(&path) {
Ok(file) => {
let mut w = BufWriter::new(file);
let _ = writeln!(w, "# tab_id: {}", tab_id);
for msg in to_save {
match serde_json::to_string(msg) {
Ok(line) => { let _ = writeln!(w, "{}", line); }
Err(e) => { warn!(%e, tab_id, "Failed to serialise message for history"); }
}
}
let _ = w.flush();
debug!(path = %path.display(), count = to_save.len(), "Saved scrollback");
}
Err(e) => { warn!(%e, path = %path.display(), "Failed to create history file"); }
}
}
pub fn load_tab(path: &std::path::Path, max_scrollback: usize) -> Option<(String, Vec<ChatMessage>)> {
let file = fs::File::open(path).ok()?;
let reader = std::io::BufReader::new(file);
let mut lines = reader.lines();
let header = lines.next().map(|r| r.ok()).flatten()?;
let tab_id = header.strip_prefix("# tab_id: ")?.to_owned();
let mut messages: Vec<ChatMessage> = Vec::new();
for line_result in lines {
let line = match line_result {
Ok(l) => l,
Err(e) => { warn!(%e, path = %path.display(), "Error reading history line"); continue; }
};
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') { continue; }
match serde_json::from_str::<ChatMessage>(trimmed) {
Ok(msg) => messages.push(msg),
Err(e) => { warn!(%e, path = %path.display(), "Failed to parse history line"); }
}
}
if messages.len() > max_scrollback {
let start = messages.len() - max_scrollback;
messages = messages[start..].to_vec();
}
debug!(path = %path.display(), tab_id = %tab_id, count = messages.len(), "Loaded scrollback");
Some((tab_id, messages))
}
pub fn save_all(app: &crate::core::app::App, max_scrollback: usize) {
for i in 0..app.tab_count() {
if let Some(tab) = app.tab_at(i) {
save_tab(&tab.id, tab.messages(), max_scrollback);
}
}
}
fn protocol_from_tag(tag: &str) -> Option<ProtocolType> {
match tag {
"IRC" => Some(ProtocolType::Irc),
"Mtx" => Some(ProtocolType::Matrix),
"ADC" => Some(ProtocolType::Adc),
"P2P" => Some(ProtocolType::BitChat),
"Dsc" => Some(ProtocolType::Discord),
"Sto" => Some(ProtocolType::Stout),
"Spc" => Some(ProtocolType::Spacebar),
"Ner" => Some(ProtocolType::Nerimity),
_ => None,
}
}
pub fn load_all(max_scrollback: usize) -> Vec<(String, ProtocolType, String, Vec<ChatMessage>)> {
let dir = history_dir();
if !dir.exists() { return Vec::new(); }
let mut results = Vec::new();
if let Ok(entries) = fs::read_dir(&dir) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if path.extension().map(|ext| ext == "log").unwrap_or(false) {
if let Some((tid, messages)) = load_tab(&path, max_scrollback) {
// Clone to avoid borrow conflict (split_once borrows tid).
let tid_clone = tid.clone();
if let Some((proto_tag, source)) = tid_clone.split_once(':') {
if let Some(protocol) = protocol_from_tag(proto_tag) {
results.push((tid, protocol, source.to_owned(), messages));
}
}
}
}
}
}
results
}