minutes-core 0.18.7

Core library for minutes — audio capture, transcription, and meeting memory
use chrono::{DateTime, Local};
use serde::{Deserialize, Serialize};
use std::collections::hash_map::DefaultHasher;
use std::fs;
use std::hash::{Hash, Hasher};
use std::io;
use std::path::{Path, PathBuf};

const SCHEMA_VERSION: u32 = 1;
const MAX_HISTORY_RECORDS: usize = 100;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DictationMemoryRecord {
    pub schema_version: u32,
    pub id: String,
    pub captured_at: DateTime<Local>,
    pub raw_text: String,
    pub cleaned_text: String,
    pub duration_secs: f64,
    pub engine_id: String,
    pub engine_descriptor_version: Option<String>,
    pub vocabulary_mode: Option<String>,
    pub vocabulary_used: Vec<String>,
    pub destination: String,
    pub insertion: DictationInsertionMemory,
    pub target_context: Option<DictationTargetContext>,
    pub file_path: Option<PathBuf>,
    pub daily_note_appended: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct DictationInsertionMemory {
    pub outcome: String,
    pub method: String,
    pub verified: bool,
    pub clipboard_restored: bool,
    pub message: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct DictationTargetContext {
    pub platform: String,
    pub app_name: Option<String>,
}

#[derive(Debug, Clone)]
pub struct DictationMemoryInput {
    pub raw_text: String,
    pub cleaned_text: String,
    pub duration_secs: f64,
    pub engine_id: String,
    pub engine_descriptor_version: Option<String>,
    pub vocabulary_mode: Option<String>,
    pub vocabulary_used: Vec<String>,
    pub destination: String,
    pub insertion: DictationInsertionMemory,
    pub target_context: Option<DictationTargetContext>,
    pub file_path: Option<PathBuf>,
    pub daily_note_appended: bool,
}

impl DictationMemoryRecord {
    pub fn new(input: DictationMemoryInput) -> Self {
        let captured_at = Local::now();
        Self::from_parts(captured_at, input)
    }

    fn from_parts(captured_at: DateTime<Local>, input: DictationMemoryInput) -> Self {
        let id = record_id(
            &captured_at,
            &input.cleaned_text,
            input.duration_secs,
            &input.engine_id,
        );
        Self {
            schema_version: SCHEMA_VERSION,
            id,
            captured_at,
            raw_text: input.raw_text,
            cleaned_text: input.cleaned_text,
            duration_secs: input.duration_secs,
            engine_id: input.engine_id,
            engine_descriptor_version: input.engine_descriptor_version,
            vocabulary_mode: input.vocabulary_mode,
            vocabulary_used: input.vocabulary_used,
            destination: input.destination,
            insertion: input.insertion,
            target_context: input.target_context,
            file_path: input.file_path,
            daily_note_appended: input.daily_note_appended,
        }
    }
}

pub fn history_path() -> PathBuf {
    crate::config::Config::minutes_dir().join("dictation-history.json")
}

pub fn load_recent(limit: usize) -> io::Result<Vec<DictationMemoryRecord>> {
    load_recent_from(&history_path(), limit)
}

pub fn find_record(id: &str) -> io::Result<Option<DictationMemoryRecord>> {
    Ok(load_recent(MAX_HISTORY_RECORDS)?
        .into_iter()
        .find(|record| record.id == id))
}

pub fn append_record(record: DictationMemoryRecord) -> io::Result<()> {
    append_record_to(&history_path(), record, MAX_HISTORY_RECORDS)
}

fn load_recent_from(path: &Path, limit: usize) -> io::Result<Vec<DictationMemoryRecord>> {
    if !path.exists() {
        return Ok(Vec::new());
    }

    let data = fs::read_to_string(path)?;
    if data.trim().is_empty() {
        return Ok(Vec::new());
    }

    let mut records: Vec<DictationMemoryRecord> = serde_json::from_str(&data)
        .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
    records.sort_by_key(|r| std::cmp::Reverse(r.captured_at));
    if limit > 0 && records.len() > limit {
        records.truncate(limit);
    }
    Ok(records)
}

fn append_record_to(
    path: &Path,
    record: DictationMemoryRecord,
    max_records: usize,
) -> io::Result<()> {
    let mut records = load_recent_from(path, max_records.max(1)).unwrap_or_default();
    records.retain(|existing| existing.id != record.id);
    records.insert(0, record);
    records.sort_by_key(|r| std::cmp::Reverse(r.captured_at));
    if records.len() > max_records {
        records.truncate(max_records);
    }

    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
        let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
        serde_json::to_writer_pretty(&mut tmp, &records).map_err(io::Error::other)?;
        tmp.persist(path).map_err(|error| error.error)?;
    }
    Ok(())
}

fn record_id(
    captured_at: &DateTime<Local>,
    cleaned_text: &str,
    duration_secs: f64,
    engine_id: &str,
) -> String {
    let mut hasher = DefaultHasher::new();
    captured_at
        .timestamp_nanos_opt()
        .unwrap_or_default()
        .hash(&mut hasher);
    cleaned_text.hash(&mut hasher);
    duration_secs.to_bits().hash(&mut hasher);
    engine_id.hash(&mut hasher);
    format!(
        "dict-{}-{:016x}",
        captured_at.format("%Y%m%d%H%M%S"),
        hasher.finish()
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::TimeZone;
    use tempfile::TempDir;

    fn sample_record(offset: i64, text: &str) -> DictationMemoryRecord {
        let captured_at = Local.timestamp_opt(1_700_000_000 + offset, 0).unwrap();
        DictationMemoryRecord::from_parts(
            captured_at,
            DictationMemoryInput {
                raw_text: text.into(),
                cleaned_text: text.into(),
                duration_secs: 1.5,
                engine_id: "whisper:base".into(),
                engine_descriptor_version: Some("base".into()),
                vocabulary_mode: None,
                vocabulary_used: Vec::new(),
                destination: "clipboard".into(),
                insertion: DictationInsertionMemory {
                    outcome: "copied".into(),
                    method: "clipboard_only".into(),
                    verified: true,
                    clipboard_restored: false,
                    message: "Copied dictation to the clipboard.".into(),
                },
                target_context: Some(DictationTargetContext {
                    platform: "macos".into(),
                    app_name: Some("Notes".into()),
                }),
                file_path: None,
                daily_note_appended: false,
            },
        )
    }

    #[test]
    fn append_record_keeps_newest_first_and_truncates() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("history.json");

        append_record_to(&path, sample_record(0, "old"), 2).unwrap();
        append_record_to(&path, sample_record(2, "new"), 2).unwrap();
        append_record_to(&path, sample_record(1, "middle"), 2).unwrap();

        let records = load_recent_from(&path, 10).unwrap();
        assert_eq!(records.len(), 2);
        assert_eq!(records[0].cleaned_text, "new");
        assert_eq!(records[1].cleaned_text, "middle");
    }

    #[test]
    fn append_record_replaces_duplicate_id() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("history.json");
        let mut record = sample_record(0, "first");
        let id = record.id.clone();

        append_record_to(&path, record.clone(), 10).unwrap();
        record.cleaned_text = "updated".into();
        append_record_to(&path, record, 10).unwrap();

        let records = load_recent_from(&path, 10).unwrap();
        assert_eq!(records.len(), 1);
        assert_eq!(records[0].id, id);
        assert_eq!(records[0].cleaned_text, "updated");
    }
}