skillok meeting
955f445c6196281d40574259b51317a618aa5376
Перейти к файлу
Код
meeting src-taurisrcmeetings.rs
anc Интерфейс по руководству «Стиль Skillok»; одни часы записи на весь экран По skillok/skillok-develop design/SKILLOK_STYLE.md: - кнопки и поля — --radius-control 2px; поля — --field-bg, 9/11, 13px, фокус терракотой внутрь; неактивная кнопка — 0,5; .ui-danger; мишени 44px под пальцем; - подписи полей — 12px капс с разрядкой; карточки без заливки, отступы 14–16px; вкладки 32px без жирного; моноширинный — только код, числа — tabular-nums; тень — только у сообщения; - значки — icons.ts (SVG 16, контур 1,5) вместо «★ ▶ ❚❚ ✕»; - обращение на «ты» в интерфейсе и сообщениях ядра; сняты пояснения «как пользоваться»; блок проверки звука — .audio-check (не пересекается с галочкой .check). Светлая тема остаётся единственной — решение владельца для Meet (руководство просит обе темы). Шапка и меню показывают время по записанному звуку, как панель записи. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> 955f445 4 октября 2026 г.
574 строки 21.6 КБ
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>>,
    /// Отметки «важный момент» — кнопкой во время записи или в проигрывателе.
    #[serde(default)]
    pub markers: Vec<Marker>,
}

/// Отметка момента записи.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Marker {
    /// Место в звуке от начала записи, мс (паузы не в счёт).
    pub ms: u64,
    /// Подпись; пусто — просто «Отметка».
    #[serde(default)]
    pub text: String,
}

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,
    /// Есть реплики с отпечатками голосов: разбиение по говорящим можно
    /// пересобрать без новой расшифровки.
    pub voices: bool,
}

#[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(),
            voices: dir.join(VOICES_FILE).is_file(),
        })
    }

    /// Реплики с отпечатками голосов (см. `speakers.rs`).
    pub fn save_voices(&self, id: &str, voices: &crate::speakers::Voices) -> Result<(), Error> {
        let dir = self.dir(id)?;
        write_json(&dir.join(VOICES_FILE), voices)
    }

    pub fn load_voices(&self, id: &str) -> Result<crate::speakers::Voices, Error> {
        read_json(&self.dir(id)?.join(VOICES_FILE))
    }

    /// Новая встреча: пустая заметка, звука нет.
    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,
            markers: Vec::new(),
        };
        std::fs::write(dir.join("note.md"), "")?;
        // meeting.json последним: пока его нет, список встречу не видит.
        write_json(&dir.join("meeting.json"), &meeting)?;
        Ok(meeting)
    }

    /// Папка встречи: заметка и звук.
    pub fn folder(&self, id: &str) -> Result<PathBuf, Error> {
        self.dir(id)
    }

    /// Путь для дорожки внутри встречи.
    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 upsert_section(&self, id: &str, heading: &str, block: &str) -> Result<String, Error> {
        self.change_note(id, |note| crate::notes::upsert(note, heading, block))
    }

    pub fn remove_section(&self, id: &str, heading: &str) -> Result<String, Error> {
        self.change_note(id, |note| crate::notes::remove(note, heading))
    }

    fn change_note(&self, id: &str, change: impl Fn(&str) -> String) -> Result<String, Error> {
        let _guard = self.lock();
        let dir = self.dir(id)?;
        let note = change(&std::fs::read_to_string(dir.join("note.md")).unwrap_or_default());
        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 add_marker(&self, id: &str, marker: Marker) -> Result<Meeting, Error> {
        self.update(id, |m| {
            m.markers.push(marker.clone());
            m.markers.sort_by_key(|k| k.ms);
        })
    }

    /// Все отметки разом — правка подписи и удаление идут через интерфейс.
    pub fn set_markers(&self, id: &str, markers: &[Marker]) -> Result<Meeting, Error> {
        let mut sorted: Vec<Marker> = markers
            .iter()
            .map(|k| Marker {
                ms: k.ms,
                text: k.text.trim().chars().take(200).collect(),
            })
            .collect();
        sorted.sort_by_key(|k| k.ms);
        self.update(id, |m| m.markers = sorted.clone())
    }

    /// Папка встречи на диске — для проигрывателя.
    pub fn meeting_dir(&self, id: &str) -> Result<PathBuf, Error> {
        self.dir(id)
    }

    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 move_all_to(&self, target: &Path) -> Result<usize, Error> {
        let _guard = self.lock();
        std::fs::create_dir_all(target)?;
        if same_dir(&self.root, target) {
            return Ok(0);
        }
        let ids: Vec<String> = match std::fs::read_dir(&self.root) {
            Ok(entries) => entries
                .flatten()
                .filter(|e| e.path().join("meeting.json").is_file())
                .filter_map(|e| e.file_name().to_str().map(str::to_string))
                .collect(),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
            Err(e) => return Err(e.into()),
        };
        if let Some(clash) = ids.iter().find(|id| target.join(id).exists()) {
            return Err(Error::Io(std::io::Error::new(
                std::io::ErrorKind::AlreadyExists,
                format!("в новой папке уже есть запись «{clash}» — выбери пустую папку"),
            )));
        }
        for id in &ids {
            let from = self.root.join(id);
            let to = target.join(id);
            if std::fs::rename(&from, &to).is_err() {
                copy_dir(&from, &to)?;
                std::fs::remove_dir_all(&from)?;
            }
        }
        Ok(ids.len())
    }

    /// Запись, оборванная выключением компьютера, перестаёт считаться идущей;
    /// дорожки на диске остаются — их можно расшифровать.
    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
    }
}

