//! Import data from ZIP archives exported by the export module. //! //! Import actions: //! - AddAsIdentity: create a new identity from the export's key + data //! - ImportPublicPosts: import only public posts into the current identity (new PostIds) //! - MergeWithKey: decrypt encrypted posts using provided key, re-encrypt for current identity use std::io::Read; use std::path::Path; use serde::{Deserialize, Serialize}; use tracing::{debug, info, warn}; use crate::blob::BlobStore; use crate::content::compute_post_id; use crate::export::{ExportManifest, ExportedPost}; use crate::storage::StoragePool; use crate::types::{Attachment, NodeId, Post, PostVisibility, PostingIdentity, VisibilityIntent}; /// Parse the Debug-formatted intent string that the export writes /// (`format!("{:?}", visibility_intent)`) back into a `VisibilityIntent`. /// /// Falls back to a visibility-shape heuristic when the export carries no /// intent (pre-v0.6.1 source DBs never populated the intent column, so the /// export writes `None`). Without this fallback, old exports imported as a /// persona would bucket every encrypted post under `Friends` and DMs would /// silently disappear from the Messages tab. fn parse_exported_intent(raw: Option<&str>, vis: &PostVisibility) -> VisibilityIntent { // Try a clean prefix match on the Debug form first. if let Some(s) = raw { let s = s.trim(); if s == "Public" { return VisibilityIntent::Public; } if s == "Friends" { return VisibilityIntent::Friends; } if s.starts_with("Circle(") { // `Circle("name")` — pull the quoted string. if let Some(q) = s.find('"') { if let Some(end) = s.rfind('"') { if end > q { let name = &s[q + 1..end]; return VisibilityIntent::Circle(name.to_string()); } } } return VisibilityIntent::Circle(String::new()); } if s.starts_with("Direct(") { // Debug form of Vec is messy to parse reliably. Recover // the recipient list from the PostVisibility if available; the // set is semantically equivalent (both derive from the same // wrapping list). if let PostVisibility::Encrypted { recipients } = vis { let ids: Vec = recipients.iter().map(|wk| wk.recipient).collect(); return VisibilityIntent::Direct(ids); } return VisibilityIntent::Direct(vec![]); } if s == "Control" { return VisibilityIntent::Control; } if s == "Profile" { return VisibilityIntent::Profile; } if s == "Announcement" { return VisibilityIntent::Announcement; } if s == "GroupKeyDistribute" { return VisibilityIntent::GroupKeyDistribute; } } // No intent recorded — infer from the visibility shape. match vis { PostVisibility::Public => VisibilityIntent::Public, // FoF Layer 3: FoFClosed pairs with VisibilityIntent::Public. // The FoF gating handles audience; intent is the structural tag. PostVisibility::FoFClosed => VisibilityIntent::Public, PostVisibility::Encrypted { recipients } => { // Heuristic: DMs typically wrap to 1-2 people (recipient + self); // Friends posts wrap to every public follow (usually many). // Use 3 as a threshold that treats small-group conversations as // Direct while letting broadcast-to-friends stay Friends. // // Rationale: the pre-intent export was built before we could // distinguish these cases, so we prefer a best-effort partition // that gets DMs into the Messages tab (where the user will look // for them) rather than filing them under Friends. if recipients.len() <= 3 { VisibilityIntent::Direct(recipients.iter().map(|wk| wk.recipient).collect()) } else { VisibilityIntent::Friends } } PostVisibility::GroupEncrypted { .. } => { // No way to recover the circle name from wire visibility alone. VisibilityIntent::Circle(String::new()) } } } /// Extract posting_identities.json from an export ZIP and upsert each entry /// into storage. Called during import so multi-persona users restore all /// their posting keys. Idempotent — INSERT OR IGNORE on conflict. No-op if /// the file is missing (pre-0.6.3 bundle). pub async fn restore_posting_identities( zip_path: &Path, storage: &StoragePool, ) -> anyhow::Result { let zip_path = zip_path.to_path_buf(); let identities: Vec = tokio::task::spawn_blocking(move || -> anyhow::Result> { let file = std::fs::File::open(&zip_path)?; let mut archive = zip::ZipArchive::new(file)?; let buf = { let mut entry = match archive.by_name("itsgoin-export/posting_identities.json") { Ok(e) => e, Err(_) => return Ok(Vec::new()), }; let mut s = String::new(); entry.read_to_string(&mut s)?; s }; Ok(serde_json::from_str(&buf).unwrap_or_default()) }).await??; let s = storage.get().await; let mut restored = 0usize; for id in &identities { s.upsert_posting_identity(id)?; restored += 1; } Ok(restored) } /// What to do with the imported data. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ImportAction { /// Create a new identity from the export's key and restore all data. AddAsIdentity, /// Import public posts into the current identity with new PostIds. ImportPublicPosts, /// Decrypt with the provided key, re-create posts under current identity. MergeWithKey { key_hex: String }, } /// Summary of what an import ZIP contains (shown to user before importing). #[derive(Debug, Serialize, Deserialize)] pub struct ImportSummary { pub node_id: String, pub scope: String, pub export_date: u64, pub post_count: usize, pub blob_count: usize, pub has_identity_key: bool, pub has_follows: bool, pub has_settings: bool, } /// Result of an import operation. #[derive(Debug, Serialize, Deserialize)] pub struct ImportResult { pub posts_imported: usize, pub posts_skipped: usize, pub blobs_imported: usize, pub message: String, } /// Read a ZIP and return a summary of its contents (without importing). pub fn read_import_summary(zip_path: &Path) -> anyhow::Result { let file = std::fs::File::open(zip_path)?; let mut archive = zip::ZipArchive::new(file)?; // Read manifest let manifest: ExportManifest = { let mut entry = archive.by_name("itsgoin-export/manifest.json")?; let mut buf = String::new(); entry.read_to_string(&mut buf)?; serde_json::from_str(&buf)? }; let has_key = archive.by_name("itsgoin-export/identity.key").is_ok(); let has_follows = archive.by_name("itsgoin-export/follows.json").is_ok(); let has_settings = archive.by_name("itsgoin-export/settings.json").is_ok(); Ok(ImportSummary { node_id: manifest.node_id, scope: format!("{:?}", manifest.scope), export_date: manifest.export_date, post_count: manifest.post_count, blob_count: manifest.blob_count, has_identity_key: has_key, has_follows, has_settings, }) } /// Parsed data from a ZIP ready for async import. struct ParsedImport { posts: Vec<(Post, PostVisibility, Vec<(Attachment, Vec)>)>, skipped: usize, } /// Staged content from a bundle, ready to write into the current identity /// without reparenting. Posts keep their original author/post_id/signatures; /// the bundle's posting keys become personas on this device. struct StagedImport { /// Posting identities to register (includes the bundle's identity.key and /// any entries from posting_identities.json). Deduped by node_id. posting_identities: Vec, /// Posts in the form (post_id, Post, PostVisibility, intent, blobs). posts: Vec<(crate::types::PostId, Post, PostVisibility, crate::types::VisibilityIntent, Vec<(Attachment, Vec)>)>, /// Follows to add to current identity's follow list. follows: Vec, /// Profiles to upsert (keyed by their own node_id, which becomes one of /// our personas or a remote peer). profiles: Vec, } /// Import a bundle as personas: add the source's posting keys to our /// `posting_identities`, and insert their posts AS-AUTHORED (no reparenting). /// Content encrypted to any of the imported keys becomes decryptable because /// we now hold those secrets. Idempotent — duplicate post ids / posting keys /// are skipped via ON CONFLICT handling. pub async fn import_as_personas( zip_path: &Path, storage: &StoragePool, blob_store: &BlobStore, ) -> anyhow::Result { let staged = { let zip_path = zip_path.to_path_buf(); tokio::task::spawn_blocking(move || -> anyhow::Result { let file = std::fs::File::open(&zip_path)?; let mut archive = zip::ZipArchive::new(file)?; // identity.key — the source device's primary key, which now // becomes a posting persona on our device. let mut posting_identities: Vec = Vec::new(); if let Ok(mut entry) = archive.by_name("itsgoin-export/identity.key") { let mut key_bytes = Vec::new(); entry.read_to_end(&mut key_bytes)?; if key_bytes.len() == 32 { let seed: [u8; 32] = key_bytes.as_slice().try_into().unwrap(); let sk = iroh::SecretKey::from_bytes(&seed); let nid: NodeId = *sk.public().as_bytes(); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); posting_identities.push(PostingIdentity { node_id: nid, secret_seed: seed, display_name: String::new(), created_at: now, }); } } // posting_identities.json (v0.6+ bundles) — additional personas. if let Ok(mut entry) = archive.by_name("itsgoin-export/posting_identities.json") { let mut buf = String::new(); entry.read_to_string(&mut buf)?; if let Ok(ids) = serde_json::from_str::>(&buf) { for id in ids { if !posting_identities.iter().any(|p| p.node_id == id.node_id) { posting_identities.push(id); } } } } // posts.json let posts_raw: Vec = match archive.by_name("itsgoin-export/posts.json") { Ok(mut entry) => { let mut buf = String::new(); entry.read_to_string(&mut buf)?; serde_json::from_str(&buf).unwrap_or_default() } Err(_) => Vec::new(), }; let mut staged_posts = Vec::new(); for ep in &posts_raw { let id_bytes = hex::decode(&ep.id).unwrap_or_default(); let post_id: crate::types::PostId = match id_bytes.as_slice().try_into() { Ok(id) => id, Err(_) => continue, }; let author_bytes = hex::decode(&ep.author).unwrap_or_default(); let author: NodeId = match author_bytes.as_slice().try_into() { Ok(a) => a, Err(_) => continue, }; let attachments: Vec = serde_json::from_str(&ep.attachments_json) .unwrap_or_default(); let vis: PostVisibility = serde_json::from_str(&ep.visibility_json) .unwrap_or(PostVisibility::Public); let post = Post { author, content: ep.content.clone(), attachments: attachments.clone(), timestamp_ms: ep.timestamp_ms, fof_gating: None, }; // Preserve the original visibility intent from the export. // Export stored it as a Debug-format string // (`format!("{:?}", intent)`), so we parse a prefix match. // When the export predates intent storage (source DB never // populated `visibility_intent`), fall back to a heuristic // based on the visibility shape. let intent = parse_exported_intent(ep.intent.as_deref(), &vis); // Read attached blobs let mut blobs = Vec::new(); for att in &attachments { let path = format!("itsgoin-export/blobs/{}", hex::encode(att.cid)); if let Ok(mut blob_entry) = archive.by_name(&path) { let mut data = Vec::new(); blob_entry.read_to_end(&mut data)?; blobs.push((att.clone(), data)); } } staged_posts.push((post_id, post, vis, intent, blobs)); } // follows.json (optional) let follows: Vec = match archive.by_name("itsgoin-export/follows.json") { Ok(mut entry) => { let mut buf = String::new(); entry.read_to_string(&mut buf)?; serde_json::from_str::>(&buf) .unwrap_or_default() .into_iter() .filter_map(|s| { let b = hex::decode(&s).ok()?; <[u8; 32]>::try_from(b.as_slice()).ok() }) .collect() } Err(_) => Vec::new(), }; // profiles.json (optional) let profiles: Vec = match archive.by_name("itsgoin-export/profiles.json") { Ok(mut entry) => { let mut buf = String::new(); entry.read_to_string(&mut buf)?; serde_json::from_str(&buf).unwrap_or_default() } Err(_) => Vec::new(), }; Ok(StagedImport { posting_identities, posts: staged_posts, follows, profiles, }) }).await?? }; // Phase 2: write into storage. let mut imported_posts = 0usize; let mut imported_blobs = 0usize; let mut imported_personas = 0usize; let mut skipped_posts = 0usize; // Posting identities first so the decrypt-any-persona path (feed render) // can find them immediately after this import call returns. If the // current device has exactly one posting identity (typically the one // auto-created on first launch) and we're importing additional ones, // switch the default to the first imported persona — the user's intent is // to pick up where they left off, not to post under a fresh throwaway. { let s = storage.get().await; let prior_count = s.count_posting_identities().unwrap_or(0); for pi in &staged.posting_identities { s.upsert_posting_identity(pi)?; imported_personas += 1; } if prior_count <= 1 { if let Some(first) = staged.posting_identities.first() { let _ = s.set_default_posting_id(&first.node_id); } } } // Posts + blobs. Content keeps its original post_id, author, signatures. for (post_id, post, vis, intent, blobs) in &staged.posts { let s = storage.get().await; if s.get_post(post_id).ok().flatten().is_some() { skipped_posts += 1; continue; } if crate::content::verify_post_id(post_id, post) { // Intent was parsed from the export (or heuristic-inferred when // the export predates intent storage). See `parse_exported_intent`. s.store_post_with_intent(post_id, post, vis, intent)?; imported_posts += 1; } else { warn!(post_id = hex::encode(post_id), "Skipping post with invalid signature during import"); skipped_posts += 1; continue; } for (att, data) in blobs { if !blob_store.has(&att.cid) { blob_store.store(&att.cid, data)?; } s.record_blob(&att.cid, post_id, &post.author, data.len() as u64, &att.mime_type, post.timestamp_ms)?; let _ = s.pin_blob(&att.cid); imported_blobs += 1; } } // Follows + profiles. { let s = storage.get().await; for f in &staged.follows { let _ = s.add_follow(f); } for p in &staged.profiles { let _ = s.store_profile(p); } } Ok(ImportResult { posts_imported: imported_posts, posts_skipped: skipped_posts, blobs_imported: imported_blobs, message: format!( "Imported {} personas, {} posts ({} skipped), {} blobs", imported_personas, imported_posts, skipped_posts, imported_blobs ), }) } /// Import public posts from a ZIP into the current identity. /// Creates new posts with the current node_id as author, preserving original timestamps. pub async fn import_public_posts( zip_path: &Path, storage: &StoragePool, blob_store: &BlobStore, our_node_id: &NodeId, ) -> anyhow::Result { // Phase 1: Read everything from ZIP synchronously (no Send requirement) let parsed = { let zip_path = zip_path.to_path_buf(); let our_node_id = *our_node_id; tokio::task::spawn_blocking(move || -> anyhow::Result { let file = std::fs::File::open(&zip_path)?; let mut archive = zip::ZipArchive::new(file)?; let posts: Vec = { let mut entry = archive.by_name("itsgoin-export/posts.json")?; let mut buf = String::new(); entry.read_to_string(&mut buf)?; serde_json::from_str(&buf)? }; let mut result_posts = Vec::new(); let mut skipped = 0usize; for ep in &posts { let vis: PostVisibility = serde_json::from_str(&ep.visibility_json).unwrap_or(PostVisibility::Public); if !matches!(vis, PostVisibility::Public) { skipped += 1; continue; } let attachments: Vec = serde_json::from_str(&ep.attachments_json).unwrap_or_default(); let new_post = Post { author: our_node_id, content: ep.content.clone(), attachments: attachments.clone(), timestamp_ms: ep.timestamp_ms, fof_gating: None, }; // Read blob data from archive let mut blob_data = Vec::new(); for att in &attachments { let cid_hex = hex::encode(att.cid); let blob_path = format!("itsgoin-export/blobs/{}", cid_hex); if let Ok(mut blob_entry) = archive.by_name(&blob_path) { let mut data = Vec::new(); blob_entry.read_to_end(&mut data)?; blob_data.push((att.clone(), data)); } } result_posts.push((new_post, vis, blob_data)); } Ok(ParsedImport { posts: result_posts, skipped }) }).await?? }; // Phase 2: Store to DB + blob store (async — needs storage.get().await) let mut imported = 0usize; let mut blobs_imported = 0usize; info!(post_count = parsed.posts.len(), skipped = parsed.skipped, "Import phase 2: storing to DB"); // Ensure we follow ourselves so imported posts appear in feed { let s = storage.get().await; let _ = s.add_follow(our_node_id); } let now = now_ms(); for (new_post, _vis, blob_data) in &parsed.posts { let new_id = compute_post_id(new_post); let s = storage.get().await; if s.get_post(&new_id).ok().flatten().is_some() { drop(s); debug!(post = hex::encode(new_id), "Import: skipping duplicate post"); continue; } // Store post with intent (matches create_post_with_visibility behavior) s.store_post_with_intent(&new_id, new_post, &PostVisibility::Public, &crate::types::VisibilityIntent::Public)?; // Store blobs + record them, matching normal post creation for (att, data) in blob_data { if !blob_store.has(&att.cid) { blob_store.store(&att.cid, data)?; } s.record_blob(&att.cid, &new_id, our_node_id, data.len() as u64, &att.mime_type, att.size_bytes)?; let _ = s.pin_blob(&att.cid); blobs_imported += 1; } // Create BlobHeader (matches what engagement/sync expects) let header = crate::types::BlobHeader { post_id: new_id, author: *our_node_id, reactions: vec![], comments: vec![], policy: crate::types::CommentPolicy::default(), updated_at: now, thread_splits: vec![], receipt_slots: vec![], comment_slots: vec![], prior_author: None, }; let header_json = serde_json::to_string(&header).unwrap_or_default(); let _ = s.store_blob_header(&new_id, our_node_id, &header_json, now); drop(s); imported += 1; debug!(imported, post = hex::encode(new_id), "Import: stored post"); } info!(imported, skipped = parsed.skipped, blobs = blobs_imported, "Public post import complete"); Ok(ImportResult { posts_imported: imported, posts_skipped: parsed.skipped, blobs_imported, message: format!("Imported {} posts ({} skipped), {} blobs", imported, parsed.skipped, blobs_imported), }) } /// Import a ZIP as a new identity (create identity subdir, extract everything). pub fn import_as_identity( zip_path: &Path, base_dir: &Path, ) -> anyhow::Result { let file = std::fs::File::open(zip_path)?; let mut archive = zip::ZipArchive::new(file)?; // Read manifest let manifest: ExportManifest = { let mut entry = archive.by_name("itsgoin-export/manifest.json")?; let mut buf = String::new(); entry.read_to_string(&mut buf)?; serde_json::from_str(&buf)? }; // Read identity key let key_data = { let mut entry = archive.by_name("itsgoin-export/identity.key") .map_err(|_| anyhow::anyhow!("Export doesn't contain an identity key"))?; let mut buf = Vec::new(); entry.read_to_end(&mut buf)?; buf }; // Create identity directory let id_dir = base_dir.join("identities").join(&manifest.node_id); if id_dir.exists() { anyhow::bail!("Identity {} already exists", &manifest.node_id[..12]); } std::fs::create_dir_all(&id_dir)?; // Write identity key let key_path = id_dir.join("identity.key"); std::fs::write(&key_path, &key_data)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let _ = std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)); } // Write metadata let now = now_ms(); let meta = serde_json::json!({ "display_name": format!("Imported {}", &manifest.node_id[..12]), "created_at": now, "last_used_at": now, }); std::fs::write(id_dir.join("meta.json"), serde_json::to_string_pretty(&meta)?)?; info!(identity = manifest.node_id, "Imported identity from ZIP — switch to it to restore data"); // Note: posts, blobs, follows, settings will be restored when the user switches to this // identity and opens the node. The full DB restore could be done here, but it's simpler // to let the user switch and then import posts via the import wizard. Ok(manifest.node_id) } /// Merge posts from another identity into the current one using the original key for decryption. /// Decrypts encrypted posts, creates new posts under the current identity, preserves timestamps. /// BlobHeader gets `prior_author` set for provenance. pub async fn merge_with_key( zip_path: &Path, original_key_hex: &str, storage: &StoragePool, blob_store: &BlobStore, our_node_id: &NodeId, _our_seed: &[u8; 32], ) -> anyhow::Result { // Derive the original identity from the provided key let original_seed_bytes = hex::decode(original_key_hex)?; let original_seed: [u8; 32] = original_seed_bytes.try_into() .map_err(|_| anyhow::anyhow!("key must be 32 bytes (64 hex chars)"))?; let original_secret_key = iroh::SecretKey::from_bytes(&original_seed); let original_node_id: NodeId = *original_secret_key.public().as_bytes(); // Phase 1: Read and decrypt everything from ZIP synchronously let parsed = { let zip_path = zip_path.to_path_buf(); let our_nid = *our_node_id; let orig_seed = original_seed; let orig_nid = original_node_id; tokio::task::spawn_blocking(move || -> anyhow::Result { let file = std::fs::File::open(&zip_path)?; let mut archive = zip::ZipArchive::new(file)?; let posts: Vec = { let mut entry = archive.by_name("itsgoin-export/posts.json")?; let mut buf = String::new(); entry.read_to_string(&mut buf)?; serde_json::from_str(&buf)? }; let mut result_posts = Vec::new(); let mut skipped = 0usize; for ep in &posts { let vis: PostVisibility = serde_json::from_str(&ep.visibility_json) .unwrap_or(PostVisibility::Public); let attachments: Vec = serde_json::from_str(&ep.attachments_json) .unwrap_or_default(); // Decrypt content if encrypted let plaintext = match &vis { PostVisibility::Public => ep.content.clone(), PostVisibility::Encrypted { recipients } => { match crate::crypto::decrypt_post( &ep.content, &orig_seed, &orig_nid, &orig_nid, recipients, ) { Ok(Some(text)) => text, Ok(None) => { debug!(post = ep.id, "Not a recipient of this post — skipping"); skipped += 1; continue; } Err(e) => { warn!(post = ep.id, error = %e, "Failed to decrypt post — skipping"); skipped += 1; continue; } } } PostVisibility::GroupEncrypted { .. } => { // Group decryption needs the group seed — skip for now debug!(post = ep.id, "Group-encrypted post — skipping (group merge not yet supported)"); skipped += 1; continue; } PostVisibility::FoFClosed => { // FoF Layer 3 import: skip for now. The recovered // post would need its fof_gating + CEK to decrypt, // and the receiving persona's keyring may not // include the right V_x. Re-issue via the author's // device is the supported path. debug!(post = ep.id, "FoFClosed post — skipping (import not yet supported)"); skipped += 1; continue; } }; // Create new post under our identity let new_post = Post { author: our_nid, content: plaintext, attachments: attachments.clone(), timestamp_ms: ep.timestamp_ms, fof_gating: None, }; // Read blob data from archive (may need decryption for encrypted posts) let mut blob_data = Vec::new(); for att in &attachments { let cid_hex = hex::encode(att.cid); let blob_path = format!("itsgoin-export/blobs/{}", cid_hex); if let Ok(mut blob_entry) = archive.by_name(&blob_path) { let mut data = Vec::new(); blob_entry.read_to_end(&mut data)?; // If the post was encrypted, blobs are also encrypted with the same CEK if matches!(vis, PostVisibility::Encrypted { .. }) { if let PostVisibility::Encrypted { ref recipients } = vis { if let Ok(Some(cek)) = crate::crypto::unwrap_cek_for_recipient( &orig_seed, &orig_nid, &orig_nid, recipients, ) { if let Ok(decrypted) = crate::crypto::decrypt_bytes_with_cek(&data, &cek) { data = decrypted; } } } } blob_data.push((att.clone(), data)); } } // Merged posts go in as public (decrypted content, new author) result_posts.push((new_post, PostVisibility::Public, blob_data)); } Ok(ParsedImport { posts: result_posts, skipped }) }).await?? }; // Phase 2: Store with prior_author provenance let mut imported = 0usize; let mut blobs_imported = 0usize; for (new_post, _vis, blob_data) in &parsed.posts { let new_id = compute_post_id(new_post); let s = storage.get().await; if s.get_post(&new_id).ok().flatten().is_some() { continue; } s.store_post_with_visibility(&new_id, new_post, &PostVisibility::Public)?; // Create BlobHeader with prior_author let now = now_ms(); let header = crate::types::BlobHeader { post_id: new_id, author: *our_node_id, reactions: vec![], comments: vec![], policy: crate::types::CommentPolicy::default(), updated_at: now, thread_splits: vec![], receipt_slots: vec![], comment_slots: vec![], prior_author: Some(original_node_id), }; let header_json = serde_json::to_string(&header).unwrap_or_default(); let _ = s.store_blob_header(&new_id, our_node_id, &header_json, now); drop(s); for (att, data) in blob_data { if !blob_store.has(&att.cid) { blob_store.store(&att.cid, data)?; let s = storage.get().await; let _ = s.record_blob(&att.cid, &new_id, our_node_id, data.len() as u64, &att.mime_type, att.size_bytes); blobs_imported += 1; } } imported += 1; } info!( imported, skipped = parsed.skipped, blobs = blobs_imported, original = hex::encode(original_node_id), "Merge with key complete" ); Ok(ImportResult { posts_imported: imported, posts_skipped: parsed.skipped, blobs_imported, message: format!( "Merged {} posts from {} ({} skipped), {} blobs", imported, &hex::encode(original_node_id)[..12], parsed.skipped, blobs_imported ), }) } fn now_ms() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64 }