130 lines
4.7 KiB
Rust
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
|
|
}
|