feat(export): portable bundle (meeting.json + summary.json) + import_meeting_bundle (FR-STORE-4)
This commit is contained in:
@@ -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, ¬es)
|
||||
.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(
|
||||
|
||||
Reference in New Issue
Block a user