skillok meeting
996ce529a8d705e470aea24a7b7fb9a19578f1e4
Перейти к файлу
Код
meeting src-taurisrcmeetings.rs
anc Ядро: встреча = заметка Markdown, всё по кнопке - Убрана автоматика: слежение за Zoom, окно согласия, трей, уведомления, автозапуск, живая расшифровка, разбор по JSON-схеме. - Встреча — note.md и дорожки звука; правка, дописывание, переименование, удаление. - «Добавить аудио»: чтение mp3, m4a, wav, aiff, flac, ogg (symphonia), потоковое приведение к 16 кГц моно. - «Расшифровать»: нарезка по паузам с обрезкой тишины в начале куска, до 4 кусков сразу, повтор, пометка нераспознанного, блок «Расшифровка». - Команды ИИ над заметкой: «Конспект», «Задачи», «Ревью», свой запрос; промпты файлами в prompts/. - «Записать» по кнопке: дорожки Zoom и микрофона в WAV, пауза, стоп. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> ad47f89 30 сентября 2026 г.
meetings.rs
//! Встречи на диске. Встреча — это заметка в Markdown и, если есть, звук:
//!
//! ```text
//! <корень>/20260930-154300/
//!   meeting.json     — название, даты, дорожки звука, когда расшифрована
//!   note.md          — заметка: её пишет человек, в неё же ложатся расшифровка
//!                      и ответы ИИ; единственный источник текста
//!   audio/           — дорожки: me.wav и others.wav из записи или исходный файл
//! ```
//!
//! Программа ничего не делает сама: каждое изменение — по действию человека
//! (записать, добавить аудио, расшифровать, команда ИИ, правка заметки).
//! Изменения «прочитал — поменял — записал» идут под одним замком: расшифровка
//! дописывается в заметку, пока человек может её править.

use std::path::{Path, PathBuf};
use std::sync::Mutex;

use chrono::{DateTime, Local};
use serde::{Deserialize, Serialize};

use crate::transcript::Speaker;

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AudioTrack {
    /// Имя файла в папке `audio/`.
    pub file: String,
    pub speaker: Speaker,
    pub duration_ms: u64,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Meeting {
    pub id: String,
    pub title: String,
    pub created_at: DateTime<Local>,
    pub updated_at: DateTime<Local>,
    pub audio: Vec<AudioTrack>,
    /// Идёт запись: дорожки ещё пишутся.
    #[serde(default)]
    pub recording: bool,
    pub transcribed_at: Option<DateTime<Local>>,
}

impl Meeting {
    pub fn duration_ms(&self) -> u64 {
        self.audio.iter().map(|a| a.duration_ms).max().unwrap_or(0)
    }
}

#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct MeetingDetail {
    pub meeting: Meeting,
    pub note: String,
}

