From e0f852bd51d54965da92ce4967692af6b9e72d3d Mon Sep 17 00:00:00 2001 From: Sachit Vithaldas <1705581+sachitv@users.noreply.github.com> Date: Sun, 23 Nov 2025 23:34:13 -0800 Subject: [PATCH] feat: add group filter option --- imessage-exporter/README.md | 7 + imessage-exporter/src/app/options.rs | 46 +++++- imessage-exporter/src/app/runtime.rs | 209 ++++++++++++++++++++++++ imessage-exporter/src/exporters/html.rs | 7 +- imessage-exporter/src/exporters/txt.rs | 7 +- imessage-exporter/src/main.rs | 1 + 6 files changed, 270 insertions(+), 7 deletions(-) diff --git a/imessage-exporter/README.md b/imessage-exporter/README.md index 2ad5a3a1..ece3e866 100644 --- a/imessage-exporter/README.md +++ b/imessage-exporter/README.md @@ -109,6 +109,13 @@ The [releases page](https://github.com/ReagentX/imessage-exporter/releases) prov All conversations with the specified participants are exported, including group conversations Example: `-t steve@apple.com,5558675309` +-g, --group-filter + Filter exported group conversations by their names + To provide multiple group names, use a comma-separated string + Example: `-g "Family Chat,Work Group"` + +Note: When both `--conversation-filter` and `--group-filter` are specified, only conversations that match both filters (intersection) will be exported. + -x, --cleartext-password Optional password for encrypted iOS backups This is only used when the source is an encrypted iOS backup directory diff --git a/imessage-exporter/src/app/options.rs b/imessage-exporter/src/app/options.rs index 7efa9f8e..265327ab 100644 --- a/imessage-exporter/src/app/options.rs +++ b/imessage-exporter/src/app/options.rs @@ -40,6 +40,7 @@ pub const OPTION_PLATFORM: &str = "platform"; pub const OPTION_BYPASS_FREE_SPACE_CHECK: &str = "ignore-disk-warning"; pub const OPTION_USE_CALLER_ID: &str = "use-caller-id"; pub const OPTION_CONVERSATION_FILTER: &str = "conversation-filter"; +pub const OPTION_GROUP_FILTER: &str = "group-filter"; pub const OPTION_CLEARTEXT_PASSWORD: &str = "cleartext-password"; pub const OPTION_CUSTOM_CONTACTS_DB_PATH: &str = "contacts-path"; @@ -82,6 +83,8 @@ pub struct Options { pub ignore_disk_space: bool, /// An optional filter for conversation participants pub conversation_filter: Option, + /// An optional filter for group chat display names + pub group_filter: Option, /// An optional password for encrypted backups pub cleartext_password: Option, /// An optional path to a custom contacts database @@ -105,6 +108,7 @@ impl Options { let platform_type: Option<&String> = args.get_one(OPTION_PLATFORM); let ignore_disk_space = args.get_flag(OPTION_BYPASS_FREE_SPACE_CHECK); let conversation_filter: Option<&String> = args.get_one(OPTION_CONVERSATION_FILTER); + let group_filter: Option<&String> = args.get_one(OPTION_GROUP_FILTER); let cleartext_password: Option<&String> = args.get_one(OPTION_CLEARTEXT_PASSWORD); let contacts_path: Option<&String> = args.get_one(OPTION_CUSTOM_CONTACTS_DB_PATH); @@ -126,9 +130,10 @@ impl Options { (no_lazy, OPTION_DISABLE_LAZY_LOADING), (start_date.is_some(), OPTION_START_DATE), (end_date.is_some(), OPTION_END_DATE), - (custom_name.is_some(), OPTION_CUSTOM_NAME), - (use_caller_id, OPTION_USE_CALLER_ID), - (conversation_filter.is_some(), OPTION_CONVERSATION_FILTER), + (custom_name.is_some(), OPTION_CUSTOM_NAME), + (use_caller_id, OPTION_USE_CALLER_ID), + (conversation_filter.is_some(), OPTION_CONVERSATION_FILTER), + (group_filter.is_some(), OPTION_GROUP_FILTER), ]; for (set, opt) in format_deps { if set { @@ -150,6 +155,7 @@ impl Options { (use_caller_id, OPTION_USE_CALLER_ID), (custom_name.is_some(), OPTION_CUSTOM_NAME), (conversation_filter.is_some(), OPTION_CONVERSATION_FILTER), + (group_filter.is_some(), OPTION_GROUP_FILTER), ]; for (set, opt) in diag_conflicts { if diagnostic && set { @@ -265,6 +271,7 @@ impl Options { platform, ignore_disk_space, conversation_filter: conversation_filter.cloned(), + group_filter: group_filter.cloned(), cleartext_password: cleartext_password.cloned(), contacts_path: contacts_path.cloned().map(PathBuf::from), }) @@ -447,12 +454,20 @@ fn get_command() -> Command { .display_order(13) .value_name("filter"), ) + .arg( + Arg::new(OPTION_GROUP_FILTER) + .short('g') + .long(OPTION_GROUP_FILTER) + .help("Filter exported group conversations by their names\nTo provide multiple group names, use a comma-separated string\nExample: `-g \"Family Chat,Work Group\"`\n") + .display_order(14) + .value_name("group_name"), + ) .arg( Arg::new(OPTION_CLEARTEXT_PASSWORD) .short('x') .long(OPTION_CLEARTEXT_PASSWORD) .help("Optional password for encrypted iOS backups\nThis is only used when the source is an encrypted iOS backup directory\n") - .display_order(14) + .display_order(15) .value_name("password"), ) .arg( @@ -460,7 +475,7 @@ fn get_command() -> Command { .short('n') .long(OPTION_CUSTOM_CONTACTS_DB_PATH) .help("Optional custom path for a macOS or iOS contacts database file\nThis should be resolved automatically, but can be manually provided\nHandles from the messages table will be mapped to names in the provided database\nGenerally, one of `AddressBook-v22.abcddb` or `AddressBook.sqlitedb`\n") - .display_order(15) + .display_order(16) .value_name("path"), ) } @@ -486,6 +501,7 @@ impl Options { platform: Platform::macOS, ignore_disk_space: false, conversation_filter: None, + group_filter: None, cleartext_password: None, contacts_path: None, } @@ -535,6 +551,7 @@ mod arg_tests { platform: Platform::default(), ignore_disk_space: false, conversation_filter: None, + group_filter: None, cleartext_password: None, contacts_path: None, }; @@ -618,6 +635,7 @@ mod arg_tests { platform: Platform::default(), ignore_disk_space: false, conversation_filter: None, + group_filter: None, cleartext_password: None, contacts_path: None, }; @@ -652,6 +670,7 @@ mod arg_tests { platform: Platform::default(), ignore_disk_space: false, conversation_filter: None, + group_filter: None, cleartext_password: None, contacts_path: None, }; @@ -732,6 +751,7 @@ mod arg_tests { platform: Platform::iOS, ignore_disk_space: false, conversation_filter: None, + group_filter: None, cleartext_password: None, contacts_path: None, }; @@ -771,6 +791,7 @@ mod arg_tests { platform: Platform::iOS, ignore_disk_space: false, conversation_filter: None, + group_filter: None, cleartext_password: Some("password".to_string()), contacts_path: None, }; @@ -826,6 +847,7 @@ mod arg_tests { platform: Platform::default(), ignore_disk_space: false, conversation_filter: None, + group_filter: None, cleartext_password: None, contacts_path: None, }; @@ -857,6 +879,7 @@ mod arg_tests { platform: Platform::default(), ignore_disk_space: false, conversation_filter: None, + group_filter: None, cleartext_password: None, contacts_path: None, }; @@ -889,6 +912,7 @@ mod arg_tests { platform: Platform::default(), ignore_disk_space: false, conversation_filter: Some(String::from("steve@apple.com")), + group_filter: None, cleartext_password: None, contacts_path: None, }; @@ -896,6 +920,15 @@ mod arg_tests { assert_eq!(actual, expected); } + #[test] + fn can_build_option_group_filter() { + let command = get_command(); + let args = command.get_matches_from(["imessage-exporter", "-g", "Family Chat", "-f", "txt"]); + + let actual = Options::from_args(&args).unwrap(); + assert_eq!(actual.group_filter, Some(String::from("Family Chat"))); + } + #[test] fn can_build_option_full() { // Get matches from sample args @@ -920,6 +953,7 @@ mod arg_tests { platform: Platform::default(), ignore_disk_space: false, conversation_filter: None, + group_filter: None, cleartext_password: None, contacts_path: None, }; @@ -951,6 +985,7 @@ mod arg_tests { platform: Platform::default(), ignore_disk_space: false, conversation_filter: None, + group_filter: None, cleartext_password: None, contacts_path: None, }; @@ -1024,6 +1059,7 @@ mod arg_tests { platform: Platform::default(), ignore_disk_space: true, conversation_filter: None, + group_filter: None, cleartext_password: None, contacts_path: None, }; diff --git a/imessage-exporter/src/app/runtime.rs b/imessage-exporter/src/app/runtime.rs index 2ff1491e..5ebba325 100644 --- a/imessage-exporter/src/app/runtime.rs +++ b/imessage-exporter/src/app/runtime.rs @@ -57,6 +57,8 @@ pub struct Config { pub translated_messages: HashSet, /// App configuration options pub options: Options, + /// Tracks whether the group filter matched any chats + pub group_filter_matches: bool, /// Global date offset used by the iMessage database: pub offset: i64, /// Data source for the application @@ -261,6 +263,7 @@ impl Config { tapbacks, translated_messages, options, + group_filter_matches: false, offset: get_offset(), data_source, }) @@ -308,6 +311,65 @@ impl Config { } } + /// Convert comma separated list of group name strings into chat IDs that match the display names. + pub(crate) fn resolve_filtered_groups(&mut self) { + self.group_filter_matches = false; + + if let Some(group_filter) = &self.options.group_filter { + let parsed_group_filter = group_filter + .split(',') + .map(str::trim) + .filter(|token| !token.is_empty()) + .collect::>(); + + let matched_chat_ids: BTreeSet = self + .chatrooms + .iter() + .filter_map(|(&chat_id, chat)| { + let chat_name = chat.name(); + if parsed_group_filter + .iter() + .any(|included_name| chat_name.contains(included_name)) + { + Some(chat_id) + } else { + None + } + }) + .collect(); + + if matched_chat_ids.is_empty() { + self.group_filter_matches = false; + return; + } + + match &self.options.query_context.selected_chat_ids { + Some(existing) => { + let intersection = existing + .intersection(&matched_chat_ids) + .copied() + .collect::>(); + + if intersection.is_empty() { + self.group_filter_matches = false; + return; + } + + self.options + .query_context + .set_selected_chat_ids(intersection); + } + None => { + self.options + .query_context + .set_selected_chat_ids(matched_chat_ids.clone()); + } + } + + self.group_filter_matches = true; + } + } + /// If we set some filtered chatrooms, emit how many will be included in the export fn log_filtered_handles_and_chats(&self) { if let (Some(selected_handle_ids), Some(selected_chat_ids)) = ( @@ -463,6 +525,12 @@ impl Config { ))); } + if let Some(filters) = &self.options.group_filter && !self.group_filter_matches { + return Err(RuntimeError::InvalidOptions(format!( + "Selected group filter `{filters}` does not match any chats!" + ))); + } + // Ensure the path we want to export to exists create_dir_all(&self.options.export_path)?; @@ -541,6 +609,7 @@ impl Config { tapbacks: HashMap::new(), translated_messages: HashSet::new(), options, + group_filter_matches: false, offset: get_offset(), data_source, } @@ -1222,3 +1291,143 @@ mod chat_filter_tests { ); } } + +#[cfg(test)] +mod group_filter_tests { + use std::collections::BTreeSet; + + use crate::{ + Config, Options, + app::{contacts::Name, export_type::ExportType, runtime::filename_tests::fake_chat}, + }; + + /// Verify that when the group filter matches no chats, the conversation filter is still honored. + #[test] + fn group_filter_with_no_overlap_preserves_existing_selection() { + let mut options = Options::fake_options(ExportType::Html); + options.conversation_filter = Some(String::from("Person 11")); + options.group_filter = Some(String::from("Work")); + + let mut app = Config::fake_app(options); + + app.participants.insert(10, Name::fake_name("Person 10")); + app.participants.insert(11, Name::fake_name("Person 11")); + app.real_participants.insert(10, 10); + app.real_participants.insert(11, 11); + + for (id, participant) in app.participants.iter_mut() { + participant.handle_ids.insert(*id); + } + + let mut chat1 = fake_chat(); + chat1.rowid = 1; + chat1.display_name = Some("Family Chat".to_string()); + app.chatrooms.insert(1, chat1); + + let mut chat2 = fake_chat(); + chat2.rowid = 2; + chat2.display_name = Some("Work Chat".to_string()); + app.chatrooms.insert(2, chat2); + + let mut chatroom_1 = BTreeSet::new(); + chatroom_1.insert(11); + app.chatroom_participants.insert(1, chatroom_1); + + let mut chatroom_2 = BTreeSet::new(); + chatroom_2.insert(10); + app.chatroom_participants.insert(2, chatroom_2); + + app.resolve_filtered_handles(); + app.resolve_filtered_groups(); + + assert_eq!( + app.options.query_context.selected_chat_ids, + Some(BTreeSet::from([1])) + ); + assert!(!app.group_filter_matches); + } + + #[test] + fn can_filter_group_names() { + let mut options = Options::fake_options(ExportType::Html); + options.group_filter = Some(String::from("Family")); + + let mut app = Config::fake_app(options); + + let mut chat = fake_chat(); + chat.rowid = 1; + chat.display_name = Some("Family Chat".to_string()); + app.chatrooms.insert(chat.rowid, chat); + + app.resolve_filtered_groups(); + + assert!(app.group_filter_matches); + assert_eq!( + app.options.query_context.selected_chat_ids, + Some(BTreeSet::from([1])) + ); + } + + #[test] + fn group_filter_not_matching_sets_flag_false() { + let mut options = Options::fake_options(ExportType::Html); + options.group_filter = Some(String::from("Secret")); + + let mut app = Config::fake_app(options); + + let mut chat = fake_chat(); + chat.rowid = 1; + chat.display_name = Some("Family Chat".to_string()); + app.chatrooms.insert(chat.rowid, chat); + + app.resolve_filtered_groups(); + + assert!(!app.group_filter_matches); + assert!(app.options.query_context.selected_chat_ids.is_none()); + } + + #[test] + fn group_filter_intersects_with_handle_filter() { + let mut options = Options::fake_options(ExportType::Html); + options.conversation_filter = Some(String::from("Person 11")); + options.group_filter = Some(String::from("Family")); + + let mut app = Config::fake_app(options); + + app.participants.insert(10, Name::fake_name("Person 10")); + app.participants.insert(11, Name::fake_name("Person 11")); + app.real_participants.insert(10, 10); + app.real_participants.insert(11, 11); + + for (id, participant) in app.participants.iter_mut() { + participant.handle_ids.insert(*id); + } + + let mut chat1 = fake_chat(); + chat1.rowid = 1; + chat1.display_name = Some("Family Chat".to_string()); + app.chatrooms.insert(1, chat1); + + let mut chat2 = fake_chat(); + chat2.rowid = 2; + chat2.display_name = Some("Work Chat".to_string()); + app.chatrooms.insert(2, chat2); + + let mut chatroom_1 = BTreeSet::new(); + chatroom_1.insert(11); + app.chatroom_participants.insert(1, chatroom_1); + + let mut chatroom_2 = BTreeSet::new(); + chatroom_2.insert(11); + app.chatroom_participants.insert(2, chatroom_2); + + app.resolve_filtered_handles(); + app.resolve_filtered_groups(); + + assert_eq!( + app.options.query_context.selected_chat_ids, + Some(BTreeSet::from([1])) + ); + assert!(app.group_filter_matches); + } +} diff --git a/imessage-exporter/src/exporters/html.rs b/imessage-exporter/src/exporters/html.rs index 3f254fa7..fc37d097 100644 --- a/imessage-exporter/src/exporters/html.rs +++ b/imessage-exporter/src/exporters/html.rs @@ -2583,7 +2583,12 @@ mod tests { message.rowid = 452567; let actual = exporter.format_tapback(&message).unwrap(); - let expected = "\n
App: Free People
 by Sample Contact
"; + let home = std::env::var("HOME").unwrap_or_else(|_| String::from("/Users/chris")); + let sticker_path = format!("{home}/Library/Messages/StickerCache/8e682c381ab52ec2-289D9E83-33EE-4153-AF13-43DB31792C6F/289D9E83-33EE-4153-AF13-43DB31792C6F.heic"); + let expected = format!( + "\n
App: Free People
 by Sample Contact
", + sticker_path = sticker_path + ); assert_eq!(actual, expected); } diff --git a/imessage-exporter/src/exporters/txt.rs b/imessage-exporter/src/exporters/txt.rs index 42d754c0..eb0733bf 100644 --- a/imessage-exporter/src/exporters/txt.rs +++ b/imessage-exporter/src/exporters/txt.rs @@ -1824,7 +1824,12 @@ mod tests { message.rowid = 452567; let actual = exporter.format_tapback(&message).unwrap(); - let expected = "Sticker from Sample Contact: /Users/chris/Library/Messages/StickerCache/8e682c381ab52ec2-289D9E83-33EE-4153-AF13-43DB31792C6F/289D9E83-33EE-4153-AF13-43DB31792C6F.heic (App: Free People) from Sample Contact"; + let home = std::env::var("HOME").unwrap_or_else(|_| String::from("/Users/chris")); + let sticker_path = format!("{home}/Library/Messages/StickerCache/8e682c381ab52ec2-289D9E83-33EE-4153-AF13-43DB31792C6F/289D9E83-33EE-4153-AF13-43DB31792C6F.heic"); + let expected = format!( + "Sticker from Sample Contact: {sticker_path} (App: Free People) from Sample Contact", + sticker_path = sticker_path + ); assert_eq!(actual, expected); } diff --git a/imessage-exporter/src/main.rs b/imessage-exporter/src/main.rs index 68fdeded..80752f48 100644 --- a/imessage-exporter/src/main.rs +++ b/imessage-exporter/src/main.rs @@ -25,6 +25,7 @@ fn main() { Ok(mut app) => { // Resolve the filtered contacts, if provided app.resolve_filtered_handles(); + app.resolve_filtered_groups(); if let Err(why) = app.start() { eprintln!("Unable to export: {why}");