fn same_dir(a: &Path, b: &Path) -> bool {
    match (a.canonicalize(), b.canonicalize()) {
        (Ok(a), Ok(b)) => a == b,
        _ => a == b,
    }
}

fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> {
    std::fs::create_dir_all(to)?;
    for entry in std::fs::read_dir(from)? {
        let entry = entry?;
        let target = to.join(entry.file_name());
        if entry.file_type()?.is_dir() {
            copy_dir(&entry.path(), &target)?;
        } else {
            std::fs::copy(entry.path(), &target)?;
        }
    }
    Ok(())
}

/// Имя файла без символов, которые 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
    }
}

/// Реплики с отпечатками голосов — рядом с заметкой.
const VOICES_FILE: &str = "voices.json";

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 sections_are_placed_and_removed() {
        let dir = tempfile::tempdir().unwrap();
        let store = Store::new(dir.path());
        let m = store.create("Встреча", at(10, 0)).unwrap();
        store
            .upsert_section(&m.id, "Расшифровка", "## Расшифровка\n\nтекст")
            .unwrap();
        store
            .upsert_section(&m.id, "Задачи", "## Задачи\n\n- [ ] отчёт")
            .unwrap();
        let note = store
            .upsert_section(&m.id, "Расшифровка", "## Расшифровка\n\nновый текст")
            .unwrap();
        assert_eq!(
            note,
            "## Расшифровка\n\nновый текст\n\n## Задачи\n\n- [ ] отчёт\n"
        );
        assert_eq!(
            store.remove_section(&m.id, "Задачи").unwrap(),
            "## Расшифровка\n\nновый текст\n"
        );
        assert_eq!(store.get(&m.id).unwrap().note, "## Расшифровка\n\nновый текст\n");
    }

    #[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 meetings_move_to_a_new_folder() {
        let dir = tempfile::tempdir().unwrap();
        let old = Store::new(dir.path().join("old"));
        let a = old.create("Раз", at(10, 0)).unwrap();
        let b = old.create("Два", at(11, 0)).unwrap();
        std::fs::write(old.audio_path(&a.id, "me.wav").unwrap(), b"sound").unwrap();
        old.upsert_section(&b.id, "Задачи", "## Задачи\n\n- [ ] x")
            .unwrap();

        let target = dir.path().join("Записи встреч");
        assert_eq!(old.move_all_to(&target).unwrap(), 2);
        let new = Store::new(&target);
        assert_eq!(new.list().unwrap().len(), 2);
        assert_eq!(
            std::fs::read(new.audio_path(&a.id, "me.wav").unwrap()).unwrap(),
            b"sound"
        );
        assert!(new.get(&b.id).unwrap().note.contains("- [ ] x"));
        assert!(
            old.list().unwrap().is_empty(),
            "в старой папке ничего не осталось"
        );
        assert_eq!(
            new.move_all_to(&target).unwrap(),
            0,
            "та же папка — ничего не делаем"
        );
    }

    #[test]
    fn move_refuses_to_overwrite() {
        let dir = tempfile::tempdir().unwrap();
        let old = Store::new(dir.path().join("old"));
        let a = old.create("Раз", at(10, 0)).unwrap();
        let target = dir.path().join("new");
        std::fs::create_dir_all(target.join(&a.id)).unwrap();
        assert!(old.move_all_to(&target).is_err());
        assert_eq!(old.list().unwrap().len(), 1, "ничего не тронуто");
    }

    #[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],
            "дорожки остаются — их можно расшифровать"
        );
    }
}