#[derive(Debug, thiserror::Error)]
pub enum Error {
    #[error("встреча «{0}» не найдена")]
    NotFound(String),
    #[error("название не может быть пустым")]
    BlankTitle,
    #[error("файл встречи: {0}")]
    Io(#[from] std::io::Error),
    #[error("файл встречи повреждён: {0}")]
    Corrupt(#[from] serde_json::Error),
}

pub struct Store {
    root: PathBuf,
    write: Mutex<()>,
}

impl Store {
    pub fn new(root: impl Into<PathBuf>) -> Self {
        Self {
            root: root.into(),
            write: Mutex::new(()),
        }
    }

    pub fn root(&self) -> &Path {
        &self.root
    }

    fn lock(&self) -> std::sync::MutexGuard<'_, ()> {
        self.write.lock().unwrap_or_else(|p| p.into_inner())
    }

    /// Все встречи, свежие сверху. Повреждённая папка пропускается.
    pub fn list(&self) -> Result<Vec<Meeting>, Error> {
        let entries = match std::fs::read_dir(&self.root) {
            Ok(e) => e,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
            Err(e) => return Err(e.into()),
        };
        let mut out: Vec<Meeting> = entries
            .flatten()
            .filter_map(|e| std::fs::read_to_string(e.path().join("meeting.json")).ok())
            .filter_map(|t| serde_json::from_str(&t).ok())
            .collect();
        out.sort_by(|a, b| b.created_at.cmp(&a.created_at).then(b.id.cmp(&a.id)));
        Ok(out)
    }

    pub fn get(&self, id: &str) -> Result<MeetingDetail, Error> {
        let dir = self.dir(id)?;
        Ok(MeetingDetail {
            meeting: read_json(&dir.join("meeting.json"))?,
            note: std::fs::read_to_string(dir.join("note.md")).unwrap_or_default(),
        })
    }

    /// Новая встреча: пустая заметка, звука нет.
    pub fn create(&self, title: &str, now: DateTime<Local>) -> Result<Meeting, Error> {
        let title = title.trim();
        if title.is_empty() {
            return Err(Error::BlankTitle);
        }
        let _guard = self.lock();
        let id = self.free_id(now);
        let dir = self.root.join(&id);
        std::fs::create_dir_all(dir.join("audio"))?;
        let meeting = Meeting {
            id,
            title: title.to_string(),
            created_at: now,
            updated_at: now,
            audio: Vec::new(),
            recording: false,
            transcribed_at: None,
        };
        std::fs::write(dir.join("note.md"), "")?;
        // meeting.json последним: пока его нет, список встречу не видит.
        write_json(&dir.join("meeting.json"), &meeting)?;
        Ok(meeting)
    }

    /// Путь для дорожки внутри встречи.
    pub fn audio_path(&self, id: &str, file: &str) -> Result<PathBuf, Error> {
        let safe = !file.is_empty() && !file.contains(['/', '\\']) && file != "." && file != "..";
        if !safe {
            return Err(Error::NotFound(file.to_string()));
        }
        Ok(self.dir(id)?.join("audio").join(file))
    }

    /// Копирует аудиофайл во встречу и записывает его дорожкой.
    pub fn attach_audio_file(&self, id: &str, source: &Path, duration_ms: u64) -> Result<Meeting, Error> {
        let name = source
            .file_name()
            .and_then(|n| n.to_str())
            .map(sanitize_file_name)
            .unwrap_or_else(|| "audio".into());
        let target = self.audio_path(id, &name)?;
        std::fs::copy(source, &target)?;
        self.update(id, |m| {
            m.audio.push(AudioTrack {
                file: name.clone(),
                speaker: Speaker::Unknown,
                duration_ms,
            });
        })
    }

    pub fn set_recording(
        &self,
        id: &str,
        recording: bool,
        tracks: Vec<AudioTrack>,
    ) -> Result<Meeting, Error> {
        self.update(id, |m| {
            m.recording = recording;
            m.audio = tracks.clone();
        })
    }

    pub fn save_note(&self, id: &str, text: &str) -> Result<Meeting, Error> {
        let _guard = self.lock();
        let dir = self.dir(id)?;
        std::fs::write(dir.join("note.md"), text)?;
        let mut m: Meeting = read_json(&dir.join("meeting.json"))?;
        m.updated_at = Local::now();
        write_json(&dir.join("meeting.json"), &m)?;
        Ok(m)
    }

    /// Дописывает блок в конец заметки, отделяя пустой строкой. Возвращает заметку целиком.
    pub fn append_to_note(&self, id: &str, block: &str) -> Result<String, Error> {
        let _guard = self.lock();
        let dir = self.dir(id)?;
        let mut note = std::fs::read_to_string(dir.join("note.md")).unwrap_or_default();
        let trimmed = note.trim_end().len();
        note.truncate(trimmed);
        if !note.is_empty() {
            note.push_str("\n\n");
        }
        note.push_str(block.trim());
        note.push('\n');
        std::fs::write(dir.join("note.md"), &note)?;
        let mut m: Meeting = read_json(&dir.join("meeting.json"))?;
        m.updated_at = Local::now();
        write_json(&dir.join("meeting.json"), &m)?;
        Ok(note)
    }

    pub fn mark_transcribed(&self, id: &str, at: DateTime<Local>) -> Result<Meeting, Error> {
        self.update(id, |m| m.transcribed_at = Some(at))
    }

    pub fn rename(&self, id: &str, title: &str) -> Result<Meeting, Error> {
        let title = title.trim().to_string();
        if title.is_empty() {
            return Err(Error::BlankTitle);
        }
        self.update(id, |m| m.title = title.clone())
    }

    pub fn delete(&self, id: &str) -> Result<(), Error> {
        let _guard = self.lock();
        let dir = self.dir(id)?;
        std::fs::remove_dir_all(dir)?;
        Ok(())
    }

    /// Запись, оборванная выключением компьютера, перестаёт считаться идущей;
    /// дорожки на диске остаются — их можно расшифровать.
    pub fn recover_interrupted(&self) -> Result<Vec<String>, Error> {
        let mut ids = Vec::new();
        for m in self.list()? {
            if m.recording {
                self.update(&m.id, |m| m.recording = false)?;
                ids.push(m.id);
            }
        }
        Ok(ids)
    }

    fn update(&self, id: &str, change: impl Fn(&mut Meeting)) -> Result<Meeting, Error> {
        let _guard = self.lock();
        let path = self.dir(id)?.join("meeting.json");
        let mut m: Meeting = read_json(&path)?;
        change(&mut m);
        m.updated_at = Local::now();
        write_json(&path, &m)?;
        Ok(m)
    }

    /// Папка встречи. Идентификатор приходит из интерфейса: в путь попадают
    /// только цифры и дефис.
    fn dir(&self, id: &str) -> Result<PathBuf, Error> {
        let valid = !id.is_empty() && id.len() <= 32 && id.chars().all(|c| c.is_ascii_digit() || c == '-');
        let dir = self.root.join(id);
        if valid && dir.join("meeting.json").is_file() {
            Ok(dir)
        } else {
            Err(Error::NotFound(id.to_string()))
        }
    }

    fn free_id(&self, now: DateTime<Local>) -> String {
        let base = now.format("%Y%m%d-%H%M%S").to_string();
        let mut id = base.clone();
        let mut n = 1;
        while self.root.join(&id).exists() {
            n += 1;
            id = format!("{base}-{n}");
        }
        id
    }
}

/// Имя файла без символов, которые Windows не разрешает.
fn sanitize_file_name(name: &str) -> String {
    let clean: String = name
        .chars()
        .map(|c| {
            if r#"<>:"/\|?*"#.contains(c) || c.is_control() {
                '_'
            } else {
                c
            }
        })
        .collect();
    let clean = clean.trim_matches(['.', ' ']).to_string();
    if clean.is_empty() {
        "audio".into()
    } else {
        clean
    }
}

