feat(export): 'obsidian' format — one vault note (notes+summary+action items+transcript, no audio) (FR-STORE-4)
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user