Skip to main content

max / goingson

11.3 KB · 353 lines History Blame Raw
1 //! External import commands for vCard and iCalendar files.
2 //!
3 //! Provides preview (dry-run) and import (create records) for .vcf and .ics files.
4
5 use chrono::NaiveDate;
6 use serde::Serialize;
7 use std::sync::Arc;
8 use tauri::State;
9 use tracing::instrument;
10
11 use goingson_core::{
12 NewContact, NewContactCustomField, NewContactEmail, NewContactPhone,
13 NewEvent, NewSocialHandle, Recurrence,
14 };
15
16 /// Maximum import file size (50 MB).
17 const MAX_IMPORT_FILE_SIZE: u64 = 50 * 1024 * 1024;
18
19 fn read_import_file(path: &str) -> Result<String, ApiError> {
20 let metadata = std::fs::metadata(path)
21 .map_err(|e| ApiError::internal(format!("Failed to stat file: {}", e)))?;
22 if metadata.len() > MAX_IMPORT_FILE_SIZE {
23 return Err(ApiError::validation_msg(format!(
24 "File is too large ({} bytes, max {} bytes)",
25 metadata.len(),
26 MAX_IMPORT_FILE_SIZE
27 )));
28 }
29 std::fs::read_to_string(path)
30 .map_err(|e| ApiError::internal(format!("Failed to read file: {}", e)))
31 }
32
33 use crate::external_sync::{ical, vcard};
34 use crate::state::{AppState, DESKTOP_USER_ID};
35 use super::ApiError;
36
37 // ============ Result Types ============
38
39 /// Result of an import operation.
40 #[derive(Debug, Serialize)]
41 #[serde(rename_all = "camelCase")]
42 pub struct ImportResult {
43 pub imported: u64,
44 pub skipped: u64,
45 pub errors: Vec<String>,
46 }
47
48 /// Preview of a single vCard contact.
49 #[derive(Debug, Serialize)]
50 #[serde(rename_all = "camelCase")]
51 pub struct VCardPreview {
52 pub display_name: String,
53 pub email_count: usize,
54 pub phone_count: usize,
55 pub company: Option<String>,
56 }
57
58 /// Preview of a single ICS event.
59 #[derive(Debug, Serialize)]
60 #[serde(rename_all = "camelCase")]
61 pub struct IcsPreview {
62 pub title: String,
63 pub start_time: String,
64 pub end_time: Option<String>,
65 pub location: Option<String>,
66 pub recurrence: String,
67 }
68
69 // ============ Preview Commands ============
70
71 /// Preview a vCard import without creating records.
72 #[tauri::command]
73 #[instrument(skip_all)]
74 pub async fn preview_vcf(file_path: String) -> Result<Vec<VCardPreview>, ApiError> {
75 let content = read_import_file(&file_path)?;
76
77 let cards = vcard::parse_vcf(&content)
78 .map_err(|e| ApiError::internal(format!("Failed to parse vCard: {}", e)))?;
79
80 Ok(cards
81 .into_iter()
82 .map(|c| VCardPreview {
83 display_name: c.display_name,
84 email_count: c.emails.len(),
85 phone_count: c.phones.len(),
86 company: c.company,
87 })
88 .collect())
89 }
90
91 /// Preview an ICS import without creating records.
92 #[tauri::command]
93 #[instrument(skip_all)]
94 pub async fn preview_ics(file_path: String) -> Result<Vec<IcsPreview>, ApiError> {
95 let content = read_import_file(&file_path)?;
96
97 let events = ical::parse_ics(&content)
98 .map_err(|e| ApiError::internal(format!("Failed to parse ICS: {}", e)))?;
99
100 Ok(events
101 .into_iter()
102 .map(|e| IcsPreview {
103 title: e.title,
104 start_time: e.start_time.to_rfc3339(),
105 end_time: e.end_time.map(|t| t.to_rfc3339()),
106 location: e.location,
107 recurrence: match e.recurrence {
108 Recurrence::Daily => "Daily".to_string(),
109 Recurrence::Weekly => "Weekly".to_string(),
110 Recurrence::Monthly => "Monthly".to_string(),
111 Recurrence::None => "None".to_string(),
112 },
113 })
114 .collect())
115 }
116
117 // ============ Import Commands ============
118
119 /// Import contacts from a vCard (.vcf) file.
120 #[tauri::command]
121 #[instrument(skip_all)]
122 pub async fn import_vcf(
123 state: State<'_, Arc<AppState>>,
124 file_path: String,
125 ) -> Result<ImportResult, ApiError> {
126 let content = read_import_file(&file_path)?;
127
128 let cards = vcard::parse_vcf(&content)
129 .map_err(|e| ApiError::internal(format!("Failed to parse vCard: {}", e)))?;
130
131 let mut imported = 0u64;
132 let mut skipped = 0u64;
133 let mut errors = Vec::new();
134
135 for card in cards {
136 // Generate a dedup key from the display name + first email
137 let ext_id = card
138 .emails
139 .first()
140 .map(|e| e.address.clone())
141 .unwrap_or_else(|| card.display_name.clone());
142
143 // Check for existing contact with same external source + id
144 if let Ok(Some(_)) = state
145 .contacts
146 .find_by_external_id("vcf", &ext_id, DESKTOP_USER_ID)
147 .await
148 {
149 skipped += 1;
150 continue;
151 }
152
153 // Parse birthday
154 let birthday = card.birthday.as_deref().and_then(|s| {
155 NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()
156 });
157
158 let new_contact = NewContact {
159 display_name: card.display_name.clone(),
160 nickname: card.nickname,
161 company: card.company,
162 title: card.title,
163 notes: card.notes.unwrap_or_default(),
164 tags: card.tags,
165 birthday,
166 timezone: card.timezone,
167 is_implicit: false,
168 };
169
170 match state.contacts.create(DESKTOP_USER_ID, new_contact).await {
171 Ok(contact) => {
172 // Set external source/id for dedup on re-import (must succeed to prevent duplicates)
173 if let Err(e) = sqlx::query(
174 "UPDATE contacts SET external_source = ?, external_id = ? WHERE id = ?",
175 )
176 .bind("vcf")
177 .bind(&ext_id)
178 .bind(contact.id.to_string())
179 .execute(&state.pool)
180 .await
181 {
182 tracing::error!(contact = %card.display_name, "Failed to set external source (dedup key lost): {}", e);
183 errors.push(format!("{}: failed to set dedup key: {}", card.display_name, e));
184 }
185
186 // Add sub-collections, collecting any errors
187 for email in card.emails {
188 if let Err(e) = state
189 .contacts
190 .add_email(
191 contact.id,
192 DESKTOP_USER_ID,
193 NewContactEmail {
194 address: email.address,
195 label: email.label,
196 is_primary: email.is_primary,
197 },
198 )
199 .await
200 {
201 tracing::warn!(contact = %card.display_name, "Failed to add email: {}", e);
202 }
203 }
204 for phone in card.phones {
205 if let Err(e) = state
206 .contacts
207 .add_phone(
208 contact.id,
209 DESKTOP_USER_ID,
210 NewContactPhone {
211 number: phone.number,
212 label: phone.label,
213 is_primary: phone.is_primary,
214 },
215 )
216 .await
217 {
218 tracing::warn!(contact = %card.display_name, "Failed to add phone: {}", e);
219 }
220 }
221 for social in card.social_handles {
222 if let Err(e) = state
223 .contacts
224 .add_social_handle(
225 contact.id,
226 DESKTOP_USER_ID,
227 NewSocialHandle {
228 platform: social.platform,
229 handle: social.handle,
230 url: social.url,
231 },
232 )
233 .await
234 {
235 tracing::warn!(contact = %card.display_name, "Failed to add social handle: {}", e);
236 }
237 }
238 for field in card.custom_fields {
239 if let Err(e) = state
240 .contacts
241 .add_custom_field(
242 contact.id,
243 DESKTOP_USER_ID,
244 NewContactCustomField {
245 label: field.label,
246 value: field.value,
247 url: field.url,
248 },
249 )
250 .await
251 {
252 tracing::warn!(contact = %card.display_name, "Failed to add custom field: {}", e);
253 }
254 }
255
256 imported += 1;
257 }
258 Err(e) => {
259 errors.push(format!("{}: {}", card.display_name, e));
260 }
261 }
262 }
263
264 Ok(ImportResult {
265 imported,
266 skipped,
267 errors,
268 })
269 }
270
271 /// Import events from an iCalendar (.ics) file.
272 #[tauri::command]
273 #[instrument(skip_all)]
274 pub async fn import_ics(
275 state: State<'_, Arc<AppState>>,
276 file_path: String,
277 ) -> Result<ImportResult, ApiError> {
278 let content = read_import_file(&file_path)?;
279
280 let parsed_events = ical::parse_ics(&content)
281 .map_err(|e| ApiError::internal(format!("Failed to parse ICS: {}", e)))?;
282
283 let mut imported = 0u64;
284 let mut skipped = 0u64;
285 let mut errors = Vec::new();
286
287 for mut parsed in parsed_events {
288 // Generate synthetic dedup key if UID is missing
289 if parsed.external_id.is_none() {
290 parsed.external_id = Some(format!(
291 "synth-{}-{}",
292 parsed.title.replace(' ', "_"),
293 parsed.start_time.timestamp()
294 ));
295 }
296
297 // Dedup by UID (real or synthetic)
298 if let Some(ref uid) = parsed.external_id {
299 if let Ok(Some(_)) = state
300 .events
301 .find_by_external_id("ics", uid, DESKTOP_USER_ID)
302 .await
303 {
304 skipped += 1;
305 continue;
306 }
307 }
308
309 let new_event = NewEvent {
310 user_id: Some(DESKTOP_USER_ID),
311 project_id: None,
312 contact_id: None,
313 title: parsed.title.clone(),
314 description: parsed.description,
315 start_time: parsed.start_time,
316 end_time: parsed.end_time,
317 location: parsed.location,
318 linked_task_id: None,
319 recurrence: parsed.recurrence,
320 recurrence_rule: None,
321 block_type: None,
322 reminder_offsets_seconds: Vec::new(),
323 };
324
325 match state.events.create(DESKTOP_USER_ID, new_event).await {
326 Ok(event) => {
327 // Set external source/id (file imports are editable, not read-only)
328 if let Some(ref uid) = parsed.external_id {
329 let _ = sqlx::query(
330 "UPDATE events SET external_source = ?, external_id = ? WHERE id = ?",
331 )
332 .bind("ics")
333 .bind(uid)
334 .bind(event.id.to_string())
335 .execute(&state.pool)
336 .await;
337 }
338
339 imported += 1;
340 }
341 Err(e) => {
342 errors.push(format!("{}: {}", parsed.title, e));
343 }
344 }
345 }
346
347 Ok(ImportResult {
348 imported,
349 skipped,
350 errors,
351 })
352 }
353