feat(export): 'obsidian' format — one vault note (notes+summary+action items+transcript, no audio) (FR-STORE-4)

This commit is contained in:
iamdoubz
2026-07-12 09:32:23 -05:00
parent d5aef2966f
commit dbe845e923
+139
View File
@@ -2591,6 +2591,12 @@ async fn export_meeting_to(
std::fs::write(dest_path.join("meeting.json"), manifest)
.map_err(|e| WaError::new("export", e.to_string()))?;
}
"obsidian" => {
// One self-contained vault note — no audio (FR-STORE-4). Everything
// is already on `meeting`, so this is pure formatting.
std::fs::write(dest_path, build_obsidian_note(&meeting))
.map_err(|e| WaError::new("export", e.to_string()))?;
}
other => {
return Err(WaError::new(
"export",
@@ -2601,6 +2607,139 @@ async fn export_meeting_to(
Ok(())
}
/// Render a meeting as a single portable Obsidian note (FR-STORE-4): YAML
/// frontmatter + notes + summary + decisions + action items + a timestamped
/// transcript, deliberately without audio. All fields come off the
/// already-loaded `Meeting`, so this does no I/O of its own.
fn build_obsidian_note(meeting: &crate::storage::Meeting) -> String {
use std::fmt::Write as _;
// Resolve a segment/participant speaker label to its display name, mirroring
// the frontend's `speakerName`.
let name_of = |label: &str| -> String {
meeting
.speakers
.iter()
.find(|sp| sp.label == label)
.and_then(|sp| sp.display_name.clone())
.unwrap_or_else(|| label.to_string())
};
let mut md = String::new();
md.push_str("---\n");
let _ = writeln!(md, "title: {}", yaml_str(&meeting.title));
let _ = writeln!(md, "date: {}", fmt_date(meeting.started_at));
if let Some(secs) = meeting.duration_secs {
let _ = writeln!(md, "duration: {}", fmt_duration(secs));
}
let participants: Vec<String> = meeting.speakers.iter().map(|s| name_of(&s.label)).collect();
if !participants.is_empty() {
let joined = participants
.iter()
.map(|p| yaml_str(p))
.collect::<Vec<_>>()
.join(", ");
let _ = writeln!(md, "participants: [{joined}]");
}
if !meeting.tags.is_empty() {
let joined = meeting
.tags
.iter()
.map(|t| yaml_str(t))
.collect::<Vec<_>>()
.join(", ");
let _ = writeln!(md, "tags: [{joined}]");
}
md.push_str("source: WhispAssist\n---\n\n");
if !meeting.notes_markdown.trim().is_empty() {
let _ = write!(md, "## Notes\n\n{}\n\n", meeting.notes_markdown.trim());
}
if let Some(sum) = &meeting.summary {
if !sum.summary_md.trim().is_empty() {
let _ = write!(md, "## Summary\n\n{}\n\n", sum.summary_md.trim());
}
if !sum.decisions.is_empty() {
md.push_str("## Decisions\n\n");
for d in &sum.decisions {
let _ = writeln!(md, "- {d}");
}
md.push('\n');
}
}
if !meeting.action_items.is_empty() {
md.push_str("## Action items\n\n");
for a in &meeting.action_items {
let check = if a.confirmed { "x" } else { " " };
let owner = a
.owner
.as_deref()
.map(|o| format!(" — {o}"))
.unwrap_or_default();
let due = a
.due_at
.map(|d| format!(" (due {})", fmt_date(d)))
.unwrap_or_default();
let _ = writeln!(md, "- [{check}] {}{owner}{due}", a.text);
}
md.push('\n');
}
if !meeting.segments.is_empty() {
md.push_str("## Transcript\n\n");
for seg in &meeting.segments {
let _ = writeln!(
md,
"**{}** {}: {}",
fmt_ts(seg.start_ms),
name_of(&seg.speaker),
seg.text.trim()
);
}
md.push('\n');
}
md
}
/// Quote a value for a YAML frontmatter scalar — always double-quoted and
/// escaped, so titles/tags containing `:`/`"`/`#` can't break the block.
fn yaml_str(s: &str) -> String {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
}
/// Unix seconds → `YYYY-MM-DD` in local time (matches how the UI shows dates).
fn fmt_date(unix: i64) -> String {
use chrono::TimeZone as _;
chrono::Local
.timestamp_opt(unix, 0)
.single()
.map(|dt| dt.format("%Y-%m-%d").to_string())
.unwrap_or_default()
}
/// Seconds → a compact `1h 5m` / `32m` / `48s` duration.
fn fmt_duration(secs: i64) -> String {
let secs = secs.max(0);
let (h, m, s) = (secs / 3600, (secs % 3600) / 60, secs % 60);
if h > 0 {
format!("{h}h {m}m")
} else if m > 0 {
format!("{m}m")
} else {
format!("{s}s")
}
}
/// Milliseconds → `m:ss` (or `h:mm:ss` past an hour), matching the transcript
/// timestamp prefix shown in the UI.
fn fmt_ts(ms: u64) -> String {
let total = ms / 1000;
let (h, m, s) = (total / 3600, (total % 3600) / 60, total % 60);
if h > 0 {
format!("{h}:{m:02}:{s:02}")
} else {
format!("{m}:{s:02}")
}
}
// ---- LLM (Phase 5) ----
/// Builds the configured `LlmProvider`, or `None` if LLM integration is off