Skip to main content

imessage_database/tables/messages/
message.rs

1/*!
2 Message table rows, query helpers, and body parsing.
3
4 # Iterating Over Message Data
5
6 Compose the message query from probed [`Capabilities`]. For [`Message`],
7 use [`Message::stream_rows()`] to prepare the query and [`Message::rows()`]
8 to iterate over it; there is no [`Table`](crate::tables::table::Table)
9 implementation for this type.
10
11 ## Example
12 ```no_run
13 use imessage_database::{
14     tables::{
15         capabilities::Capabilities,
16         messages::Message,
17         table::get_connection,
18     },
19     util::{dirs::default_db_path, query_context::QueryContext},
20 };
21
22 // Get the default database path and connect to it
23 let db_path = default_db_path().unwrap();
24 let conn = get_connection(&db_path).unwrap();
25
26 // Probe the schema once and use the result to compose every message query
27 let capabilities = Capabilities::determine(&conn).unwrap();
28 let query_context = QueryContext::default();
29
30 let mut statement = Message::stream_rows(&conn, &capabilities, &query_context).unwrap();
31 for message_result in Message::rows(&mut statement, []).unwrap() {
32     match message_result {
33         Ok(message) => println!("Message: {:#?}", message),
34         Err(e) => eprintln!("Error: {:?}", e),
35     }
36 }
37 ```
38
39 # Iterating Over Filtered Message Data
40
41 Use a [`QueryContext`] to specify filters and constraints for the message query.
42
43 ## Example
44 ```no_run
45 use chrono::Utc;
46 use imessage_database::{
47     tables::{
48         capabilities::Capabilities,
49         messages::Message,
50         table::get_connection,
51     },
52     util::{dirs::default_db_path, query_context::QueryContext},
53 };
54
55 // Get the default database path and connect to it
56 let db_path = default_db_path().unwrap();
57 let conn = get_connection(&db_path).unwrap();
58 let capabilities = Capabilities::determine(&conn).unwrap();
59 let mut query_context = QueryContext::default();
60
61 // Customize the query context as needed
62 // For example, filter messages by date using midnight in `Utc`:
63 query_context.set_start("2023-01-01", &Utc).unwrap();
64 query_context.set_end("2023-12-31", &Utc).unwrap();
65
66 // Prepare the statement for streaming rows
67 let mut statement = Message::stream_rows(&conn, &capabilities, &query_context).unwrap();
68
69 // Iterate over the streamed rows and handle each message result
70 for message_result in Message::rows(&mut statement, []).unwrap() {
71     match message_result {
72         Ok(message) => println!("Message: {:#?}", message),
73         Err(e) => eprintln!("Error: {:?}", e),
74     }
75 }
76 ```
77
78 # Making Custom Message Queries
79
80 [`Message`] includes a few fields that are derived by the default query and
81 are not direct `message` table columns:
82
83 - [`Message::chat_id`]
84 - [`Message::num_attachments`]
85 - [`Message::deleted_from`]
86 - [`Message::num_replies`]
87
88 [`Message::rows`] and [`Message::row`] resolve result columns by name once,
89 then decode by ordinal. Column order is immaterial; column names are not. Six
90 columns are mandatory: `rowid`, `guid`, `date`, `is_from_me`,
91 `num_attachments`, and `num_replies`. A query that omits any of them fails to
92 deserialize. Every other column [`Message`] reads may be omitted and takes its
93 default. Thus,
94 [`Message::filter_action`] and [`Message::filter_sub_action`] read as `None`
95 against schemas without those columns.
96
97 Body parsing uses optional `attributedBody`, `message_summary_info`, and
98 `has_payload_data` result columns. The payload flag is `payload_data IS NOT
99 NULL`; custom `m.*` projections can supply raw `payload_data` instead. Missing
100 parser inputs are treated as absent, so a projection containing only `text`
101 still produces plain-text body components. Text recovery failures preserve
102 the row's metadata and any recovered body components or edit metadata.
103
104 Built-in queries preserve the storage bytes of text-valued parser inputs in
105 UTF-16 databases. Custom queries must cast such inputs to `BLOB` to prevent
106 SQLite from transcoding them to UTF-8 before parsing.
107
108 ## Sample Queries
109
110 Custom queries must include those derived columns:
111
112 ```sql
113 SELECT
114     *,
115     c.chat_id,
116     (SELECT COUNT(*) FROM message_attachment_join a WHERE m.ROWID = a.message_id) as num_attachments,
117     d.chat_id as deleted_from,
118     (SELECT COUNT(*) FROM message m2 WHERE m2.thread_originator_guid = m.guid) as num_replies
119 FROM
120     message as m
121 LEFT JOIN chat_message_join as c ON m.ROWID = c.message_id
122 LEFT JOIN chat_recoverable_message_join as d ON m.ROWID = d.message_id
123 ORDER BY
124     m.date;
125 ```
126
127 If a source database does not include recoverable-message or reply columns,
128 synthesize the missing values:
129
130 ```sql
131 SELECT
132     *,
133     c.chat_id,
134     (SELECT COUNT(*) FROM message_attachment_join a WHERE m.ROWID = a.message_id) as num_attachments,
135     NULL as deleted_from,
136     0 as num_replies
137 FROM
138     message as m
139 LEFT JOIN chat_message_join as c ON m.ROWID = c.message_id
140 ORDER BY
141     m.date;
142 ```
143
144 ## Custom Query Example
145
146 This returns an iterator over messages that have an associated emoji:
147
148
149 ```no_run
150 use imessage_database::{
151     tables::{messages::Message, table::get_connection},
152     util::dirs::default_db_path
153 };
154
155 let db_path = default_db_path().unwrap();
156 let db = get_connection(&db_path).unwrap();
157
158 let mut statement = db.prepare_cached("
159 SELECT
160     *,
161     c.chat_id,
162     (SELECT COUNT(*) FROM message_attachment_join a WHERE m.ROWID = a.message_id) as num_attachments,
163     d.chat_id as deleted_from,
164     (SELECT COUNT(*) FROM message m2 WHERE m2.thread_originator_guid = m.guid) as num_replies
165 FROM
166     message as m
167 LEFT JOIN chat_message_join as c ON m.ROWID = c.message_id
168 LEFT JOIN chat_recoverable_message_join as d ON m.ROWID = d.message_id
169 WHERE m.associated_message_emoji IS NOT NULL
170 ORDER BY
171     m.date;
172 ").unwrap();
173
174 for message in Message::rows(&mut statement, []).unwrap() {
175     println!("{:#?}", message);
176 }
177 ```
178*/
179
180use std::{
181    collections::{HashMap, HashSet},
182    fmt::Write,
183    io::{Cursor, Read},
184};
185
186use chrono::{DateTime, TimeZone, Utc};
187use crabstep::TypedStreamDeserializer;
188use plist::Value;
189use rusqlite::{CachedStatement, Connection, Params, Result, Statement};
190
191use crate::{
192    error::{message::MessageError, table::TableError},
193    message_types::{
194        edited::{EditStatus, EditedMessage},
195        expressives::{BubbleEffect, Expressive, ScreenEffect},
196        polls::Poll,
197        text_effects::text_effect::TextEffect,
198        translation::Translation,
199        variants::{Announcement, BalloonProvider, CustomBalloon, Tapback, TapbackAction, Variant},
200    },
201    tables::{
202        capabilities::Capabilities,
203        diagnostic::{MessageDiagnostic, count_query, table_exists},
204        messages::{
205            body::{parse_body_legacy, parse_body_typedstream},
206            columns::MessageColumns,
207            models::{BubbleComponent, FilterAction, GroupAction, Service, SharedLocation},
208            query_parts::{from_clause, message_query, prepare_message_query},
209        },
210        table::{
211            CHAT_MESSAGE_JOIN, MESSAGE, MESSAGE_PAYLOAD, MESSAGE_SUMMARY_INFO, RECENTLY_DELETED,
212            flatten_row, open_blob,
213        },
214    },
215    util::{
216        bundle_id::parse_balloon_bundle_id,
217        dates::{readable_diff, to_utc},
218        query_context::QueryContext,
219        streamtyped,
220    },
221};
222
223/// Row from the `message` table, with body and edit metadata parsed during row decoding.
224#[derive(Debug)]
225pub struct Message {
226    /// Message row ID.
227    pub rowid: i32,
228    /// Message GUID.
229    pub guid: String,
230    /// Plain body text, decoded from `attributedBody` when available.
231    pub text: Option<String>,
232    /// Raw service name.
233    pub service: Option<String>,
234    /// Sender handle row ID.
235    pub handle_id: Option<i32>,
236    /// Address that received the message.
237    pub destination_caller_id: Option<String>,
238    /// Subject field.
239    pub subject: Option<String>,
240    /// Raw timestamp for when the message was written to the database.
241    pub date: i64,
242    /// Raw timestamp for when the message was read.
243    pub date_read: i64,
244    /// Raw timestamp for when the message was delivered.
245    pub date_delivered: i64,
246    /// `true` when the database owner sent the message.
247    pub is_from_me: bool,
248    /// `true` when the message was read by the recipient.
249    pub is_read: bool,
250    /// Message item type used by [`variant`](Self::variant).
251    pub item_type: i32,
252    /// Additional handle used by shared-location and group-action messages.
253    pub other_handle: Option<i32>,
254    /// Shared-location active/inactive flag.
255    pub share_status: bool,
256    /// Shared-location direction flag.
257    pub share_direction: Option<bool>,
258    /// Group title carried by group-name-change messages.
259    pub group_title: Option<String>,
260    /// Group action code.
261    pub group_action_type: i32,
262    /// GUID of the message this row references.
263    pub associated_message_guid: Option<String>,
264    /// Type code for the associated message, used by [`variant`](Self::variant).
265    pub associated_message_type: Option<i32>,
266    /// The [bundle ID](https://developer.apple.com/help/app-store-connect/reference/app-bundle-information) of the app that generated the [`AppMessage`](crate::message_types::app::AppMessage)
267    pub balloon_bundle_id: Option<String>,
268    /// Expressive-send identifier used by [`get_expressive`](Self::get_expressive).
269    pub expressive_send_style_id: Option<String>,
270    /// Indicates the first message in a thread of replies in [`get_replies()`](crate::tables::messages::Message::get_replies)
271    pub thread_originator_guid: Option<String>,
272    /// Body part index targeted by a reply.
273    pub thread_originator_part: Option<String>,
274    /// Raw timestamp for the most recent edit.
275    pub date_edited: i64,
276    /// Emoji associated with a custom emoji tapback.
277    pub associated_message_emoji: Option<String>,
278    /// The [`rowid`](crate::tables::chat::Chat::rowid) of the chat this message belongs to.
279    ///
280    /// `None` for a recovered deleted message; most callers should prefer
281    /// [`chat_rowid()`](Self::chat_rowid).
282    pub chat_id: Option<i32>,
283    /// Number of attached files included in the message.
284    pub num_attachments: i32,
285    /// The [`rowid`](crate::tables::chat::Chat::rowid) of the chat the message was deleted from.
286    ///
287    /// Most callers should prefer [`chat_rowid()`](Self::chat_rowid).
288    pub deleted_from: Option<i32>,
289    /// Number of replies to the message.
290    pub num_replies: i32,
291    /// Raw message filter category code, read by [`filter_action`](Self::filter_action).
292    pub filter_action: Option<i32>,
293    /// Raw message filter subcategory code. This field has no parsed representation.
294    pub filter_sub_action: Option<i32>,
295    /// The components of the message body, parsed by a [`TypedStreamDeserializer`] or [`streamtyped::parse()`]
296    pub components: Vec<BubbleComponent>,
297    /// Parsed edit/unsent metadata from `message_summary_info`.
298    pub edited_parts: Option<EditedMessage>,
299}
300
301// MARK: Rows
302impl Message {
303    /// Resolve the column layout after the first step, then deserialize every
304    /// row by ordinal. Pass either a [`rusqlite::Statement`] or a
305    /// [`rusqlite::CachedStatement`] (via deref coercion).
306    ///
307    /// If a required column is absent, return [`TableError::QueryError`] for
308    /// every row. Failed layout resolutions are not cached: retry resolution
309    /// after each step.
310    ///
311    /// Resolve through the first [`Row`](rusqlite::Row) to use metadata after
312    /// `sqlite3_step`: if the schema changed after preparation, the statement
313    /// may be recompiled during that step.
314    pub fn rows<'stmt, P: Params>(
315        stmt: &'stmt mut Statement<'_>,
316        params: P,
317    ) -> Result<impl Iterator<Item = Result<Self, TableError>> + 'stmt, TableError>
318    where
319        Self: 'stmt,
320    {
321        let mut layout = None;
322        let mapped = stmt.query_map(params, move |row| {
323            let columns = match layout.as_ref() {
324                Some(columns) => columns,
325                None => layout.insert(MessageColumns::resolve(row.as_ref())?),
326            };
327            Ok(Self::from_row_mapped(row, columns))
328        })?;
329        Ok(mapped.map(flatten_row))
330    }
331
332    /// Resolve the stepped row's column layout, then deserialize by ordinal.
333    /// Return [`TableError::QueryError`] if no row is found, a required column
334    /// is absent, or deserialization fails.
335    pub fn row<P: Params>(stmt: &mut Statement<'_>, params: P) -> Result<Self, TableError> {
336        flatten_row(stmt.query_row(params, |row| {
337            let columns = MessageColumns::resolve(row.as_ref())?;
338            Ok(Self::from_row_mapped(row, &columns))
339        }))
340    }
341}
342
343// MARK: Diagnostic
344impl Message {
345    /// Compute diagnostic data for the `message` table.
346    ///
347    /// # Example
348    ///
349    /// ```no_run
350    /// use imessage_database::util::dirs::default_db_path;
351    /// use imessage_database::tables::table::get_connection;
352    /// use imessage_database::tables::messages::Message;
353    ///
354    /// let db_path = default_db_path().unwrap();
355    /// let conn = get_connection(&db_path).unwrap();
356    /// Message::run_diagnostic(&conn);
357    /// ```
358    pub fn run_diagnostic(db: &Connection) -> Result<MessageDiagnostic, TableError> {
359        let messages_without_chat = count_query(
360            db,
361            &format!(
362                "
363            SELECT
364                COUNT(m.rowid)
365            FROM
366            {MESSAGE} as m
367            LEFT JOIN {CHAT_MESSAGE_JOIN} as c ON m.rowid = c.message_id
368            WHERE
369                c.chat_id is NULL
370            ORDER BY
371                m.date
372            "
373            ),
374        )?;
375
376        let messages_in_multiple_chats = count_query(
377            db,
378            &format!(
379                "
380            SELECT
381                COUNT(*)
382            FROM (
383            SELECT DISTINCT
384                message_id
385              , COUNT(chat_id) AS c
386            FROM {CHAT_MESSAGE_JOIN}
387            GROUP BY
388                message_id
389            HAVING c > 1);
390            "
391            ),
392        )?;
393
394        let total_messages = count_query(
395            db,
396            &format!(
397                "
398            SELECT
399                COUNT(rowid)
400            FROM
401                {MESSAGE}
402            "
403            ),
404        )?;
405
406        // Recently deleted messages are stored in a separate table when present.
407        let recoverable_messages = if table_exists(db, RECENTLY_DELETED)? {
408            Some(count_query(
409                db,
410                &format!("SELECT COUNT(*) FROM {RECENTLY_DELETED}"),
411            )?)
412        } else {
413            None
414        };
415
416        // The date range is nullable when the message table is empty.
417        let mut date_range = db.prepare(&format!("SELECT MIN(date), MAX(date) FROM {MESSAGE}"))?;
418        let (first_message_date, last_message_date): (Option<i64>, Option<i64>) = date_range
419            .query_row([], |r| Ok((r.get(0).ok(), r.get(1).ok())))
420            .unwrap_or((None, None));
421
422        Ok(MessageDiagnostic {
423            total_messages,
424            messages_without_chat,
425            messages_in_multiple_chats,
426            recoverable_messages,
427            first_message_date,
428            last_message_date,
429        })
430    }
431}
432
433// MARK: Cache
434impl Message {
435    /// Cache tapback messages by target message GUID and body component index.
436    ///
437    /// Builds a map like:
438    ///
439    /// ```json
440    /// {
441    ///     "message_guid": {
442    ///         0: [Message, Message],
443    ///         1: [Message]
444    ///     }
445    /// }
446    /// ```
447    ///
448    /// The `0` and `1` keys are component indexes in the target message body.
449    /// Return an empty map without querying if `associated_message_guid` is
450    /// absent from the schema.
451    pub fn tapbacks_by_target(
452        db: &Connection,
453        capabilities: &Capabilities,
454    ) -> Result<HashMap<String, HashMap<usize, Vec<Message>>>, TableError> {
455        let mut map: HashMap<String, HashMap<usize, Vec<Message>>> = HashMap::new();
456
457        if !capabilities.associated_message_guids {
458            return Ok(map);
459        }
460
461        // The cache only maps each tapback to its target GUID and component
462        // index, and tapback rendering never reads a body, so this full scan
463        // skips the derived features' correlated subqueries and join and the
464        // body inputs' blob reads and typedstream decode.
465        let cache_capabilities = capabilities
466            .without_derived_features()
467            .without_body_inputs();
468        let mut statement = db.prepare_cached(&message_query(
469            &cache_capabilities,
470            Some("WHERE m.associated_message_guid IS NOT NULL"),
471        ))?;
472
473        for message in Self::rows(&mut statement, [])? {
474            let message = message?;
475            if message.is_tapback()
476                && let Some((idx, tapback_target_guid)) = message.clean_associated_guid()
477            {
478                map.entry(tapback_target_guid.to_string())
479                    .or_default()
480                    .entry(idx)
481                    .or_default()
482                    .push(message);
483            }
484        }
485
486        Ok(map)
487    }
488}
489
490// MARK: Impl
491impl Message {
492    // MARK: Text Gen
493    /// Populate body fields from supplied bytes without reading the database.
494    ///
495    /// `None` represents an absent or unreadable input. `has_payload` is the
496    /// non-nullness of `payload_data`, including non-blob values.
497    /// Edit-summary bytes are parsed only when [`Self::is_edited`] is true.
498    ///
499    /// Parsing is best effort: text recovery failures preserve parsed body
500    /// components and edit metadata.
501    pub(super) fn parse_body(
502        &mut self,
503        attributed_body: Option<&[u8]>,
504        message_summary_info: Option<&[u8]>,
505        has_payload: bool,
506    ) {
507        let edited_parts = self
508            .is_edited()
509            .then_some(message_summary_info)
510            .flatten()
511            .and_then(|bytes| Value::from_reader(Cursor::new(bytes)).ok())
512            .as_ref()
513            .and_then(|payload| EditedMessage::from_map(payload).ok());
514
515        // Initialize variables for the text, components, and balloon bundle ID that will be parsed from the body
516        let mut text = None;
517        let mut components = vec![];
518        let mut balloon_bundle_id = None;
519
520        if let Some(body) = attributed_body {
521            // Attempt to deserialize the typedstream data
522            let mut typedstream = TypedStreamDeserializer::new(body);
523            match parse_body_typedstream(typedstream.iter_root().ok(), edited_parts.as_ref()) {
524                Some(parsed) => {
525                    text = parsed.text;
526
527                    // Single-link messages can render as URL previews even
528                    // when `balloon_bundle_id` is missing.
529                    let is_single_url = match &parsed.components[..] {
530                        [BubbleComponent::Run(ranges)] => match &ranges[..] {
531                            [range] if range.attachment.is_none() => {
532                                matches!(&range.effects[..], [TextEffect::Link(_)])
533                            }
534                            _ => false,
535                        },
536                        _ => false,
537                    };
538
539                    // App payloads render as a single app component.
540                    if self.balloon_bundle_id.is_some() {
541                        components = vec![BubbleComponent::App];
542                    } else if is_single_url && has_payload {
543                        // URL previews may omit `balloon_bundle_id` while still carrying
544                        // preview payload data.
545                        balloon_bundle_id =
546                            Some("com.apple.messages.URLBalloonProvider".to_string());
547                        components = vec![BubbleComponent::App];
548                    } else {
549                        components = parsed.components;
550                    }
551                }
552                None => {
553                    // Typedstream failed entirely; try self.text before legacy parser
554                    text = self.text.clone();
555                }
556            }
557
558            // A failed legacy parse must not discard valid components or edit metadata.
559            if text.is_none() {
560                text = streamtyped::parse(body.to_vec()).ok();
561            }
562        }
563
564        // Older message rows may already have the plain text field populated.
565        let text = text.or_else(|| self.text.clone());
566
567        // The balloon bundle ID can be set in the single URL case, otherwise it should fall back to the existing balloon bundle ID on the message
568        let balloon_bundle_id = balloon_bundle_id.or_else(|| self.balloon_bundle_id.clone());
569
570        // If typedstream did not produce components, derive simple text ranges.
571        if components.is_empty() && text.is_some() {
572            components = parse_body_legacy(&text);
573        }
574
575        self.text = text;
576        self.components = components;
577        self.edited_parts = edited_parts;
578        self.balloon_bundle_id = balloon_bundle_id;
579    }
580
581    // MARK: Dates
582    /// Decode [`date`](Self::date) to an instant.
583    ///
584    /// Stored as nanoseconds (seconds in older rows) since the Messages epoch,
585    /// `2001-01-01 00:00:00` UTC. Call `with_timezone()` to render in another zone.
586    pub fn date(&self) -> Result<DateTime<Utc>, MessageError> {
587        to_utc(self.date)
588    }
589
590    /// Decode [`date_delivered`](Self::date_delivered) to an instant.
591    ///
592    /// Stored as nanoseconds (seconds in older rows) since the Messages epoch,
593    /// `2001-01-01 00:00:00` UTC. Call `with_timezone()` to render in another zone.
594    pub fn date_delivered(&self) -> Result<DateTime<Utc>, MessageError> {
595        to_utc(self.date_delivered)
596    }
597
598    /// Decode [`date_read`](Self::date_read) to an instant.
599    ///
600    /// Stored as nanoseconds (seconds in older rows) since the Messages epoch,
601    /// `2001-01-01 00:00:00` UTC. Call `with_timezone()` to render in another zone.
602    pub fn date_read(&self) -> Result<DateTime<Utc>, MessageError> {
603        to_utc(self.date_read)
604    }
605
606    /// Decode [`date_edited`](Self::date_edited) to an instant.
607    ///
608    /// Stored as nanoseconds (seconds in older rows) since the Messages epoch,
609    /// `2001-01-01 00:00:00` UTC. Call `with_timezone()` to render in another zone.
610    pub fn date_edited(&self) -> Result<DateTime<Utc>, MessageError> {
611        to_utc(self.date_edited)
612    }
613
614    /// Calculate the elapsed time until the message was read or delivered.
615    ///
616    /// This can happen in two ways:
617    ///
618    /// - You received a message, then waited to read it
619    /// - You sent a message, and the recipient waited to read it
620    ///
621    /// In the former case, this computes the difference from the date received (`date`) to the date read (`date_read`).
622    /// In the latter case, this computes the difference from the date sent (`date`) to the date delivered (`date_delivered`).
623    ///
624    /// Not all messages get tagged with the read properties.
625    /// If more than one message has been sent in a thread before getting read,
626    /// only the most recent message will get the tag.
627    ///
628    /// Use the calendar in `zone` to count whole years; see
629    /// [`readable_diff`](crate::util::dates::readable_diff).
630    #[must_use]
631    pub fn time_until_read<Tz: TimeZone>(&self, zone: &Tz) -> Option<String> {
632        // Message we received
633        if !self.is_from_me && self.date_read != 0 && self.date != 0 {
634            return readable_diff(
635                &self.date().ok()?.with_timezone(zone),
636                &self.date_read().ok()?.with_timezone(zone),
637            );
638        }
639        // Message we sent
640        else if self.is_from_me && self.date_delivered != 0 && self.date != 0 {
641            return readable_diff(
642                &self.date().ok()?.with_timezone(zone),
643                &self.date_delivered().ok()?.with_timezone(zone),
644            );
645        }
646        None
647    }
648
649    // MARK: Bools
650    /// `true` when the message is a thread reply.
651    #[must_use]
652    pub fn is_reply(&self) -> bool {
653        self.thread_originator_guid.is_some()
654    }
655
656    /// `true` when the message is an [`Announcement`].
657    #[must_use]
658    pub fn is_announcement(&self) -> bool {
659        self.get_announcement().is_some()
660    }
661
662    /// `true` when the message is a [`Tapback`] to another message.
663    #[must_use]
664    pub fn is_tapback(&self) -> bool {
665        matches!(self.variant(), Variant::Tapback(..))
666    }
667
668    /// `true` when the message has an [`Expressive`] send effect.
669    #[must_use]
670    pub fn is_expressive(&self) -> bool {
671        self.expressive_send_style_id.is_some()
672    }
673
674    /// `true` when the message has a [URL preview](crate::message_types::url).
675    #[must_use]
676    pub fn is_url(&self) -> bool {
677        matches!(self.variant(), Variant::App(CustomBalloon::URL))
678    }
679
680    /// `true` when the message is a [`HandwrittenMessage`](crate::message_types::handwriting::HandwrittenMessage).
681    #[must_use]
682    pub fn is_handwriting(&self) -> bool {
683        matches!(self.variant(), Variant::App(CustomBalloon::Handwriting))
684    }
685
686    /// `true` when the message is a [`Digital Touch`](crate::message_types::digital_touch) message.
687    #[must_use]
688    pub fn is_digital_touch(&self) -> bool {
689        matches!(self.variant(), Variant::App(CustomBalloon::DigitalTouch))
690    }
691
692    /// `true` when the message is a [`Poll`].
693    #[must_use]
694    pub fn is_poll(&self) -> bool {
695        matches!(self.variant(), Variant::App(CustomBalloon::Polls))
696    }
697
698    /// `true` when the message is a [`PollVote`](crate::message_types::polls::PollVote).
699    #[must_use]
700    pub fn is_poll_vote(&self) -> bool {
701        self.associated_message_type == Some(4000)
702    }
703
704    /// `true` when the message adds or updates poll options.
705    #[must_use]
706    pub fn is_poll_update(&self) -> bool {
707        matches!(self.variant(), Variant::PollUpdate)
708    }
709
710    /// `true` when the message was [`edited`](crate::message_types::edited).
711    #[must_use]
712    pub fn is_edited(&self) -> bool {
713        self.date_edited != 0
714    }
715
716    /// `true` when the specified message component was [edited](crate::message_types::edited::EditStatus::Edited).
717    #[must_use]
718    pub fn is_part_edited(&self, index: usize) -> bool {
719        if let Some(edited_parts) = &self.edited_parts
720            && let Some(part) = edited_parts.part(index)
721        {
722            return matches!(part.status, EditStatus::Edited);
723        }
724        false
725    }
726
727    /// `true` when all message components were [unsent](crate::message_types::edited::EditStatus::Unsent).
728    #[must_use]
729    pub fn is_fully_unsent(&self) -> bool {
730        self.edited_parts.as_ref().is_some_and(|ep| {
731            ep.parts
732                .iter()
733                .all(|part| matches!(part.status, EditStatus::Unsent))
734        })
735    }
736
737    /// `true` when the message contains [`Attachment`](crate::tables::attachment::Attachment)s.
738    ///
739    /// Attachments can be queried with [`Attachment::from_message()`](crate::tables::attachment::Attachment::from_message).
740    #[must_use]
741    pub fn has_attachments(&self) -> bool {
742        self.num_attachments > 0
743    }
744
745    /// `true` when the message begins a thread.
746    #[must_use]
747    pub fn has_replies(&self) -> bool {
748        self.num_replies > 0
749    }
750
751    /// `true` when the message indicates a sent audio message was kept.
752    #[must_use]
753    pub fn is_kept_audio_message(&self) -> bool {
754        self.item_type == 5
755    }
756
757    /// `true` when the message is a [SharePlay/FaceTime](crate::message_types::variants::Variant::SharePlay) message.
758    #[must_use]
759    pub fn is_shareplay(&self) -> bool {
760        self.item_type == 6
761    }
762
763    /// `true` when the message was sent by the database owner.
764    #[must_use]
765    pub fn is_from_me(&self) -> bool {
766        // Share direction and other handle are only populated for shared location messages,
767        // so this check is only necessary for those
768        if self.item_type == 4
769            && let (Some(other_handle), Some(share_direction)) =
770                (self.other_handle, self.share_direction)
771        {
772            self.is_from_me || other_handle != 0 && !share_direction
773        } else {
774            self.is_from_me
775        }
776    }
777
778    /// Returns the [`SharedLocation`] when the message is a legacy
779    /// shared-location event.
780    #[must_use]
781    pub fn shared_location_kind(&self) -> Option<SharedLocation> {
782        if self.item_type == 4 && self.group_action_type == 0 {
783            Some(if self.share_status {
784                SharedLocation::Stopped
785            } else {
786                SharedLocation::Started
787            })
788        } else {
789            None
790        }
791    }
792
793    /// `true` when the message is present in the recoverable deleted-message table.
794    ///
795    /// Messages removed by deleting an entire conversation or by deleting a single message
796    /// from a conversation are moved to a separate collection for up to 30 days. Messages
797    /// present in this collection are restored to the conversations they belong to. Apple
798    /// details this process [here](https://support.apple.com/en-us/HT202549#delete).
799    ///
800    /// Messages that have expired from this restoration process are permanently deleted and
801    /// cannot be recovered.
802    ///
803    /// Note: This is not the same as an [`Unsent`](crate::message_types::edited::EditStatus::Unsent) message.
804    #[must_use]
805    pub fn is_deleted(&self) -> bool {
806        self.deleted_from.is_some()
807    }
808
809    /// The [`rowid`](crate::tables::chat::Chat::rowid) of the chat this message belongs to.
810    ///
811    /// A recovered deleted message has no `chat_message_join` row, so its
812    /// [`chat_id`](Self::chat_id) is `None` and [`deleted_from`](Self::deleted_from)
813    /// is the only reference to its chat. Resolve a message's chat through this
814    /// method so its transcript and attachments land in the same conversation.
815    #[must_use]
816    pub fn chat_rowid(&self) -> Option<i32> {
817        self.chat_id.or(self.deleted_from)
818    }
819
820    /// `true` when the message summary includes translation metadata.
821    pub fn has_translation(&self, db: &Connection) -> bool {
822        // `7472616E736C6174696F6E4C616E6775616765` -> "translationLanguage"
823        // `7472616E736C6174656454657874` -> "translatedText"
824        let query = format!(
825            "SELECT ROWID FROM {MESSAGE} 
826                WHERE message_summary_info IS NOT NULL 
827                AND length(message_summary_info) > 61 
828                AND instr(message_summary_info, X'7472616E736C6174696F6E4C616E6775616765') > 0 
829                AND instr(message_summary_info, X'7472616E736C6174656454657874') > 0 
830                AND ROWID = ?"
831        );
832        if let Ok(mut statement) = db.prepare_cached(&query) {
833            let result: Result<i32, _> = statement.query_row([self.rowid], |row| row.get(0));
834            result.is_ok()
835        } else {
836            false
837        }
838    }
839
840    /// Parse translation metadata for the message.
841    pub fn get_translation(&self, db: &Connection) -> Result<Option<Translation>, MessageError> {
842        if let Some(payload) = self.message_summary_info(db) {
843            return Ok(Some(Translation::from_payload(&payload)?));
844        }
845        Ok(None)
846    }
847
848    /// Cache message GUIDs whose summaries include translation metadata.
849    pub fn cache_translations(db: &Connection) -> Result<HashSet<String>, TableError> {
850        // `7472616E736C6174696F6E4C616E6775616765` -> "translationLanguage"
851        // `7472616E736C6174656454657874` -> "translatedText"
852        let query = format!(
853            "SELECT guid FROM {MESSAGE} 
854                WHERE message_summary_info IS NOT NULL 
855                AND length(message_summary_info) > 61 
856                AND instr(message_summary_info, X'7472616E736C6174696F6E4C616E6775616765') > 0 
857                AND instr(message_summary_info, X'7472616E736C6174656454657874') > 0"
858        );
859
860        let mut statement = db.prepare(&query)?;
861        let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
862
863        let mut guids = HashSet::new();
864        for guid_result in rows {
865            guids.insert(guid_result?);
866        }
867
868        Ok(guids)
869    }
870
871    /// Parse the group action encoded by the message.
872    #[must_use]
873    pub fn group_action(&'_ self) -> Option<GroupAction<'_>> {
874        GroupAction::from_message(self)
875    }
876
877    /// Parse the body component index targeted by a reply.
878    fn get_reply_index(&self) -> usize {
879        if let Some(parts) = &self.thread_originator_part {
880            return match parts.split(':').next() {
881                Some(part) => str::parse::<usize>(part).unwrap_or(0),
882                None => 0,
883            };
884        }
885        0
886    }
887
888    // MARK: SQL
889    /// Build the SQL `WHERE` clause described by a [`QueryContext`].
890    ///
891    /// If `include_recoverable` is `true`, the filter includes messages from the recently deleted messages
892    /// table that match the chat IDs. This allows recovery of deleted messages that are still
893    /// present in the database but no longer visible in the Messages app.
894    pub(crate) fn generate_filter_statement(
895        context: &QueryContext,
896        include_recoverable: bool,
897    ) -> String {
898        let mut filters = String::with_capacity(128);
899
900        // Start date filter
901        if let Some(start) = context.start {
902            let _ = write!(filters, " m.date >= {start}");
903        }
904
905        // End date filter
906        if let Some(end) = context.end {
907            if !filters.is_empty() {
908                filters.push_str(" AND ");
909            }
910            let _ = write!(filters, " m.date <= {end}");
911        }
912
913        // Chat ID filter, optionally including recoverable messages
914        if let Some(chat_ids) = &context.selected_chat_ids {
915            if !filters.is_empty() {
916                filters.push_str(" AND ");
917            }
918
919            // Allocate the filter string for interpolation
920            let ids = chat_ids
921                .iter()
922                .map(std::string::ToString::to_string)
923                .collect::<Vec<String>>()
924                .join(", ");
925
926            if include_recoverable {
927                let _ = write!(filters, " (c.chat_id IN ({ids}) OR d.chat_id IN ({ids}))");
928            } else {
929                let _ = write!(filters, " c.chat_id IN ({ids})");
930            }
931        }
932
933        if !filters.is_empty() {
934            return format!("WHERE {filters}");
935        }
936        filters
937    }
938
939    /// Count messages matching the provided query context.
940    ///
941    /// # Example
942    ///
943    /// ```no_run
944    /// use imessage_database::tables::{
945    ///     capabilities::Capabilities,
946    ///     messages::Message,
947    ///     table::get_connection,
948    /// };
949    /// use imessage_database::util::dirs::default_db_path;
950    /// use imessage_database::util::query_context::QueryContext;
951    ///
952    /// let db_path = default_db_path().unwrap();
953    /// let conn = get_connection(&db_path).unwrap();
954    /// let capabilities = Capabilities::determine(&conn).unwrap();
955    /// let context = QueryContext::default();
956    /// Message::get_count(&conn, &capabilities, &context);
957    /// ```
958    pub fn get_count(
959        db: &Connection,
960        capabilities: &Capabilities,
961        context: &QueryContext,
962    ) -> Result<i64, TableError> {
963        // The unfiltered count skips the chat join: `chat_message_join` can
964        // associate one message with several chats, and every extra
965        // association would inflate `COUNT(*)`.
966        let mut statement = if context.has_filters() {
967            let filters =
968                Self::generate_filter_statement(context, capabilities.recoverable_messages);
969            db.prepare_cached(&format!(
970                "SELECT COUNT(*){}\n{filters}",
971                from_clause(capabilities)
972            ))?
973        } else {
974            db.prepare_cached(&format!("SELECT COUNT(*) FROM {MESSAGE}"))?
975        };
976        // Execute query, defaulting to zero if it fails
977        let count: i64 = statement.query_row([], |r| r.get(0)).unwrap_or(0);
978
979        Ok(count)
980    }
981
982    /// Stream messages from the database with optional filters.
983    ///
984    /// # Example
985    ///
986    /// ```no_run
987    /// use imessage_database::tables::{
988    ///     capabilities::Capabilities,
989    ///     messages::Message,
990    ///     table::get_connection,
991    /// };
992    /// use imessage_database::util::dirs::default_db_path;
993    /// use imessage_database::util::query_context::QueryContext;
994    ///
995    /// let db_path = default_db_path().unwrap();
996    /// let conn = get_connection(&db_path).unwrap();
997    /// let capabilities = Capabilities::determine(&conn).unwrap();
998    /// let context = QueryContext::default();
999    ///
1000    /// let mut statement = Message::stream_rows(&conn, &capabilities, &context).unwrap();
1001    ///
1002    /// for message in Message::rows(&mut statement, []).unwrap() {
1003    ///     println!("{:#?}", message);
1004    /// }
1005    /// ```
1006    pub fn stream_rows<'a>(
1007        db: &'a Connection,
1008        capabilities: &Capabilities,
1009        context: &'a QueryContext,
1010    ) -> Result<CachedStatement<'a>, TableError> {
1011        let filters = context
1012            .has_filters()
1013            .then(|| Self::generate_filter_statement(context, capabilities.recoverable_messages));
1014        prepare_message_query(db, capabilities, filters.as_deref())
1015    }
1016
1017    /// Parse the target body component index and GUID from `associated_message_guid`.
1018    ///
1019    /// Returns a tuple of (component index, message GUID) if present.
1020    #[must_use]
1021    pub fn clean_associated_guid(&self) -> Option<(usize, &str)> {
1022        if let Some(guid) = &self.associated_message_guid {
1023            if guid.starts_with("p:") {
1024                let mut split = guid.split('/');
1025                let index_str = split.next()?;
1026                let message_id = split.next()?;
1027                let index = str::parse::<usize>(&index_str.replace("p:", "")).unwrap_or(0);
1028                return Some((index, message_id.get(0..36)?));
1029            } else if guid.starts_with("bp:") {
1030                return Some((0, guid.get(3..39)?));
1031            }
1032
1033            return Some((0, guid.get(0..36)?));
1034        }
1035        None
1036    }
1037
1038    /// Parse the target body component index for a tapback.
1039    fn tapback_index(&self) -> usize {
1040        match self.clean_associated_guid() {
1041            Some((x, _)) => x,
1042            None => 0,
1043        }
1044    }
1045
1046    /// Group replies by target body component index.
1047    pub fn get_replies(
1048        &self,
1049        db: &Connection,
1050        capabilities: &Capabilities,
1051    ) -> Result<HashMap<usize, Vec<Self>>, TableError> {
1052        let mut out_h: HashMap<usize, Vec<Self>> = HashMap::new();
1053
1054        // No need to hit the DB if we know we don't have replies. A nonzero
1055        // `num_replies` also proves the schema has `thread_originator_guid`,
1056        // so the filter below always prepares.
1057        if self.has_replies() {
1058            // Use a parameterized filter so the prepared statement can be cached/reused
1059            let filters = "WHERE m.thread_originator_guid = ?1";
1060            let mut statement = prepare_message_query(db, capabilities, Some(filters))?;
1061
1062            for message in Message::rows(&mut statement, [self.guid.as_str()])? {
1063                let m = message?;
1064                let idx = m.get_reply_index();
1065                match out_h.get_mut(&idx) {
1066                    Some(body_part) => body_part.push(m),
1067                    None => {
1068                        out_h.insert(idx, vec![m]);
1069                    }
1070                }
1071            }
1072        }
1073
1074        Ok(out_h)
1075    }
1076
1077    // MARK: Polls
1078    /// Load messages that vote on or update the parent poll.
1079    pub fn get_votes(
1080        &self,
1081        db: &Connection,
1082        capabilities: &Capabilities,
1083    ) -> Result<Vec<Self>, TableError> {
1084        let mut out_v: Vec<Self> = Vec::new();
1085
1086        // No need to hit the DB if we know we don't have a poll. Polls carry
1087        // app payload data, which postdates `associated_message_guid`, so the
1088        // filter below always prepares.
1089        if self.is_poll() {
1090            // Use a parameterized filter so the prepared statement can be cached/reused
1091            let filters = "WHERE m.associated_message_guid = ?1";
1092            let mut statement = prepare_message_query(db, capabilities, Some(filters))?;
1093
1094            for message in Message::rows(&mut statement, [self.guid.as_str()])? {
1095                out_v.push(message?);
1096            }
1097        }
1098
1099        Ok(out_v)
1100    }
1101
1102    /// Parse this message as a poll, including vote counts and option updates.
1103    pub fn as_poll(
1104        &self,
1105        db: &Connection,
1106        capabilities: &Capabilities,
1107    ) -> Result<Option<Poll>, MessageError> {
1108        if self.is_poll()
1109            && let Some(payload) = self.payload_data(db)
1110        {
1111            let mut poll = Poll::from_payload(&payload)?;
1112
1113            // Get all votes associated with this poll
1114            let votes = self.get_votes(db, capabilities).unwrap_or_default();
1115
1116            // Later poll-option updates are stored as messages referencing the original poll.
1117            for vote in votes.iter().rev() {
1118                // The most recent non-vote message is the latest poll update
1119                // and contains all of the possible options
1120                if !vote.is_poll_vote()
1121                    && let Some(vote_payload) = vote.payload_data(db)
1122                    && let Ok(update) = Poll::from_payload(&vote_payload)
1123                {
1124                    poll = update;
1125                    break;
1126                }
1127            }
1128
1129            // Poll update messages share the same association field but do not cast votes.
1130            for vote in &votes {
1131                if vote.is_poll_vote()
1132                    && let Some(vote_payload) = vote.payload_data(db)
1133                {
1134                    poll.count_votes(&vote_payload)?;
1135                }
1136            }
1137
1138            return Ok(Some(poll));
1139        }
1140
1141        Ok(None)
1142    }
1143
1144    // MARK: Variant
1145    /// Classify the message using its associated-message fields and app balloon bundle ID.
1146    #[must_use]
1147    pub fn variant(&'_ self) -> Variant<'_> {
1148        // Edited messages expose their original type through `edited_parts`.
1149        if self.is_edited() {
1150            return Variant::Edited;
1151        }
1152
1153        // Handle different types of associated message types
1154        if let Some(associated_message_type) = self.associated_message_type {
1155            match associated_message_type {
1156                // Standard iMessages with either text or an app payload.
1157                0 | 2 | 3 => return self.get_app_variant().unwrap_or(Variant::Normal),
1158                // Tapbacks, added or removed.
1159                1000 | 2000..=2007 | 3000..=3007 => {
1160                    if let Some((action, tapback)) = self.get_tapback() {
1161                        return Variant::Tapback(self.tapback_index(), action, tapback);
1162                    }
1163                }
1164                // A vote was cast on a poll.
1165                4000 => return Variant::Vote,
1166                x => return Variant::Unknown(x),
1167            }
1168        }
1169
1170        // Any other rarer cases belong here
1171        if self.is_shareplay() {
1172            return Variant::SharePlay;
1173        }
1174
1175        Variant::Normal
1176    }
1177
1178    /// Classify app-message variants from the balloon bundle ID.
1179    #[must_use]
1180    fn get_app_variant(&self) -> Option<Variant<'_>> {
1181        let bundle_id = parse_balloon_bundle_id(self.balloon_bundle_id.as_deref())?;
1182        let custom = match bundle_id {
1183            "com.apple.messages.URLBalloonProvider" => CustomBalloon::URL,
1184            "com.apple.Handwriting.HandwritingProvider" => CustomBalloon::Handwriting,
1185            "com.apple.DigitalTouchBalloonProvider" => CustomBalloon::DigitalTouch,
1186            "com.apple.PassbookUIService.PeerPaymentMessagesExtension" => CustomBalloon::ApplePay,
1187            "com.apple.ActivityMessagesApp.MessagesExtension" => CustomBalloon::Fitness,
1188            "com.apple.mobileslideshow.PhotosMessagesApp" => CustomBalloon::Slideshow,
1189            "com.apple.SafetyMonitorApp.SafetyMonitorMessages" => CustomBalloon::CheckIn,
1190            "com.apple.findmy.FindMyMessagesApp" => CustomBalloon::FindMy,
1191            "com.apple.icloud.apps.messages.business.extension" => CustomBalloon::Business,
1192            "com.apple.messages.Polls" => {
1193                // Special case: Check if this is the original poll or an update
1194                if self
1195                    .associated_message_guid
1196                    .as_ref()
1197                    .is_none_or(|id| id == &self.guid)
1198                {
1199                    CustomBalloon::Polls
1200                } else {
1201                    return Some(Variant::PollUpdate);
1202                }
1203            }
1204            _ => CustomBalloon::Application(bundle_id),
1205        };
1206        Some(Variant::App(custom))
1207    }
1208
1209    /// Classify tapback action and type from the associated message type.
1210    #[must_use]
1211    fn get_tapback(&self) -> Option<(TapbackAction, Tapback<'_>)> {
1212        match self.associated_message_type? {
1213            1000 => Some((TapbackAction::Added, Tapback::Sticker)),
1214            2000 => Some((TapbackAction::Added, Tapback::Loved)),
1215            2001 => Some((TapbackAction::Added, Tapback::Liked)),
1216            2002 => Some((TapbackAction::Added, Tapback::Disliked)),
1217            2003 => Some((TapbackAction::Added, Tapback::Laughed)),
1218            2004 => Some((TapbackAction::Added, Tapback::Emphasized)),
1219            2005 => Some((TapbackAction::Added, Tapback::Questioned)),
1220            2006 => Some((
1221                TapbackAction::Added,
1222                Tapback::Emoji(self.associated_message_emoji.as_deref()),
1223            )),
1224            2007 => Some((TapbackAction::Added, Tapback::Sticker)),
1225            3000 => Some((TapbackAction::Removed, Tapback::Loved)),
1226            3001 => Some((TapbackAction::Removed, Tapback::Liked)),
1227            3002 => Some((TapbackAction::Removed, Tapback::Disliked)),
1228            3003 => Some((TapbackAction::Removed, Tapback::Laughed)),
1229            3004 => Some((TapbackAction::Removed, Tapback::Emphasized)),
1230            3005 => Some((TapbackAction::Removed, Tapback::Questioned)),
1231            3006 => Some((
1232                TapbackAction::Removed,
1233                Tapback::Emoji(self.associated_message_emoji.as_deref()),
1234            )),
1235            3007 => Some((TapbackAction::Removed, Tapback::Sticker)),
1236            _ => None,
1237        }
1238    }
1239
1240    /// Parse the announcement represented by this message.
1241    #[must_use]
1242    pub fn get_announcement(&'_ self) -> Option<Announcement<'_>> {
1243        if let Some(action) = self.group_action() {
1244            return Some(Announcement::GroupAction(action));
1245        }
1246
1247        if self.is_fully_unsent() {
1248            return Some(Announcement::FullyUnsent);
1249        }
1250
1251        if self.is_kept_audio_message() {
1252            return Some(Announcement::AudioMessageKept);
1253        }
1254
1255        None
1256    }
1257
1258    /// Parse the message service.
1259    #[must_use]
1260    pub fn service(&'_ self) -> Service<'_> {
1261        Service::from_name(self.service.as_deref())
1262    }
1263
1264    /// Parse the message's raw filter category.
1265    ///
1266    /// A raw `0` maps to [`FilterAction::Unfiltered`]; an absent or `NULL` value
1267    /// maps to `None`.
1268    #[must_use]
1269    pub fn filter_action(&self) -> Option<FilterAction> {
1270        FilterAction::from_code(self.filter_action)
1271    }
1272
1273    // MARK: BLOBs
1274    /// Parse the [`MESSAGE_PAYLOAD`] `BLOB` column as a property list.
1275    ///
1276    /// Calling this reads a `BLOB` from the database.
1277    ///
1278    /// This column contains data used by iMessage app balloons and can be parsed with
1279    /// [`parse_ns_keyed_archiver()`](crate::util::plist::parse_ns_keyed_archiver).
1280    pub fn payload_data(&self, db: &Connection) -> Option<Value> {
1281        // Read the blob into memory first, then parse from a `Cursor`.
1282        Value::from_reader(Cursor::new(self.raw_payload_data(db)?)).ok()
1283    }
1284
1285    /// Read the raw [`MESSAGE_PAYLOAD`] `BLOB` bytes.
1286    ///
1287    /// Calling this reads a `BLOB` from the database.
1288    ///
1289    /// This column contains data used by [`HandwrittenMessage`](crate::message_types::handwriting::HandwrittenMessage)s.
1290    pub fn raw_payload_data(&self, db: &Connection) -> Option<Vec<u8>> {
1291        let mut buf = Vec::new();
1292        open_blob(db, MESSAGE, MESSAGE_PAYLOAD, self.rowid.into())?
1293            .read_to_end(&mut buf)
1294            .ok()?;
1295        Some(buf)
1296    }
1297
1298    /// Parse the [`MESSAGE_SUMMARY_INFO`] `BLOB` column as a property list.
1299    ///
1300    /// Calling this reads a `BLOB` from the database.
1301    ///
1302    /// This column contains data used by [`edited`](crate::message_types::edited) iMessages.
1303    pub fn message_summary_info(&self, db: &Connection) -> Option<Value> {
1304        // Bulk-read the blob, then parse from memory.
1305        let mut buf = Vec::new();
1306        open_blob(db, MESSAGE, MESSAGE_SUMMARY_INFO, self.rowid.into())?
1307            .read_to_end(&mut buf)
1308            .ok()?;
1309        Value::from_reader(Cursor::new(buf)).ok()
1310    }
1311
1312    // MARK: Expressive
1313    /// Parse the expressive send effect.
1314    #[must_use]
1315    pub fn get_expressive(&'_ self) -> Expressive<'_> {
1316        match &self.expressive_send_style_id {
1317            Some(content) => match content.as_str() {
1318                "com.apple.MobileSMS.expressivesend.gentle" => {
1319                    Expressive::Bubble(BubbleEffect::Gentle)
1320                }
1321                "com.apple.MobileSMS.expressivesend.impact" => {
1322                    Expressive::Bubble(BubbleEffect::Slam)
1323                }
1324                "com.apple.MobileSMS.expressivesend.invisibleink" => {
1325                    Expressive::Bubble(BubbleEffect::InvisibleInk)
1326                }
1327                "com.apple.MobileSMS.expressivesend.loud" => Expressive::Bubble(BubbleEffect::Loud),
1328                "com.apple.messages.effect.CKConfettiEffect" => {
1329                    Expressive::Screen(ScreenEffect::Confetti)
1330                }
1331                "com.apple.messages.effect.CKEchoEffect" => Expressive::Screen(ScreenEffect::Echo),
1332                "com.apple.messages.effect.CKFireworksEffect" => {
1333                    Expressive::Screen(ScreenEffect::Fireworks)
1334                }
1335                "com.apple.messages.effect.CKHappyBirthdayEffect" => {
1336                    Expressive::Screen(ScreenEffect::Balloons)
1337                }
1338                "com.apple.messages.effect.CKHeartEffect" => {
1339                    Expressive::Screen(ScreenEffect::Heart)
1340                }
1341                "com.apple.messages.effect.CKLasersEffect" => {
1342                    Expressive::Screen(ScreenEffect::Lasers)
1343                }
1344                "com.apple.messages.effect.CKShootingStarEffect" => {
1345                    Expressive::Screen(ScreenEffect::ShootingStar)
1346                }
1347                "com.apple.messages.effect.CKSparklesEffect" => {
1348                    Expressive::Screen(ScreenEffect::Sparkles)
1349                }
1350                "com.apple.messages.effect.CKSpotlightEffect" => {
1351                    Expressive::Screen(ScreenEffect::Spotlight)
1352                }
1353                _ => Expressive::Unknown(content),
1354            },
1355            None => Expressive::None,
1356        }
1357    }
1358
1359    /// Query a single message by [`GUID`](Self::guid).
1360    ///
1361    /// The returned message includes parsed [`components`](Self::components)
1362    /// and [`edited_parts`](Self::edited_parts) when available. Body parsing
1363    /// failures preserve the row's metadata.
1364    ///
1365    /// # Example
1366    /// ```no_run
1367    /// use imessage_database::{
1368    ///     tables::{
1369    ///         capabilities::Capabilities,
1370    ///         messages::Message,
1371    ///         table::get_connection,
1372    ///     },
1373    ///     util::dirs::default_db_path,
1374    /// };
1375    ///
1376    /// let db_path = default_db_path().unwrap();
1377    /// let conn = get_connection(&db_path).unwrap();
1378    /// let capabilities = Capabilities::determine(&conn).unwrap();
1379    ///
1380    /// if let Ok(message) = Message::from_guid("example-guid", &conn, &capabilities) {
1381    ///     println!("Text: {:?}", message.text);
1382    ///     println!("Body components: {:#?}", message.components);
1383    ///     println!("Edit history: {:#?}", message.edited_parts);
1384    /// }
1385    /// ```
1386    pub fn from_guid(
1387        guid: &str,
1388        db: &Connection,
1389        capabilities: &Capabilities,
1390    ) -> Result<Self, TableError> {
1391        let mut statement = prepare_message_query(db, capabilities, Some("WHERE m.guid = ?1"))?;
1392
1393        Message::row(&mut statement, [guid])
1394    }
1395}
1396
1397// MARK: Fixture
1398#[cfg(test)]
1399impl Message {
1400    #[must_use]
1401    /// Build a blank test message with default values.
1402    pub fn blank() -> Message {
1403        use std::vec;
1404
1405        Message {
1406            rowid: i32::default(),
1407            guid: String::default(),
1408            text: None,
1409            service: Some("iMessage".to_string()),
1410            handle_id: Some(i32::default()),
1411            destination_caller_id: None,
1412            subject: None,
1413            date: i64::default(),
1414            date_read: i64::default(),
1415            date_delivered: i64::default(),
1416            is_from_me: false,
1417            is_read: false,
1418            item_type: 0,
1419            other_handle: None,
1420            share_status: false,
1421            share_direction: None,
1422            group_title: None,
1423            group_action_type: 0,
1424            associated_message_guid: None,
1425            associated_message_type: None,
1426            balloon_bundle_id: None,
1427            expressive_send_style_id: None,
1428            thread_originator_guid: None,
1429            thread_originator_part: None,
1430            date_edited: 0,
1431            associated_message_emoji: None,
1432            chat_id: None,
1433            num_attachments: 0,
1434            deleted_from: None,
1435            num_replies: 0,
1436            filter_action: None,
1437            filter_sub_action: None,
1438            components: vec![],
1439            edited_parts: None,
1440        }
1441    }
1442}
1443
1444#[cfg(test)]
1445mod diagnostic_tests {
1446    use rusqlite::Connection;
1447
1448    use crate::tables::messages::Message;
1449
1450    fn diagnostic_db() -> Connection {
1451        let db = Connection::open_in_memory().unwrap();
1452        db.execute_batch(
1453            "
1454            CREATE TABLE message (
1455                ROWID INTEGER PRIMARY KEY,
1456                date INTEGER
1457            );
1458            CREATE TABLE chat_message_join (
1459                chat_id INTEGER,
1460                message_id INTEGER
1461            );
1462            INSERT INTO message (ROWID, date) VALUES (1, 10), (2, 20);
1463            INSERT INTO chat_message_join (chat_id, message_id) VALUES (1, 1);
1464            ",
1465        )
1466        .unwrap();
1467        db
1468    }
1469
1470    #[test]
1471    fn diagnostic_omits_recoverable_count_when_table_is_missing() {
1472        let db = diagnostic_db();
1473
1474        let diagnostic = Message::run_diagnostic(&db).unwrap();
1475
1476        assert_eq!(diagnostic.total_messages, 2);
1477        assert_eq!(diagnostic.messages_without_chat, 1);
1478        assert_eq!(diagnostic.recoverable_messages, None);
1479    }
1480
1481    #[test]
1482    fn diagnostic_counts_recoverable_messages_when_table_exists() {
1483        let db = diagnostic_db();
1484        db.execute_batch(
1485            "
1486            CREATE TABLE chat_recoverable_message_join (
1487                chat_id INTEGER,
1488                message_id INTEGER
1489            );
1490            INSERT INTO chat_recoverable_message_join (chat_id, message_id) VALUES (1, 2);
1491            ",
1492        )
1493        .unwrap();
1494
1495        let diagnostic = Message::run_diagnostic(&db).unwrap();
1496
1497        assert_eq!(diagnostic.recoverable_messages, Some(1));
1498    }
1499}