feat: Implemented export and added some docs

This commit is contained in:
jzitnik-dev 2024-12-13 15:01:57 +01:00
parent 05b31e426e
commit 1bc631b0a0
Signed by: jzitnik
GPG Key ID: C577A802A6AF4EF3
9 changed files with 377 additions and 11 deletions

55
README.md Normal file
View File

@ -0,0 +1,55 @@
# msgexport
Simple tool for exporting and merging all your chats between your platforms.
**This tool is not a chat app that has all your chats between the platforms. This tool only merges exported chat from different platforms to a unified structure**
## Supported platforms
- Instagram
- WhatsApp
(more platforms will be implemented later)
## How to use msgexport
### Downloading msgexport
Download latest build from releases or build it yourself.
### Creating a project
First you need to create a `msgexport` project. To do that you need to run:
```bash
msgexport --new project.msge
```
.msge file is just a simple zip file that contains all of the messages and media.
### Adding users to a project
First you need to add users to the project. The users in a project are all of the participants that are in the chat. You the user count needs to have at least the amount of participants. You cannot have a participant in a chat that doesn't have a user.
<details>
<summary>Why is it this way</summary>
Let say that on WhatsApp the person is called by his first name, lets say `Joe`. The same person can have his nickname as his name on Instagram. We need to know which user is who. This is why we create users in out project. We name them however we want and then when we start merging the messages we say that for example `LegendMaster123` is Joe. Then it will automatically assign all the messages from `LegendMaster123` to Joe.
</details>
```bash
msgexport --users project.msge
```
### Merging messages
To merge a messages from a specific platform you need to run this:
```bash
msgexport --add project.msge
```
Then continue to a documentation for the specific platform.
- [Instagram](docs/services/instagram.md)
- [WhatsApp](docs/services/whatsapp.md)

19
docs/services/whatsapp.md Normal file
View File

@ -0,0 +1,19 @@
# WhatsApp
## Export a chat from WhatsApp
Go to your specified chat on your phone, click on the three dots at the right top and click **More** and **Export chat**.
Then select **Include media** and move the exported zip to your computer and extract it. Then locate the folder and copy the relative path to it. (in the folder will be some media and one txt file)
## Start merging
Run
```bash
msgexport --add project.msge
```
and then paste the relative path to the console.
Then select what participant in the WhatsApp chat coresponds to a msgexport user.

View File

@ -1,12 +1,16 @@
use std::io;
use std::process::exit; use std::process::exit;
use dialoguer::Select; use dialoguer::Select;
use tokio::fs;
use tokio::{fs::File, io::AsyncWriteExt}; use tokio::{fs::File, io::AsyncWriteExt};
use crate::types::project::Project; use crate::types::project::Project;
use crate::utils::html_builder::build_html;
use crate::utils::zip::extract_specific_folder;
pub async fn export(path: String) { pub async fn export(path: String) -> io::Result<()> {
let raw_project = Project::load(path.clone()).await.unwrap_or_else(|e| { let mut raw_project = Project::load(path.clone()).await.unwrap_or_else(|e| {
eprintln!("Error while loading a project: {}", e); eprintln!("Error while loading a project: {}", e);
exit(1); exit(1);
}); });
@ -23,12 +27,17 @@ pub async fn export(path: String) {
match selected { match selected {
0 => { 0 => {
// HTML // HTML
let html_content = build_html(&raw_project.project);
let mut file = File::create("./export/index.html").await.expect("Error while creating a file.");
let _ = file.write_all(html_content.as_bytes()).await;
extract_specific_folder(&mut raw_project.zip, "media/", "export/media")?;
} }
1 => { 1 => {
// JSON (without media) // JSON (without media)
let content = raw_project.project.to_projectjson(); let content = raw_project.project.to_projectjson();
let json_content = serde_json::to_string(&content).unwrap(); let json_content = serde_json::to_string(&content).unwrap();
fs::create_dir("export").await?;
let mut file = File::create("./export.json").await.expect("Error while creating a file."); let mut file = File::create("./export.json").await.expect("Error while creating a file.");
let _ = file.write_all(json_content.as_bytes()).await; let _ = file.write_all(json_content.as_bytes()).await;
} }
@ -36,4 +45,6 @@ pub async fn export(path: String) {
panic!("Invalid option"); panic!("Invalid option");
} }
}; };
Ok(())
} }

View File

