use chrono::{DateTime, Local};
use serde::{Deserialize, Serialize};
use std::fs::{self, OpenOptions};
use std::io::{BufRead, BufReader, Write};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use crate::config::Config;
use crate::markdown::ContentType;
const MAX_EVENT_FILE_BYTES: u64 = 10 * 1024 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventEnvelope {
pub timestamp: DateTime<Local>,
#[serde(flatten)]
pub event: MinutesEvent,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "event_type")]
pub enum MinutesEvent {
RecordingCompleted {
path: String,
title: String,
word_count: usize,
content_type: String,
duration: String,
},
AudioProcessed {
path: String,
title: String,
word_count: usize,
content_type: String,
source_path: String,
},
WatchProcessed {
path: String,
title: String,
word_count: usize,
source_path: String,
},
NoteAdded {
meeting_path: String,
text: String,
},
VaultSynced {
source_path: String,
vault_path: String,
strategy: String,
},
VoiceMemoProcessed {
path: String,
title: String,
word_count: usize,
source_path: String,
device: Option<String>,
},
}
fn events_path() -> PathBuf {
Config::minutes_dir().join("events.jsonl")
}
pub fn append_event(event: MinutesEvent) {
let envelope = EventEnvelope {
timestamp: Local::now(),
event,
};
if let Err(e) = append_event_inner(&envelope) {
tracing::warn!(error = %e, "failed to append event");
}
}
fn append_event_inner(envelope: &EventEnvelope) -> std::io::Result<()> {
rotate_if_needed()?;
let path = events_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let creating = !path.exists();
let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
#[cfg(unix)]
if creating {
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
}
let line = serde_json::to_string(envelope).map_err(|e| std::io::Error::other(e.to_string()))?;
writeln!(file, "{}", line)?;
Ok(())
}
pub fn read_events(since: Option<DateTime<Local>>, limit: Option<usize>) -> Vec<EventEnvelope> {
match read_events_inner(since, limit) {
Ok(events) => events,
Err(e) => {
tracing::warn!(error = %e, "failed to read events");
vec![]
}
}
}
fn read_events_inner(
since: Option<DateTime<Local>>,
limit: Option<usize>,
) -> std::io::Result<Vec<EventEnvelope>> {
let path = events_path();
if !path.exists() {
return Ok(vec![]);
}
let file = fs::File::open(&path)?;
let reader = BufReader::new(file);
let mut events: Vec<EventEnvelope> = Vec::new();
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<EventEnvelope>(&line) {
Ok(envelope) => {
if let Some(ref since_dt) = since {
if envelope.timestamp < *since_dt {
continue;
}
}
events.push(envelope);
}
Err(e) => {
tracing::debug!(error = %e, "skipping malformed event line");
}
}
}
if let Some(limit) = limit {
let skip = events.len().saturating_sub(limit);
events = events.into_iter().skip(skip).collect();
}
Ok(events)
}
fn rotate_if_needed() -> std::io::Result<()> {
let path = events_path();
if !path.exists() {
return Ok(());
}
let metadata = fs::metadata(&path)?;
if metadata.len() < MAX_EVENT_FILE_BYTES {
return Ok(());
}
let date = Local::now().format("%Y-%m-%d").to_string();
let rotated = path.with_file_name(format!("events.{}.jsonl", date));
fs::rename(&path, &rotated)?;
tracing::info!(
from = %path.display(),
to = %rotated.display(),
"rotated event log"
);
Ok(())
}
pub fn audio_processed_event(
result: &crate::markdown::WriteResult,
source_path: &str,
) -> MinutesEvent {
let content_type = match result.content_type {
ContentType::Meeting => "meeting".to_string(),
ContentType::Memo => "memo".to_string(),
ContentType::Dictation => "dictation".to_string(),
};
MinutesEvent::AudioProcessed {
path: result.path.display().to_string(),
title: result.title.clone(),
word_count: result.word_count,
content_type,
source_path: source_path.to_string(),
}
}
pub fn recording_completed_event(
result: &crate::markdown::WriteResult,
duration: &str,
) -> MinutesEvent {
let content_type = match result.content_type {
ContentType::Meeting => "meeting".to_string(),
ContentType::Memo => "memo".to_string(),
ContentType::Dictation => "dictation".to_string(),
};
MinutesEvent::RecordingCompleted {
path: result.path.display().to_string(),
title: result.title.clone(),
word_count: result.word_count,
content_type,
duration: duration.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn set_events_dir(dir: &std::path::Path) -> PathBuf {
dir.join("events.jsonl")
}
#[test]
fn append_and_read_events() {
let dir = TempDir::new().unwrap();
let path = set_events_dir(dir.path());
let envelope = EventEnvelope {
timestamp: Local::now(),
event: MinutesEvent::RecordingCompleted {
path: "/tmp/test.md".into(),
title: "Test Meeting".into(),
word_count: 100,
content_type: "meeting".into(),
duration: "5m".into(),
},
};
let line = serde_json::to_string(&envelope).unwrap();
fs::write(&path, format!("{}\n", line)).unwrap();
let file = fs::File::open(&path).unwrap();
let reader = BufReader::new(file);
let mut events = Vec::new();
for line in reader.lines() {
let line = line.unwrap();
let parsed: EventEnvelope = serde_json::from_str(&line).unwrap();
events.push(parsed);
}
assert_eq!(events.len(), 1);
match &events[0].event {
MinutesEvent::RecordingCompleted { title, .. } => {
assert_eq!(title, "Test Meeting");
}
_ => panic!("expected RecordingCompleted"),
}
}
#[test]
fn event_envelope_serializes_with_tag() {
let envelope = EventEnvelope {
timestamp: Local::now(),
event: MinutesEvent::NoteAdded {
meeting_path: "/tmp/test.md".into(),
text: "Important point".into(),
},
};
let json = serde_json::to_string(&envelope).unwrap();
assert!(json.contains("\"event_type\":\"NoteAdded\""));
assert!(json.contains("\"text\":\"Important point\""));
}
#[test]
fn read_events_returns_empty_for_missing_file() {
let events = read_events_inner(None, None);
assert!(events.is_ok());
}
}