feat: Implemented a whatsapp and minor changes

This commit is contained in:
jzitnik-dev 2024-12-08 20:04:45 +01:00
parent 7c23064afe
commit 05b31e426e
Signed by: jzitnik
GPG Key ID: C577A802A6AF4EF3
16 changed files with 840 additions and 83 deletions

153
Cargo.lock generated
View File

@ -37,6 +37,21 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "android-tzdata"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "anstream" name = "anstream"
version = "0.6.18" version = "0.6.18"
@ -139,6 +154,12 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "bumpalo"
version = "3.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
[[package]] [[package]]
name = "bytecount" name = "bytecount"
version = "0.6.8" version = "0.6.8"
@ -195,6 +216,20 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "chrono"
version = "0.4.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401"
dependencies = [
"android-tzdata",
"iana-time-zone",
"js-sys",
"num-traits",
"wasm-bindgen",
"windows-targets",
]
[[package]] [[package]]
name = "cipher" name = "cipher"
version = "0.4.4" version = "0.4.4"
@ -270,6 +305,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc"
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.16" version = "0.2.16"
@ -488,6 +529,29 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "iana-time-zone"
version = "0.1.61"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "inout" name = "inout"
version = "0.1.3" version = "0.1.3"
@ -518,6 +582,16 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "js-sys"
version = "0.3.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6717b6b5b077764fb5966237269cb3c64edddde4b14ce42647430a78ced9e7b7"
dependencies = [
"once_cell",
"wasm-bindgen",
]
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@ -546,6 +620,12 @@ dependencies = [
"scopeguard", "scopeguard",
] ]
[[package]]
name = "log"
version = "0.4.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.7.4" version = "2.7.4"
@ -577,6 +657,7 @@ name = "msgexport"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"chrono",
"clap", "clap",
"dialoguer", "dialoguer",
"encoding", "encoding",
@ -595,6 +676,15 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]] [[package]]
name = "object" name = "object"
version = "0.36.5" version = "0.36.5"
@ -1081,6 +1171,69 @@ version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "wasm-bindgen"
version = "0.2.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a474f6281d1d70c17ae7aa6a613c87fce69a127e2624002df63dcb39d6cf6396"
dependencies = [
"cfg-if",
"once_cell",
"wasm-bindgen-macro",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f89bb38646b4f81674e8f5c3fb81b562be1fd936d84320f3264486418519c79"
dependencies = [
"bumpalo",
"log",
"proc-macro2",
"quote",
"syn 2.0.90",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cc6181fd9a7492eef6fef1f33961e3695e4579b9872a6f7c83aee556666d4fe"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.90",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6"
[[package]]
name = "windows-core"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
dependencies = [
"windows-targets",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"

View File

@ -15,3 +15,4 @@ encoding = "0.2"
zip = "0.6" zip = "0.6"
tokio-util = "0.7" tokio-util = "0.7"
regex = "1.10" regex = "1.10"
chrono = "0.4.38"

View File

@ -2,7 +2,7 @@ use std::{collections::HashMap, process::exit};
use dialoguer::Select; use dialoguer::Select;
use crate::{services::{instagram::Instagram, service::Service}, types::{participant::Participant, project::Project, user::User}}; use crate::{services::{instagram::instagram::Instagram, service::Service, whatsapp::whatsapp::WhatsApp}, types::{participant::Participant, project::Project, user::User}};
pub async fn add(path: String) { pub async fn add(path: String) {
let mut raw_project = Project::load(path.clone()).await.unwrap_or_else(|e| { let mut raw_project = Project::load(path.clone()).await.unwrap_or_else(|e| {
@ -19,6 +19,9 @@ pub async fn add(path: String) {
Box::new(Instagram { Box::new(Instagram {
data: None data: None
}), }),
Box::new(WhatsApp {
data: None
}),
]; ];
let options: Vec<&'static str> = services.iter().map(|service| service.get_name()).collect(); let options: Vec<&'static str> = services.iter().map(|service| service.get_name()).collect();
@ -32,30 +35,41 @@ pub async fn add(path: String) {
let service = services.get(selected).unwrap(); let service = services.get(selected).unwrap();
let mut final_service = service.setup(); let mut final_service = service.setup();
let _ = final_service.load().await;
println!("Loading the project file");
final_service.load().await.unwrap_or_else(|e| {
println!("Error while loading {} export: {}", final_service.get_name(), e);
exit(1);
});
let participants = final_service.get_participants(); let participants = final_service.get_participants();
if participants.len() > raw_project.project.users.len() {
eprintln!("You have {} users in your project but in the {} chat there are {} participants!", raw_project.project.users.len(), final_service.get_name(), participants.len());
println!("You need to have equal amount or more users in project that participants in chat!");
exit(1);
}
let mut user_map: HashMap<Participant, User> = HashMap::new(); let mut user_map: HashMap<Participant, User> = HashMap::new();
println!("Now select what project user is coresponding to a {} participant", service.get_name()); println!("Now select what project user is coresponding to a {} participant", service.get_name());
for user in raw_project.project.users.clone() { for participant in participants {
let parts: Vec<Participant> = participants.iter().filter(|participant| { let users: Vec<User> = raw_project.project.users.iter().filter(|user| {
!user_map.contains_key(&participant) !user_map.values().any(|x| x.name == user.name)
}).map(|e| e.clone()).collect(); }).map(|e| e.clone()).collect();
let options: Vec<String> = parts.iter().map(|part| part.name.clone()).collect(); let options: Vec<String> = users.iter().map(|usr| usr.name.clone()).collect();
let selected = Select::new() let selected = Select::new()
.with_prompt(format!("Select a {} participant for user '{}'", service.get_name(), user.name)) .with_prompt(format!("Select a {} user for participant '{}'", service.get_name(), participant.name))
.items(&options) .items(&options)
.default(0) .default(0)
.interact() .interact()
.unwrap(); .unwrap();
let selected_part = parts.get(selected).unwrap().clone(); let selected_user = users.get(selected).unwrap().clone();
user_map.insert(selected_part.clone(), user); user_map.insert(participant, selected_user);
} }
println!("Starting to merge messages..."); println!("Starting to merge messages...");

39
src/commands/export.rs Normal file
View File

@ -0,0 +1,39 @@
use std::process::exit;
use dialoguer::Select;
use tokio::{fs::File, io::AsyncWriteExt};
use crate::types::project::Project;
pub async fn export(path: String) {
let raw_project = Project::load(path.clone()).await.unwrap_or_else(|e| {
eprintln!("Error while loading a project: {}", e);
exit(1);
});
let options = vec!["HTML", "JSON (without media)"];
let selected = Select::new()
.with_prompt("Choose a output type")
.items(&options)
.default(0)
.interact()
.unwrap();
match selected {
0 => {
// HTML
}
1 => {
// JSON (without media)
let content = raw_project.project.to_projectjson();
let json_content = serde_json::to_string(&content).unwrap();
let mut file = File::create("./export.json").await.expect("Error while creating a file.");
let _ = file.write_all(json_content.as_bytes()).await;
}
_ => {
panic!("Invalid option");
}
};
}

View File

@ -1,2 +1,3 @@
pub mod users; pub mod users;
pub mod add; pub mod add;
pub mod export;

View File

@ -1,4 +1,4 @@
use std::process::exit; use std::{collections::HashSet, process::exit};
use dialoguer::{Input, Select}; use dialoguer::{Input, Select};
use tabled::Table; use tabled::Table;
@ -72,6 +72,21 @@ pub async fn menu(mut project_raw: ProjectRaw, path: String) {
.default(0) .default(0)
.interact() .interact()
.unwrap(); .unwrap();
let delete_name = options_users.get(option_remove).unwrap();
let mut participants_in_chat: HashSet<String> = HashSet::new();
for message in project.messages.clone() {
participants_in_chat.insert(message.sender_name);
for reaction in message.reactions {
participants_in_chat.insert(reaction.actor_name);
}
}
if participants_in_chat.contains(delete_name) {
eprintln!("This user has some messages in chat, therefore cannot be removed!")
}
project.users.remove(option_remove); project.users.remove(option_remove);

View File

@ -5,7 +5,7 @@ mod services;
use std::collections::HashSet; use std::collections::HashSet;
use clap::{command, ArgGroup, Parser}; use clap::{command, ArgGroup, Parser};
use commands::{add::add, users::users}; use commands::{add::add, export::export, users::users};
use types::project::Project; use types::project::Project;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
@ -15,7 +15,7 @@ use types::project::Project;
about = "Export and merge your messages between platforms", about = "Export and merge your messages between platforms",
author = "Jakub Žitník", author = "Jakub Žitník",
group = ArgGroup::new("command") group = ArgGroup::new("command")
.args(&["new", "users", "add"]) .args(&["new", "users", "add", "export"])
.multiple(false) .multiple(false)
.required(true) .required(true)
)] )]
@ -28,6 +28,9 @@ struct Cli {
#[arg(long = "add", help = "Add messages from a service.", value_name = "PROJECT_FILE")] #[arg(long = "add", help = "Add messages from a service.", value_name = "PROJECT_FILE")]
add: Option<String>, add: Option<String>,
// #[arg(long = "export", help = "Export all the chats in specific format", value_name = "PROJECT_FILE")]
// export: Option<String>,
} }
#[tokio::main] #[tokio::main]
@ -53,4 +56,8 @@ async fn main() {
if let Some(path) = cli.add { if let Some(path) = cli.add {
add(path).await; add(path).await;
} }
// if let Some(path) = cli.export {
// export(path).await;
// }
} }

View File

@ -7,81 +7,15 @@ use encoding::{all::ISO_8859_1, EncoderTrap, Encoding};
use serde::Deserialize; use serde::Deserialize;
use tokio::{fs::File, io::AsyncReadExt}; use tokio::{fs::File, io::AsyncReadExt};
use crate::types::{message::{Message, Photo}, participant::Participant, project::ProjectRaw, user::User}; use crate::{services::service::Service, types::{participant::Participant, project::ProjectRaw, user::User}};
use super::service::Service; use super::types::{InstagramMessage, InstagramParticipant};
fn decode_str(input: &String) -> String { pub fn decode_str(input: &String) -> String {
let vec = ISO_8859_1.encode(input.as_str(), EncoderTrap::Strict).unwrap(); let vec = ISO_8859_1.encode(input.as_str(), EncoderTrap::Strict).unwrap();
String::from_utf8(vec).unwrap() String::from_utf8(vec).unwrap()
} }
#[derive(Deserialize, Debug)]
pub struct InstagramMessage {
sender_name: String,
timestamp_ms: usize,
content: Option<String>,
photos: Option<Vec<InstagramPhoto>>
}
impl InstagramMessage {
pub fn conv(&self, media_index: &mut usize, project_path: PathBuf, user_map: &HashMap<Participant, User>) -> Message {
let text_content = if let Some(content) = &self.content {
Some(decode_str(content))
} else {
None
};
let photos: Vec<Photo> = if let Some(photos_list) = &self.photos {
photos_list.iter().map(|photo| photo.conv(&text_content, media_index, project_path.clone())).collect()
} else {
vec![]
};
let participant = Participant {
name: decode_str(&self.sender_name)
};
Message {
sender_name: user_map.get(&participant).unwrap().name.clone(),
text_content,
timestamp: self.timestamp_ms,
photos
}
}
}
#[derive(Deserialize, Debug)]
struct InstagramPhoto {
uri: String
}
impl InstagramPhoto {
pub fn conv(&self, text_content: &Option<String>, media_index: &mut usize, mut original_path: PathBuf) -> Photo {
*media_index += 1;
let img_path = (*media_index - 1).to_string();
for _ in 0..4 {
if !original_path.pop() {
break;
}
}
original_path.push(self.uri.clone());
Photo {
text_content: text_content.clone(),
img_path,
original_img_path: Some(original_path.display().to_string())
}
}
}
#[derive(Deserialize, Debug)]
pub struct InstagramParticipant {
pub name: String,
}
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
struct InstagramDataJson { struct InstagramDataJson {
messages: Vec<InstagramMessage>, messages: Vec<InstagramMessage>,
@ -212,7 +146,7 @@ impl Service for Instagram {
let moved = raw_project.project.push_msg(&msg); let moved = raw_project.project.push_msg(&msg);
if moved { if moved {
// Move the file to the media folder // Move media files to the media folder
for photo in msg.photos.clone() { for photo in msg.photos.clone() {
let original_path = photo.original_img_path.unwrap().clone(); let original_path = photo.original_img_path.unwrap().clone();
let path = format!("media/{}", photo.img_path); let path = format!("media/{}", photo.img_path);
@ -220,6 +154,20 @@ impl Service for Instagram {
media_files.push(photovalues); media_files.push(photovalues);
} }
for video in msg.videos.clone() {
let original_path = video.original_vid_path.unwrap().clone();
let path = format!("media/{}", video.vid_path);
let photovalues = (path, original_path);
media_files.push(photovalues);
}
for audio in msg.audio.clone() {
let original_path = audio.original_audio_path.unwrap().clone();
let path = format!("media/{}", audio.audio_path);
let photovalues = (path, original_path);
media_files.push(photovalues);
}
// Update the highest media index of the project if the message was merged // Update the highest media index of the project if the message was merged
raw_project.project.media_index = media_index_clone; raw_project.project.media_index = media_index_clone;
} }

View File

@ -0,0 +1,2 @@
pub mod types;
pub mod instagram;

View File

@ -0,0 +1,168 @@
use std::{collections::HashMap, path::PathBuf};
use serde::Deserialize;
use crate::types::{message::{Audio, Message, Photo, Reaction, Video}, participant::Participant, user::User};
use super::instagram::decode_str;
#[derive(Deserialize, Debug)]
pub struct InstagramParticipant {
pub name: String,
}
#[derive(Deserialize, Debug)]
pub struct InstagramMessage {
sender_name: String,
timestamp_ms: usize,
content: Option<String>,
photos: Option<Vec<InstagramPhoto>>,
videos: Option<Vec<InstagramVideo>>,
audio_files: Option<Vec<InstagramAudio>>,
reactions: Option<Vec<InstagramReaction>>
}
impl InstagramMessage {
pub fn conv(&self, media_index: &mut usize, project_path: PathBuf, user_map: &HashMap<Participant, User>) -> Message {
let text_content = if let Some(content) = &self.content {
Some(decode_str(content))
} else {
None
};
let photos: Vec<Photo> = if let Some(photos_list) = &self.photos {
photos_list.iter().map(|photo| photo.conv(&text_content, media_index, project_path.clone())).collect()
} else {
vec![]
};
let videos: Vec<Video> = if let Some(videos_list) = &self.videos {
videos_list.iter().map(|video| video.conv(&text_content, media_index, project_path.clone())).collect()
} else {
vec![]
};
let audio: Vec<Audio> = if let Some(audio_list) = &self.audio_files {
audio_list.iter().map(|audio| audio.conv(&text_content, media_index, project_path.clone())).collect()
} else {
vec![]
};
let reactions: Vec<Reaction> = if let Some(reaction_list) = &self.reactions {
reaction_list.iter().map(|reaction| reaction.conv()).collect()
} else {
vec![]
};
let participant = Participant {
name: decode_str(&self.sender_name)
};
let sender = user_map.get(&participant);
let sender_name = if let Some(sender_value) = sender {
sender_value.name.clone()
} else {
String::from("Unknown user")
};
Message {
sender_name,
text_content,
timestamp: self.timestamp_ms,
service: crate::types::message::Service::Instagram,
photos,
videos,
audio,
reactions
}
}
}
#[derive(Deserialize, Debug)]
pub struct InstagramReaction {
pub reaction: String,
pub actor: String,
pub timestamp: Option<usize>
}
impl InstagramReaction {
pub fn conv(&self) -> Reaction {
Reaction {
actor_name: decode_str(&self.actor),
content: decode_str(&self.reaction),
timestamp: self.timestamp.unwrap_or(0)
}
}
}
// Media files
#[derive(Deserialize, Debug)]
struct InstagramPhoto {
uri: String
}
#[derive(Deserialize, Debug)]
struct InstagramVideo {
uri: String
}
#[derive(Deserialize, Debug)]
struct InstagramAudio {
uri: String
}
impl InstagramPhoto {
pub fn conv(&self, text_content: &Option<String>, media_index: &mut usize, mut original_path: PathBuf) -> Photo {
*media_index += 1;
let img_path = (*media_index - 1).to_string();
for _ in 0..4 {
if !original_path.pop() {
break;
}
}
original_path.push(self.uri.clone());
Photo {
text_content: text_content.clone(),
img_path,
original_img_path: Some(original_path.display().to_string())
}
}
}
impl InstagramVideo {
pub fn conv(&self, text_content: &Option<String>, media_index: &mut usize, mut original_path: PathBuf) -> Video {
*media_index += 1;
let vid_path = (*media_index - 1).to_string();
for _ in 0..4 {
if !original_path.pop() {
break;
}
}
original_path.push(self.uri.clone());
Video {
text_content: text_content.clone(),
vid_path,
original_vid_path: Some(original_path.display().to_string())
}
}
}
impl InstagramAudio {
pub fn conv(&self, text_content: &Option<String>, media_index: &mut usize, mut original_path: PathBuf) -> Audio {
*media_index += 1;
let audio_path = (*media_index - 1).to_string();
for _ in 0..4 {
if !original_path.pop() {
break;
}
}
original_path.push(self.uri.clone());
Audio {
text_content: text_content.clone(),
audio_path,
original_audio_path: Some(original_path.display().to_string())
}
}
}

View File

@ -1,2 +1,4 @@
pub mod service; pub mod service;
pub mod instagram; pub mod instagram;
pub mod whatsapp;

View File

@ -0,0 +1,2 @@
pub mod whatsapp;
pub mod types;

View File

@ -0,0 +1,125 @@
use std::{collections::HashMap, path::PathBuf};
use serde::Deserialize;
use crate::types::{message::{Audio, Message, Photo, Video}, participant::Participant, user::User};
#[derive(Deserialize, Debug)]
pub struct WhatsAppMessage {
pub sender: String,
pub epoch: usize,
pub content: Option<String>,
pub photos: Vec<WhatsAppPhoto>,
pub videos: Vec<WhatsAppVideo>,
pub audio: Option<WhatsAppAudio>,
}
impl WhatsAppMessage {
pub fn conv(&self, media_index: &mut usize, project_path: PathBuf, user_map: &HashMap<Participant, User>) -> Message {
let photos: Vec<Photo> =
self.photos.iter().map(|photo| photo.conv(media_index, project_path.clone())).collect();
let videos: Vec<Video> =
self.videos.iter().map(|video| video.conv(media_index, project_path.clone())).collect();
let audio: Vec<Audio> = if let Some(audio_f) = &self.audio {
vec![audio_f.conv(media_index, project_path.clone())]
} else {
vec![]
};
let participant = Participant {
name: self.sender.clone()
};
let sender = user_map.get(&participant);
let sender_name = if let Some(sender_value) = sender {
sender_value.name.clone()
} else {
String::from("Unknown user")
};
Message {
sender_name,
text_content: self.content.clone(),
timestamp: self.epoch,
service: crate::types::message::Service::WhatsApp,
photos,
videos,
audio,
reactions: vec![]
}
}
}
// Media files
#[derive(Deserialize, Debug)]
pub struct WhatsAppPhoto {
pub uri: String,
pub text_content: String,
}
#[derive(Deserialize, Debug)]
pub struct WhatsAppVideo {
pub uri: String,
pub text_content: String,
}
#[derive(Deserialize, Debug)]
pub struct WhatsAppAudio {
pub uri: String,
pub text_content: String,
}
impl WhatsAppPhoto {
pub fn conv(&self, media_index: &mut usize, mut original_path: PathBuf) -> Photo {
*media_index += 1;
let img_path = (*media_index - 1).to_string();
original_path.push(self.uri.clone());
Photo {
text_content: Some(self.text_content.clone()),
img_path,
original_img_path: Some(original_path.display().to_string())
}
}
}
impl WhatsAppVideo {
pub fn conv(&self, media_index: &mut usize, mut original_path: PathBuf) -> Video {
*media_index += 1;
let vid_path = (*media_index - 1).to_string();
original_path.push(self.uri.clone());
Video {
text_content: Some(self.text_content.clone()),
vid_path,
original_vid_path: Some(original_path.display().to_string())
}
}
}
impl WhatsAppAudio {
pub fn conv(&self, media_index: &mut usize, mut original_path: PathBuf) -> Audio {
*media_index += 1;
let audio_path = (*media_index - 1).to_string();
original_path.push(self.uri.clone());
Audio {
text_content: Some(self.text_content.clone()),
audio_path,
original_audio_path: Some(original_path.display().to_string())
}
}
}
pub struct WhatsAppParticipant {
pub name: String
}
impl WhatsAppParticipant {
pub fn conv(&self) -> Participant {
Participant {
name: self.name.clone()
}
}
}

View File

@ -0,0 +1,232 @@
use std::{collections::{HashMap, HashSet}, fs, io, path::PathBuf, process::exit};
use chrono::NaiveDateTime;
use dialoguer::Input;
use async_trait::async_trait;
use regex::Regex;
use tokio::{fs::File, io::AsyncReadExt};
use crate::{services::service::Service, types::{participant::Participant, project::ProjectRaw, user::User}};
use super::types::{WhatsAppAudio, WhatsAppMessage, WhatsAppParticipant, WhatsAppPhoto, WhatsAppVideo};
pub fn parse_messages(input: &str) -> Vec<WhatsAppMessage> {
let mut messages = Vec::new();
let re = Regex::new(r"^(\d{2}/\d{2}/\d{2}), (\d{2}:\d{2}) - (.*?): (.*)").unwrap();
let mut current_message: Option<WhatsAppMessage> = None;
for line in input.lines() {
if let Some(captures) = re.captures(line) {
let date = captures.get(1).unwrap().as_str();
let time = captures.get(2).unwrap().as_str();
let sender = captures.get(3).unwrap().as_str().to_string();
let content = captures.get(4).unwrap().as_str();
// Parse date as DD/MM/YY and convert to epoch
let datetime_str = format!("{} {}", date, time); // DD/MM/YY HH:MM
let datetime = NaiveDateTime::parse_from_str(&datetime_str, "%d/%m/%y %H:%M").unwrap();
let epoch = datetime.and_utc().timestamp() as usize;
// Save the previous message if there is one
if let Some(msg) = current_message.take() {
messages.push(msg);
}
// Handle media messages
if content.starts_with("IMG") || content.starts_with("STK") {
current_message = Some(WhatsAppMessage {
sender,
epoch,
content: None,
photos: vec![WhatsAppPhoto {
uri: content.split(' ').next().unwrap().to_string(),
text_content: String::new(),
}],
videos: vec![],
audio: None,
});
} else if content.starts_with("VID") {
current_message = Some(WhatsAppMessage {
sender,
epoch,
content: None,
photos: vec![],
videos: vec![WhatsAppVideo {
uri: content.split(' ').next().unwrap().to_string(),
text_content: String::new(),
}],
audio: None,
});
} else if content.starts_with("PTT") {
messages.push(WhatsAppMessage {
sender,
epoch,
content: None,
photos: vec![],
videos: vec![],
audio: Some(WhatsAppAudio {
uri: content.split(' ').next().unwrap().to_string(),
text_content: String::new(),
}),
});
} else {
// Regular message
current_message = Some(WhatsAppMessage {
sender,
epoch,
content: Some(content.to_string()),
photos: vec![],
videos: vec![],
audio: None,
});
}
} else if let Some(msg) = current_message.as_mut() {
// Handle continuations or appending to media
if let Some(photo) = msg.photos.last_mut() {
photo.text_content.push('\n');
photo.text_content.push_str(line);
} else if let Some(video) = msg.videos.last_mut() {
video.text_content.push('\n');
video.text_content.push_str(line);
} else if let Some(content) = msg.content.as_mut() {
content.push('\n');
content.push_str(line);
}
}
}
// Add the last message if any
if let Some(msg) = current_message {
messages.push(msg);
}
messages
}
pub struct WhatsAppData {
pub project_path: String,
pub participants: Vec<WhatsAppParticipant>,
pub messages: Vec<WhatsAppMessage>,
}
pub struct WhatsApp {
pub data: Option<WhatsAppData>,
}
#[async_trait]
impl Service for WhatsApp {
fn get_name(&self) -> &'static str {
"WhatsApp"
}
fn setup(&self) -> Box<dyn Service> {
let export_path: String = Input::new()
.with_prompt("Enter path for your WhatsApp export. (extracted zip file)")
.interact()
.unwrap();
Box::new(WhatsApp {
data: Some(WhatsAppData {
project_path: export_path,
participants: vec![],
messages: vec![],
}),
})
}
async fn load(&mut self) -> io::Result<()> {
let data = self.data.as_mut().unwrap();
let project_path = &data.project_path;
let paths: Vec<PathBuf> = fs::read_dir(project_path).unwrap_or_else(|_| {
eprintln!("Folder not found!");
exit(1);
})
.filter_map(|entry| entry.ok())
.filter(|entry| {
if let Some(extension) = entry.path().extension() {
entry.path().is_file() && extension == "txt"
} else {
false
}
})
.map(|entry| entry.path()).collect();
if paths.len() != 1 {
eprintln!("The chat file was not found in the WhatsApp export");
exit(1);
}
let file_path = paths.get(0).unwrap();
let mut file = File::open(file_path).await?;
let mut contents = String::new();
file.read_to_string(&mut contents).await?;
let messages = parse_messages(&contents);
let mut participants: HashSet<String> = HashSet::new();
for message in &messages {
participants.insert(message.sender.clone());
}
data.participants = participants.into_iter().map(|p| {
WhatsAppParticipant {
name: p
}
}).collect();
data.messages = messages;
Ok(())
}
fn get_participants(&self) -> Vec<Participant> {
self.data.as_ref().unwrap().participants.iter().map(|part| part.conv()).collect()
}
async fn merge_messages(&self, raw_project: &mut ProjectRaw, user_map: &HashMap<Participant, User>) {
let data = self.data.as_ref().unwrap();
let path = PathBuf::from(data.project_path.clone());
let mut media_files: Vec<(String, String)> = vec![];
for message in &data.messages {
let mut media_index_clone = raw_project.project.media_index.clone();
let msg = message.conv(&mut media_index_clone, path.clone(), user_map);
let moved = raw_project.project.push_msg(&msg);
if moved {
// Move media files to the media folder
for photo in msg.photos.clone() {
let original_path = photo.original_img_path.unwrap().clone();
let path = format!("media/{}", photo.img_path);
let photovalues = (path, original_path);
media_files.push(photovalues);
}
for video in msg.videos.clone() {
let original_path = video.original_vid_path.unwrap().clone();
let path = format!("media/{}", video.vid_path);
let photovalues = (path, original_path);
media_files.push(photovalues);
}
for audio in msg.audio.clone() {
let original_path = audio.original_audio_path.unwrap().clone();
let path = format!("media/{}", audio.audio_path);
let photovalues = (path, original_path);
media_files.push(photovalues);
}
// Update the highest media index of the project if the message was merged
raw_project.project.media_index = media_index_clone;
}
}
let _ = raw_project.save_media_files(media_files).await;
}
}

View File

@ -5,7 +5,24 @@ pub struct Message {
pub sender_name: String, pub sender_name: String,
pub timestamp: usize, pub timestamp: usize,
pub text_content: Option<String>, pub text_content: Option<String>,
pub photos: Vec<Photo> pub photos: Vec<Photo>,
pub videos: Vec<Video>,
pub audio: Vec<Audio>,
pub service: Service,
pub reactions: Vec<Reaction>
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Reaction {
pub actor_name: String,
pub content: String,
pub timestamp: usize
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub enum Service {
Instagram,
WhatsApp,
} }
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
@ -16,3 +33,21 @@ pub struct Photo {
#[serde(skip)] #[serde(skip)]
pub original_img_path: Option<String>, pub original_img_path: Option<String>,
} }
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Video {
pub text_content: Option<String>,
pub vid_path: String,
#[serde(skip)]
pub original_vid_path: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Audio {
pub text_content: Option<String>,
pub audio_path: String,
#[serde(skip)]
pub original_audio_path: Option<String>,
}

View File

@ -14,12 +14,25 @@ pub struct Project {
pub media_index: usize, pub media_index: usize,
} }
#[derive(Serialize)]
pub struct ProjectJson {
pub users: Vec<User>,
pub messages: Vec<Message>
}
pub struct ProjectRaw { pub struct ProjectRaw {
pub project: Project, pub project: Project,
pub zip: ZipArchive<Cursor<Vec<u8>>> pub zip: ZipArchive<Cursor<Vec<u8>>>
} }
impl Project { impl Project {
pub fn to_projectjson(&self) -> ProjectJson {
ProjectJson {
users: self.users.clone(),
messages: self.messages.clone()
}
}
pub async fn load(pathstr: String) -> io::Result<ProjectRaw> { pub async fn load(pathstr: String) -> io::Result<ProjectRaw> {
let path = PathBuf::from(pathstr); let path = PathBuf::from(pathstr);