@ -1,6 +1,7 @@
mod types; mod types;
mod commands; mod commands;
mod services; mod services;
mod utils;
use std::collections::HashSet; use std::collections::HashSet;
@ -29,8 +30,8 @@ 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")] #[arg(long = "export", help = "Export all the chats in specific format", value_name = "PROJECT_FILE")]
// export: Option<String>, export: Option<String>,
} }
#[tokio::main] #[tokio::main]
@ -57,7 +58,9 @@ async fn main() {
add(path).await; add(path).await;
} }
// if let Some(path) = cli.export { if let Some(path) = cli.export {
// export(path).await; export(path).await.unwrap_or_else(|e| {
// } println!("Error: {}", e);
});
}
} }

View File

@ -46,7 +46,7 @@ impl InstagramMessage {
}; };
let reactions: Vec<Reaction> = if let Some(reaction_list) = &self.reactions { let reactions: Vec<Reaction> = if let Some(reaction_list) = &self.reactions {
reaction_list.iter().map(|reaction| reaction.conv()).collect() reaction_list.iter().map(|reaction| reaction.conv(user_map)).collect()
} else { } else {
vec![] vec![]
}; };
@ -83,9 +83,20 @@ pub struct InstagramReaction {
} }
impl InstagramReaction { impl InstagramReaction {
pub fn conv(&self) -> Reaction { pub fn conv(&self, user_map: &HashMap<Participant, User>) -> Reaction {
let participant = Participant {
name: decode_str(&self.actor)
};
let sender = user_map.get(&participant);
let sender_name = if let Some(sender_value) = sender {
sender_value.name.clone()
} else {
String::from("Unknown user")
};
Reaction { Reaction {
actor_name: decode_str(&self.actor), actor_name: sender_name,
content: decode_str(&self.reaction), content: decode_str(&self.reaction),
timestamp: self.timestamp.unwrap_or(0) timestamp: self.timestamp.unwrap_or(0)
} }

View File

@ -25,7 +25,7 @@ pub fn parse_messages(input: &str) -> Vec<WhatsAppMessage> {
// Parse date as DD/MM/YY and convert to epoch // Parse date as DD/MM/YY and convert to epoch
let datetime_str = format!("{} {}", date, time); // DD/MM/YY HH:MM 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 datetime = NaiveDateTime::parse_from_str(&datetime_str, "%d/%m/%y %H:%M").unwrap();
let epoch = datetime.and_utc().timestamp() as usize; let epoch = (datetime.and_utc().timestamp() * 1000) as usize;
// Save the previous message if there is one // Save the previous message if there is one
if let Some(msg) = current_message.take() { if let Some(msg) = current_message.take() {

226
src/utils/html_builder.rs Normal file
View File

@ -0,0 +1,226 @@
use crate::types::project::Project;
pub fn build_html(project: &Project) -> String {
let users: Vec<String> = project.users.iter().map(|u| u.name.clone()).collect();
let string = format!(
"
<!doctype html>
<html lang=\"en\">
<head>
<meta charset=\"UTF-8\" />
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />
<meta name=\"generator\" content=\"msgexport {}\" />
<title>Chat with {}</title>
<style>
body {{
font-family: Arial, sans-serif;
background-color: #f9f9f9;
margin: 0;
padding: 0;
}}
.chat-container {{
max-width: 800px;
margin: 20px auto;
padding: 10px;
background-color: #ffffff;
border-radius: 10px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
}}
.day-marker {{
text-align: center;
margin: 20px 0;
font-size: 14px;
color: #888;
}}
.message {{
display: flex;
flex-direction: column;
align-items: flex-start;
margin-bottom: 20px;
}}
.message .sender {{
font-weight: bold;
margin-bottom: 5px;
color: #555;
}}
.message .content {{
background-color: #e4f2ff;
color: #333;
padding: 10px 15px;
border-radius: 10px;
max-width: 70%;
word-wrap: break-word;
position: relative;
}}
.message .time {{
font-size: 12px;
color: #777;
position: absolute;
bottom: -18px;
right: 10px;
white-space: nowrap;
}}
.message img,
.message video,
.message audio {{
display: block;
margin-top: 5px;
max-width: 100%;
border-radius: 5px;
}}
.reactions {{
margin-top: 5px;
font-size: 12px;
color: #555;
display: flex;
flex-wrap: wrap;
gap: 5px;
}}
.reaction {{
background-color: #fff;
color: #333;
border: 1px solid #ddd;
border-radius: 15px;
padding: 5px 10px;
display: inline-flex;
align-items: center;
gap: 5px;
}}
</style>
</head>
<body>
<h1 style=\"text-align: center;\">Chat with {}</h1>
<div class=\"chat-container\" id=\"chat-container\"></div>
<script type=\"application/json\" id=\"chat-data\">
{}
</script>
<script>
// Fetch the JSON data from the <script> tag
const data = JSON.parse(document.getElementById(\"chat-data\").textContent);
// Helper function to format a timestamp to HH:mm
function formatTime(timestamp) {{
const date = new Date(timestamp);
return date.toLocaleTimeString([], {{
hour: \"2-digit\",
minute: \"2-digit\",
}});
}}
function formatDate(timestamp) {{
const date = new Date(timestamp);
return date.toLocaleDateString([], {{
weekday: \"long\",
year: \"numeric\",
month: \"long\",
day: \"numeric\",
}});
}}
function createMessageBubble(message, showSenderName) {{
const div = document.createElement(\"div\");
div.className = \"message\";
if (showSenderName) {{
const senderDiv = document.createElement(\"div\");
senderDiv.className = \"sender\";
senderDiv.textContent = message.sender_name;
div.appendChild(senderDiv);
}}
const contentDiv = document.createElement(\"div\");
contentDiv.className = \"content\";
contentDiv.setAttribute(\"tooltip\", message.service)
if (message.text_content) {{
const text = document.createElement(\"p\");
text.textContent = message.text_content;
contentDiv.appendChild(text);
}}
if (message.photos.length > 0) {{
message.photos.forEach((photo) => {{
const img = document.createElement(\"img\");
img.src = \"media/\" + photo.img_path;
contentDiv.appendChild(img);
}});
}}
if (message.videos.length > 0) {{
message.videos.forEach((video) => {{
const vid = document.createElement(\"video\");
vid.src = \"media/\" + video.vid_path;
vid.controls = true;
contentDiv.appendChild(vid);
}});
}}
if (message.audio.length > 0) {{
message.audio.forEach((audio) => {{
const aud = document.createElement(\"audio\");
aud.src = \"media/\" + audio.audio_path;
aud.controls = true;
contentDiv.appendChild(aud);
}});
}}
const timeDiv = document.createElement(\"div\");
timeDiv.className = \"time\";
timeDiv.textContent = formatTime(message.timestamp);
contentDiv.appendChild(timeDiv);
if (message.reactions.length > 0) {{
const reactionsDiv = document.createElement(\"div\");
reactionsDiv.className = \"reactions\";
message.reactions.forEach((reaction) => {{
const reactionDiv = document.createElement(\"div\");
reactionDiv.className = \"reaction\";
reactionDiv.textContent = `${{reaction.actor_name}}: ${{reaction.content}}`;
reactionsDiv.appendChild(reactionDiv);
}});
contentDiv.appendChild(reactionsDiv);
}}
div.appendChild(contentDiv);
return div;
}}
function renderChat(data) {{
const chatContainer = document.getElementById(\"chat-container\");
let lastSender = null;
let lastDate = null;
data.messages.forEach((message) => {{
const currentDate = formatDate(message.timestamp);
if (currentDate !== lastDate) {{
const dayMarker = document.createElement(\"div\");
dayMarker.className = \"day-marker\";
dayMarker.textContent = currentDate;
chatContainer.appendChild(dayMarker);
lastDate = currentDate;
}}
const showSenderName = message.sender_name !== lastSender;
const bubble = createMessageBubble(message, showSenderName);
chatContainer.appendChild(bubble);
lastSender = message.sender_name;
}});
}}
renderChat(data);
</script>
</body>
</html>",
env!("CARGO_PKG_VERSION"),
users.join(", "),
users.join(", "),
serde_json::to_string(project).unwrap()
);
string
}

2
src/utils/mod.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod html_builder;
pub mod zip;

39
src/utils/zip.rs Normal file
View File

@ -0,0 +1,39 @@
use std::{fs::File, io::{self, Read}, path::PathBuf};
use zip::ZipArchive;
pub fn extract_specific_folder<R: Read + io::Seek>(
zip_archive: &mut ZipArchive<R>,
source_folder: &str,
destination_folder: &str,
) -> io::Result<()> {
for i in 0..zip_archive.len() {
let mut file = zip_archive.by_index(i)?;
let file_name = file.name();
// Check if the file is in the desired folder
if file_name.starts_with(source_folder) {
// Remove the source folder prefix
let relative_path = file_name.strip_prefix(source_folder).unwrap_or(file_name);
// Create the destination path
let mut dest_path = PathBuf::from(destination_folder);
dest_path.push(relative_path);
if file.is_dir() {
// Create the directory
std::fs::create_dir_all(&dest_path)?;
} else {
// Create the destination directory if it doesn't exist
if let Some(parent) = dest_path.parent() {
std::fs::create_dir_all(parent)?;
}
// Write the file to the destination
let mut outfile = File::create(&dest_path)?;
io::copy(&mut file, &mut outfile)?;
}
}
}
Ok(())
}