1use crabstep::TypedStreamDeserializer;
5use plist::Value;
6
7use crate::{
8 error::plist::PlistParseError,
9 message_types::variants::BalloonProvider,
10 tables::messages::{body::parse_body_typedstream, models::BubbleComponent},
11 util::{
12 dates::TIMESTAMP_FACTOR,
13 plist::{
14 extract_array_key, extract_bytes_key, extract_dictionary, extract_int_key,
15 plist_as_dictionary,
16 },
17 },
18};
19
20#[derive(Debug, PartialEq, Eq)]
22pub enum EditStatus {
23 Edited,
25 Unsent,
27 Original,
29}
30
31#[derive(Debug, PartialEq)]
33pub struct EditedEvent {
34 pub date: i64,
36 pub text: String,
39 pub components: Vec<BubbleComponent>,
41 pub guid: Option<String>,
43}
44
45impl EditedEvent {
46 pub(crate) fn new(
47 date: i64,
48 text: String,
49 components: Vec<BubbleComponent>,
50 guid: Option<String>,
51 ) -> Self {
52 Self {
53 date,
54 text,
55 components,
56 guid,
57 }
58 }
59}
60
61#[derive(Debug, PartialEq)]
63pub struct EditedMessagePart {
64 pub status: EditStatus,
66 pub edit_history: Vec<EditedEvent>,
68}
69
70impl Default for EditedMessagePart {
71 fn default() -> Self {
72 Self {
73 status: EditStatus::Original,
74 edit_history: vec![],
75 }
76 }
77}
78
79#[derive(Debug, PartialEq)]
102pub struct EditedMessage {
103 pub parts: Vec<EditedMessagePart>,
105}
106
107impl<'a> BalloonProvider<'a> for EditedMessage {
108 fn from_map(payload: &'a Value) -> Result<Self, PlistParseError> {
109 let plist_root = plist_as_dictionary(payload)?;
111
112 let message_parts = extract_dictionary(plist_root, "otr")?;
114
115 let mut edited = Self::with_capacity(message_parts.len());
117 message_parts
118 .values()
119 .for_each(|_| edited.parts.push(EditedMessagePart::default()));
120
121 if let Ok(edited_message_events) = extract_dictionary(plist_root, "ec") {
122 for (idx, (key, events)) in edited_message_events.iter().enumerate() {
123 let events = events
124 .as_array()
125 .ok_or_else(|| PlistParseError::InvalidTypeIndex(idx, "array".to_string()))?;
126 let parsed_key = key.parse::<usize>().map_err(|_| {
127 PlistParseError::InvalidType(
128 "ec dictionary key".to_string(),
129 "numeric string".to_string(),
130 )
131 })?;
132
133 for (event_idx, event) in events.iter().enumerate() {
134 let message_data = event.as_dictionary().ok_or_else(|| {
135 PlistParseError::InvalidTypeIndex(event_idx, "dictionary".to_string())
136 })?;
137
138 let timestamp = extract_int_key(message_data, "d")?
139 .checked_mul(TIMESTAMP_FACTOR)
140 .ok_or_else(|| {
141 PlistParseError::InvalidEditedMessage(
142 "edit timestamp out of range".to_string(),
143 )
144 })?;
145
146 let data = extract_bytes_key(message_data, "t")?;
147
148 let mut typedstream = TypedStreamDeserializer::new(data);
149 let result = parse_body_typedstream(Some(typedstream.iter_root()?), None)
150 .ok_or_else(|| {
151 PlistParseError::InvalidEditedMessage(
152 "Failed to parse typedstream data".to_string(),
153 )
154 })?;
155
156 let text = result.text.ok_or_else(|| {
157 PlistParseError::InvalidEditedMessage(
158 "Edit-history entry missing text!".to_string(),
159 )
160 })?;
161
162 let guid = message_data
163 .get("bcg")
164 .and_then(|item| item.as_string())
165 .map(Into::into);
166
167 if let Some(item) = edited.parts.get_mut(parsed_key) {
168 item.status = EditStatus::Edited;
169 item.edit_history.push(EditedEvent::new(
170 timestamp,
171 text,
172 result.components,
173 guid,
174 ));
175 }
176 }
177 }
178 }
179
180 if let Ok(unsent_message_indexes) = extract_array_key(plist_root, "rp") {
181 for (idx, unsent_message_idx) in unsent_message_indexes.iter().enumerate() {
182 let parsed_idx = unsent_message_idx
183 .as_signed_integer()
184 .ok_or_else(|| PlistParseError::InvalidTypeIndex(idx, "int".to_string()))?
185 as usize;
186 if let Some(item) = edited.parts.get_mut(parsed_idx) {
187 item.status = EditStatus::Unsent;
188 }
189 }
190 }
191
192 Ok(edited)
193 }
194}
195
196impl EditedMessage {
197 fn with_capacity(capacity: usize) -> Self {
199 EditedMessage {
200 parts: Vec::with_capacity(capacity),
201 }
202 }
203
204 #[must_use]
206 pub fn part(&self, index: usize) -> Option<&EditedMessagePart> {
207 self.parts.get(index)
208 }
209
210 #[must_use]
212 pub fn is_unedited_at(&self, index: usize) -> bool {
213 match self.parts.get(index) {
214 Some(part) => matches!(part.status, EditStatus::Original),
215 None => false,
216 }
217 }
218
219 #[must_use]
221 pub fn items(&self) -> usize {
222 self.parts.len()
223 }
224}
225
226#[cfg(test)]
227mod test_parser {
228 use crate::message_types::edited::{EditStatus, EditedEvent, EditedMessagePart};
229 use crate::message_types::text_effects::{style::Style, text_effect::TextEffect};
230 use crate::message_types::{edited::EditedMessage, variants::BalloonProvider};
231 use crate::tables::messages::models::{AttributedRange, BubbleComponent};
232
233 use plist::Value;
234 use std::env::current_dir;
235 use std::fs::File;
236
237 #[test]
238 fn test_parse_edited() {
239 let plist_path = current_dir()
240 .unwrap()
241 .as_path()
242 .join("test_data/edited_message/Edited.plist");
243 let plist_data = File::open(plist_path).unwrap();
244 let plist = Value::from_reader(plist_data).unwrap();
245 let parsed = EditedMessage::from_map(&plist).unwrap();
246
247 let expected = EditedMessage {
248 parts: vec![EditedMessagePart {
249 status: EditStatus::Edited,
250 edit_history: vec![
251 EditedEvent::new(
252 690513474000000000,
253 "First message ".to_string(),
254 vec![BubbleComponent::Run(vec![AttributedRange::text(
255 0,
256 15,
257 vec![TextEffect::Default],
258 )])],
259 None,
260 ),
261 EditedEvent::new(
262 690513480000000000,
263 "Edit 1".to_string(),
264 vec![BubbleComponent::Run(vec![AttributedRange::text(
265 0,
266 6,
267 vec![TextEffect::Default],
268 )])],
269 None,
270 ),
271 EditedEvent::new(
272 690513485000000000,
273 "Edit 2".to_string(),
274 vec![BubbleComponent::Run(vec![AttributedRange::text(
275 0,
276 6,
277 vec![TextEffect::Default],
278 )])],
279 None,
280 ),
281 EditedEvent::new(
282 690513494000000000,
283 "Edited message".to_string(),
284 vec![BubbleComponent::Run(vec![AttributedRange::text(
285 0,
286 14,
287 vec![TextEffect::Default],
288 )])],
289 None,
290 ),
291 ],
292 }],
293 };
294
295 assert_eq!(parsed, expected);
296
297 let expected_item = Some(expected.parts.first().unwrap());
298 assert_eq!(parsed.part(0), expected_item);
299 }
300
301 #[test]
302 fn test_parse_edited_to_link() {
303 let plist_path = current_dir()
304 .unwrap()
305 .as_path()
306 .join("test_data/edited_message/EditedToLink.plist");
307 let plist_data = File::open(plist_path).unwrap();
308 let plist = Value::from_reader(plist_data).unwrap();
309 let parsed = EditedMessage::from_map(&plist).unwrap();
310
311 let expected = EditedMessage {
312 parts: vec![
313 EditedMessagePart {
314 status: EditStatus::Original,
315 edit_history: vec![], },
317 EditedMessagePart {
318 status: EditStatus::Edited,
319 edit_history: vec![
320 EditedEvent::new(
321 690514004000000000,
322 "here we go!".to_string(),
323 vec![BubbleComponent::Run(vec![AttributedRange::text(
324 0,
325 11,
326 vec![TextEffect::Default],
327 )])],
328 None,
329 ),
330 EditedEvent::new(
331 690514772000000000,
332 "https://github.com/ReagentX/imessage-exporter/issues/10".to_string(),
333 vec![BubbleComponent::Run(vec![AttributedRange::text(
334 0,
335 55,
336 vec![TextEffect::Default],
337 )])],
338 Some("292BF9C6-C9B8-4827-BE65-6EA1C9B5B384".to_string()),
339 ),
340 ],
341 },
342 ],
343 };
344
345 assert_eq!(parsed, expected);
346 }
347
348 #[test]
349 fn test_parse_edited_to_link_and_back() {
350 let plist_path = current_dir()
351 .unwrap()
352 .as_path()
353 .join("test_data/edited_message/EditedToLinkAndBack.plist");
354 let plist_data = File::open(plist_path).unwrap();
355 let plist = Value::from_reader(plist_data).unwrap();
356 let parsed = EditedMessage::from_map(&plist).unwrap();
357
358 let expected = EditedMessage {
359 parts: vec![EditedMessagePart {
360 status: EditStatus::Edited,
361 edit_history: vec![
362 EditedEvent::new(
363 690514809000000000,
364 "This is a normal message".to_string(),
365 vec![BubbleComponent::Run(vec![AttributedRange::text(
366 0,
367 24,
368 vec![TextEffect::Default],
369 )])],
370 None,
371 ),
372 EditedEvent::new(
373 690514819000000000,
374 "Edit to a url https://github.com/ReagentX/imessage-exporter/issues/10"
375 .to_string(),
376 vec![BubbleComponent::Run(vec![AttributedRange::text(
377 0,
378 69,
379 vec![TextEffect::Default],
380 )])],
381 Some("0B9103FE-280C-4BD0-A66F-4EDEE3443247".to_string()),
382 ),
383 EditedEvent::new(
384 690514834000000000,
385 "And edit it back to a normal message...".to_string(),
386 vec![BubbleComponent::Run(vec![AttributedRange::text(
387 0,
388 39,
389 vec![TextEffect::Default],
390 )])],
391 Some("0D93DF88-05BA-4418-9B20-79918ADD9923".to_string()),
392 ),
393 ],
394 }],
395 };
396
397 assert_eq!(parsed, expected);
398 }
399
400 #[test]
401 fn test_parse_deleted() {
402 let plist_path = current_dir()
403 .unwrap()
404 .as_path()
405 .join("test_data/edited_message/Deleted.plist");
406 let plist_data = File::open(plist_path).unwrap();
407 let plist = Value::from_reader(plist_data).unwrap();
408 let parsed = EditedMessage::from_map(&plist).unwrap();
409
410 let expected = EditedMessage {
411 parts: vec![EditedMessagePart {
412 status: EditStatus::Unsent,
413 edit_history: vec![],
414 }],
415 };
416
417 assert_eq!(parsed, expected);
418 }
419
420 #[test]
421 fn test_parse_multipart_deleted() {
422 let plist_path = current_dir()
423 .unwrap()
424 .as_path()
425 .join("test_data/edited_message/MultiPartOneDeleted.plist");
426 let plist_data = File::open(plist_path).unwrap();
427 let plist = Value::from_reader(plist_data).unwrap();
428 let parsed = EditedMessage::from_map(&plist).unwrap();
429
430 let expected = EditedMessage {
431 parts: vec![
432 EditedMessagePart {
433 status: EditStatus::Original,
434 edit_history: vec![],
435 },
436 EditedMessagePart {
437 status: EditStatus::Original,
438 edit_history: vec![],
439 },
440 EditedMessagePart {
441 status: EditStatus::Original,
442 edit_history: vec![],
443 },
444 EditedMessagePart {
445 status: EditStatus::Unsent,
446 edit_history: vec![],
447 },
448 ],
449 };
450
451 assert_eq!(parsed, expected);
452 }
453
454 #[test]
455 fn test_parse_multipart_edited_and_deleted() {
456 let plist_path = current_dir()
457 .unwrap()
458 .as_path()
459 .join("test_data/edited_message/EditedAndDeleted.plist");
460 let plist_data = File::open(plist_path).unwrap();
461 let plist = Value::from_reader(plist_data).unwrap();
462 let parsed = EditedMessage::from_map(&plist).unwrap();
463
464 let expected = EditedMessage {
465 parts: vec![
466 EditedMessagePart {
467 status: EditStatus::Original,
468 edit_history: vec![],
469 },
470 EditedMessagePart {
471 status: EditStatus::Original,
472 edit_history: vec![],
473 },
474 EditedMessagePart {
475 status: EditStatus::Edited,
476 edit_history: vec![
477 EditedEvent::new(
478 743907180000000000,
479 "Second message".to_string(),
480 vec![BubbleComponent::Run(vec![AttributedRange::text(
481 0,
482 14,
483 vec![TextEffect::Default],
484 )])],
485 None,
486 ),
487 EditedEvent::new(
488 743907190000000000,
489 "Second message got edited!".to_string(),
490 vec![BubbleComponent::Run(vec![AttributedRange::text(
491 0,
492 26,
493 vec![TextEffect::Default],
494 )])],
495 None,
496 ),
497 ],
498 },
499 ],
500 };
501
502 assert_eq!(parsed, expected);
503 }
504
505 #[test]
506 fn test_parse_multipart_edited_and_unsent() {
507 let plist_path = current_dir()
508 .unwrap()
509 .as_path()
510 .join("test_data/edited_message/EditedAndUnsent.plist");
511 let plist_data = File::open(plist_path).unwrap();
512 let plist = Value::from_reader(plist_data).unwrap();
513 let parsed = EditedMessage::from_map(&plist).unwrap();
514
515 let expected = EditedMessage {
516 parts: vec![
517 EditedMessagePart {
518 status: EditStatus::Original,
519 edit_history: vec![],
520 },
521 EditedMessagePart {
522 status: EditStatus::Original,
523 edit_history: vec![],
524 },
525 EditedMessagePart {
526 status: EditStatus::Edited,
527 edit_history: vec![
528 EditedEvent::new(
529 743907435000000000,
530 "Second test".to_string(),
531 vec![BubbleComponent::Run(vec![AttributedRange::text(
532 0,
533 11,
534 vec![TextEffect::Default],
535 )])],
536 None,
537 ),
538 EditedEvent::new(
539 743907448000000000,
540 "Second test was edited!".to_string(),
541 vec![BubbleComponent::Run(vec![AttributedRange::text(
542 0,
543 23,
544 vec![TextEffect::Default],
545 )])],
546 None,
547 ),
548 ],
549 },
550 EditedMessagePart {
551 status: EditStatus::Unsent,
552 edit_history: vec![],
553 },
554 ],
555 };
556
557 assert_eq!(parsed, expected);
558 }
559
560 #[test]
561 fn test_parse_edited_with_formatting() {
562 let plist_path = current_dir()
563 .unwrap()
564 .as_path()
565 .join("test_data/edited_message/EditedWithFormatting.plist");
566 let plist_data = File::open(plist_path).unwrap();
567 let plist = Value::from_reader(plist_data).unwrap();
568 let parsed = EditedMessage::from_map(&plist).unwrap();
569
570 let expected = EditedMessage {
571 parts: vec![EditedMessagePart {
572 status: EditStatus::Edited,
573 edit_history: vec![
574 EditedEvent::new(
575 758573156000000000,
576 "Test".to_string(),
577 vec![BubbleComponent::Run(vec![AttributedRange::text(
578 0,
579 4,
580 vec![TextEffect::Default],
581 )])],
582 None,
583 ),
584 EditedEvent::new(
585 758573166000000000,
586 "Test".to_string(),
587 vec![BubbleComponent::Run(vec![AttributedRange::text(
588 0,
589 4,
590 vec![TextEffect::Styles(vec![Style::Strikethrough])],
591 )])],
592 Some("76A466B8-D21E-4A20-AF62-FF2D3A20D31C".to_string()),
593 ),
594 ],
595 }],
596 };
597
598 assert_eq!(parsed, expected);
599
600 let expected_item = Some(expected.parts.first().unwrap());
601 assert_eq!(parsed.part(0), expected_item);
602 }
603
604 #[test]
605 fn test_parse_edited_memoji() {
606 let plist_path = current_dir()
611 .unwrap()
612 .as_path()
613 .join("test_data/edited_message/MemojiEdited.plist");
614 let plist_data = File::open(plist_path).unwrap();
615 let plist = Value::from_reader(plist_data).unwrap();
616 let parsed = EditedMessage::from_map(&plist).unwrap();
617
618 let history = &parsed.parts[0].edit_history;
619 assert_eq!(history.len(), 2);
620 assert_eq!(history[1].text, "Check this out: \u{FFFC} 😀");
621
622 let BubbleComponent::Run(ranges) = &history[1].components[0] else {
623 panic!("expected a Run, got {:?}", history[1].components);
624 };
625 let memoji = ranges
626 .iter()
627 .find(|range| range.attachment.is_some())
628 .expect("the latest version should contain the Memoji attachment range");
629 assert!(
630 memoji.emoji_image,
631 "the Memoji must be flagged as an inline (emoji_image) sticker"
632 );
633 assert_eq!(
634 memoji.attachment.as_ref().unwrap().guid.as_deref(),
635 Some("F2C223DB-0140-4D49-B38A-C1A3553B4CBA"),
636 );
637 }
638}