feat(export): portable bundle (meeting.json + summary.json) + import_meeting_bundle (FR-STORE-4)

This commit is contained in:
iamdoubz
2026-07-11 22:42:07 -05:00
parent 25204c0235
commit 4dff7a58b5
+181
View File
@@ -2327,6 +2327,154 @@ pub async fn bulk_export_meetings(
Ok(count)
}
/// Import meeting bundle(s) exported with `format: "bundle"` (FR-STORE-4) — the
/// other half of moving recordings between computers. `dir` is either a single
/// bundle folder (contains `meeting.json`) or a parent folder of bundles (from
/// a bulk export); every bundle found is reconstructed under a fresh meeting id
/// so re-importing never collides with existing meetings. Returns the count
/// imported.
#[tauri::command]
pub async fn import_meeting_bundle(
state: State<'_, AppState>,
dir: String,
) -> WaResult<u32> {
let root = PathBuf::from(&dir);
let mut bundles: Vec<PathBuf> = Vec::new();
if root.join("meeting.json").exists() {
bundles.push(root.clone());
} else {
let entries =
std::fs::read_dir(&root).map_err(|e| WaError::new("import", e.to_string()))?;
for entry in entries.flatten() {
let p = entry.path();
if p.is_dir() && p.join("meeting.json").exists() {
bundles.push(p);
}
}
}
if bundles.is_empty() {
return Err(WaError::new(
"import",
"no meeting.json found — pick a folder exported with \"Bundle\"",
));
}
bundles.sort();
let mut count = 0u32;
for b in &bundles {
match import_one_bundle(&state, b).await {
Ok(_) => count += 1,
Err(e) => tracing::warn!("bundle import skipped {}: {}", b.display(), e.message),
}
}
Ok(count)
}
/// Reconstruct one exported bundle folder into a new meeting. Order matters:
/// create → finalize (writes transcript.json/speakers/status) → restore dates →
/// copy audio/summary → notes → tags → action items.
async fn import_one_bundle(state: &State<'_, AppState>, dir: &Path) -> WaResult<MeetingId> {
let manifest = std::fs::read_to_string(dir.join("meeting.json"))
.map_err(|e| WaError::new("import", e.to_string()))?;
let bundle: crate::models::MeetingBundle = serde_json::from_str(&manifest)
.map_err(|e| WaError::new("import", format!("bad meeting.json: {e}")))?;
// Segments live in transcript.json (plaintext in an export); pull just the
// array rather than depending on storage's private TranscriptFile shape.
let segments: Vec<crate::models::TranscriptSegment> =
std::fs::read_to_string(dir.join("transcript.json"))
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| serde_json::from_value(v.get("segments")?.clone()).ok())
.unwrap_or_default();
let id = state
.store
.create_meeting(NewMeeting {
title: bundle.title.clone(),
calendar_event_id: None,
template_id: bundle.template_id.clone(),
language: bundle.language.clone(),
})
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
state
.store
.finalize_meeting(
&id,
FinalizeMeeting {
segments,
speakers: bundle.speakers.clone(),
duration_secs: bundle.duration_secs.unwrap_or(0),
recorded: bundle.recorded,
language: bundle.language.clone(),
backend_used: bundle.backend_used.clone(),
model_used: bundle.model_used.clone(),
},
)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
state
.store
.set_meeting_times(&id, bundle.started_at, bundle.ended_at)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let dest_dir = meeting_dir(&id);
// Audio: copy byte-for-byte, mirroring the export's copy-as-is. ponytail:
// if the source machine sealed audio at rest under a different vault key,
// it won't decrypt here — cross-machine vault key transfer is out of scope;
// upgrade path is decrypting audio into the bundle on export.
let audio_src = dir.join("audio.wav");
if audio_src.exists() {
std::fs::copy(&audio_src, dest_dir.join("audio.wav"))
.map_err(|e| WaError::new("import", e.to_string()))?;
}
// notes.md
if let Ok(notes) = std::fs::read_to_string(dir.join("notes.md")) {
state
.store
.update_notes(&id, &notes)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
}
// summary.json — reseal at rest when the vault is unlocked (T8.8), same as
// generate_summary's write path.
let summary_src = dir.join("summary.json");
if summary_src.exists() {
if let Ok(raw) = std::fs::read(&summary_src) {
if let Ok(sealed) = crate::vault::seal(&raw) {
let _ = std::fs::write(dest_dir.join("summary.json"), sealed);
}
}
}
if !bundle.tags.is_empty() {
state
.store
.set_tags(&id, &bundle.tags)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
}
if !bundle.action_items.is_empty() {
// Drop source ids so each inserts fresh under the new meeting id.
let items: Vec<ActionItem> = bundle
.action_items
.iter()
.map(|i| ActionItem {
id: None,
..i.clone()
})
.collect();
state
.store
.save_action_items(&id, &items)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
}
Ok(id)
}
/// A filesystem-safe, collision-resistant filename stem: sanitized title +
/// an id prefix, since meeting titles are very often duplicates ("Untitled
/// meeting") and would otherwise silently overwrite each other in a batch.
@@ -2409,6 +2557,39 @@ async fn export_meeting_to(
}
std::fs::write(dest_path.join("notes.md"), &meeting.notes_markdown)
.map_err(|e| WaError::new("export", e.to_string()))?;
// summary.json may be vault-sealed; export the decrypted content so
// the bundle is portable (T8.8), same as transcript.json above.
let summary_src = source_dir.join("summary.json");
if summary_src.exists() {
let bytes = std::fs::read(&summary_src)
.map_err(|e| WaError::new("export", e.to_string()))?;
let plain =
crate::vault::open(&bytes).map_err(|e| WaError::new("vault", e.to_string()))?;
std::fs::write(dest_path.join("summary.json"), plain)
.map_err(|e| WaError::new("export", e.to_string()))?;
}
// meeting.json — the portable manifest that makes a bundle
// re-importable on another machine (FR-STORE-4). meeting.action_items
// is table-backed (Feature 3), so confirmed items travel too.
let bundle = crate::models::MeetingBundle {
schema: 1,
title: meeting.title.clone(),
started_at: meeting.started_at,
ended_at: meeting.ended_at,
duration_secs: meeting.duration_secs,
language: meeting.language.clone(),
backend_used: meeting.backend_used.clone(),
model_used: meeting.model_used.clone(),
recorded: meeting.recorded,
template_id: meeting.template_id.clone(),
tags: meeting.tags.clone(),
speakers: meeting.speakers.clone(),
action_items: meeting.action_items.clone(),
};
let manifest = serde_json::to_string_pretty(&bundle)
.map_err(|e| WaError::new("export", e.to_string()))?;
std::fs::write(dest_path.join("meeting.json"), manifest)
.map_err(|e| WaError::new("export", e.to_string()))?;
}
other => {
return Err(WaError::new(