fn read_json<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, Error> {
    Ok(serde_json::from_str(&std::fs::read_to_string(path)?)?)
}

fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), Error> {
    let tmp = path.with_extension("json.tmp");
    std::fs::write(&tmp, serde_json::to_vec_pretty(value)?)?;
    std::fs::rename(&tmp, path)?;
    Ok(())
}

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

    fn at(h: u32, m: u32) -> DateTime<Local> {
        Local.with_ymd_and_hms(2026, 9, 30, h, m, 0).unwrap()
    }

    #[test]
    fn create_list_get() {
        let dir = tempfile::tempdir().unwrap();
        let store = Store::new(dir.path().join("m"));
        assert!(store.list().unwrap().is_empty(), "нет папки — пустой список");
        let a = store.create("Планёрка", at(10, 0)).unwrap();
        let b = store.create("Созвон", at(11, 0)).unwrap();
        assert_eq!(a.id, "20260930-100000");
        let ids: Vec<_> = store.list().unwrap().into_iter().map(|m| m.id).collect();
        assert_eq!(ids, vec![b.id, a.id.clone()], "свежие сверху");
        assert_eq!(store.get(&a.id).unwrap().note, "");
        assert!(matches!(store.create("  ", at(12, 0)), Err(Error::BlankTitle)));
    }

    #[test]
    fn same_second_gets_a_new_id() {
        let dir = tempfile::tempdir().unwrap();
        let store = Store::new(dir.path());
        assert_eq!(store.create("Раз", at(10, 0)).unwrap().id, "20260930-100000");
        assert_eq!(store.create("Два", at(10, 0)).unwrap().id, "20260930-100000-2");
    }

    #[test]
    fn note_is_saved_and_blocks_are_appended() {
        let dir = tempfile::tempdir().unwrap();
        let store = Store::new(dir.path());
        let m = store.create("Встреча", at(10, 0)).unwrap();
        assert_eq!(
            store.append_to_note(&m.id, "## Расшифровка\n\nтекст").unwrap(),
            "## Расшифровка\n\nтекст\n"
        );
        store
            .save_note(&m.id, "# Мои заметки\n\n## Расшифровка\n\nтекст\n\n\n")
            .unwrap();
        let note = store
            .append_to_note(&m.id, "\n## Задачи\n\n- [ ] отчёт\n")
            .unwrap();
        assert_eq!(
            note,
            "# Мои заметки\n\n## Расшифровка\n\nтекст\n\n## Задачи\n\n- [ ] отчёт\n"
        );
        assert_eq!(store.get(&m.id).unwrap().note, note);
    }

    #[test]
    fn audio_file_is_copied_in_with_a_safe_name() {
        let dir = tempfile::tempdir().unwrap();
        let store = Store::new(dir.path().join("m"));
        let m = store.create("Диктофон", at(10, 0)).unwrap();
        let src = dir.path().join("Запись: 1?.m4a");
        std::fs::write(&src, b"audio").unwrap();
        let m = store.attach_audio_file(&m.id, &src, 61_000).unwrap();
        assert_eq!(m.audio[0].file, "Запись_ 1_.m4a");
        assert_eq!(m.audio[0].speaker, Speaker::Unknown);
        assert_eq!(m.duration_ms(), 61_000);
        assert_eq!(
            std::fs::read(store.audio_path(&m.id, &m.audio[0].file).unwrap()).unwrap(),
            b"audio"
        );
    }

    #[test]
    fn rename_delete_and_bad_paths() {
        let dir = tempfile::tempdir().unwrap();
        let store = Store::new(dir.path().join("m"));
        let m = store.create("Старое", at(10, 0)).unwrap();
        assert_eq!(store.rename(&m.id, " Новое ").unwrap().title, "Новое");
        assert!(matches!(store.rename(&m.id, ""), Err(Error::BlankTitle)));
        assert!(store.audio_path(&m.id, "../meeting.json").is_err());
        assert!(store.audio_path(&m.id, "..").is_err());
        for id in ["../x", "", "a/b"] {
            assert!(matches!(store.get(id), Err(Error::NotFound(_))), "{id}");
        }
        store.delete(&m.id).unwrap();
        assert!(store.list().unwrap().is_empty());
    }

    #[test]
    fn interrupted_recording_is_released_on_start() {
        let dir = tempfile::tempdir().unwrap();
        let store = Store::new(dir.path());
        let m = store.create("Запись", at(10, 0)).unwrap();
        let track = AudioTrack {
            file: "me.wav".into(),
            speaker: Speaker::Me,
            duration_ms: 5_000,
        };
        store.set_recording(&m.id, true, vec![track.clone()]).unwrap();
        assert_eq!(store.recover_interrupted().unwrap(), vec![m.id.clone()]);
        let after = store.get(&m.id).unwrap().meeting;
        assert!(!after.recording);
        assert_eq!(
            after.audio,
            vec![track],
            "дорожки остаются — их можно расшифровать"
        );
    }
}