Skip to main content

max / goingson

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