initial commit

This commit is contained in:
JZITNIK-github 2024-12-08 09:22:37 +01:00
commit 386e8abe5b
Signed by: jzitnik
GPG Key ID: C577A802A6AF4EF3
16 changed files with 1764 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

1180
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

16
Cargo.toml Normal file
View File

@ -0,0 +1,16 @@
[package]
name = "msgexport"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
clap = { version = "4.0", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
dialoguer = "0.11.0"
tabled = "0.15.0"
async-trait = "0.1.48"
encoding = "0.2"
zip = "0.6"
tokio-util = "0.7"

BIN
file.zip Normal file

Binary file not shown.

63
src/commands/add.rs Normal file
View File

@ -0,0 +1,63 @@
use std::{collections::HashMap, process::exit};
use dialoguer::Select;
use crate::{services::{instagram::Instagram, service::Service}, types::{participant::Participant, project::Project, user::User}};
pub async fn add(path: String) {
let mut raw_project = Project::load(path).await.unwrap_or_else(|e| {
eprintln!("Error while loading a project: {}", e);
exit(1);
});
if raw_project.project.users.is_empty() {
eprintln!("Error! You do not have any users in your project. Create at least one to import chat.");
exit(1);
}
let services: Vec<Box<dyn Service>> = vec![
Box::new(Instagram {
data: None
}),
];
let options: Vec<&'static str> = services.iter().map(|service| service.get_name()).collect();
let selected = Select::new()
.with_prompt("Choose a service")
.items(&options)
.default(0)
.interact()
.unwrap();
let service = services.get(selected).unwrap();
let mut final_service = service.setup();
let _ = final_service.load().await;
let participants = final_service.get_participants();
let mut user_map: HashMap<Participant, User> = HashMap::new();
println!("Now select what project user is coresponding to a {} participant", service.get_name());
for user in raw_project.project.users.clone() {
let parts: Vec<Participant> = participants.iter().filter(|participant| {
!user_map.contains_key(&participant)
}).map(|e| e.clone()).collect();
let options: Vec<String> = parts.iter().map(|part| part.name.clone()).collect();
let selected = Select::new()
.with_prompt(format!("Select a {} participant for user '{}'", service.get_name(), user.name))
.items(&options)
.default(0)
.interact()
.unwrap();
let selected_part = parts.get(selected).unwrap().clone();
user_map.insert(selected_part.clone(), user);
}
println!("Starting to merge messages...");
final_service.merge_messages(&mut raw_project, &user_map)
}

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

@ -0,0 +1,2 @@
pub mod users;
pub mod add;

88
src/commands/users.rs Normal file
View File

@ -0,0 +1,88 @@
use std::process::exit;
use dialoguer::{Input, Select};
use tabled::Table;
use crate::types::{project::Project, user::User};
pub async fn users(path: String) {
let project = Project::load(path.clone()).await.unwrap_or_else(|e| {
eprintln!("Error while loading a project: {}", e);
exit(1);
}).project;
menu(project, path).await;
}
pub fn print_users(users: &Vec<User>) {
if users.is_empty() {
println!("Currently you do not have any users!");
return
}
let users = Table::new(users).to_string();
println!("{}", users);
}
pub async fn menu(mut project: Project, path: String) {
println!("User management:");
print_users(&project.users);
println!("\n");
let options = vec!["Create a new user", "Remove a user", "Save and exit"];
let option = Select::new()
.with_prompt("Choose a option")
.items(&options)
.default(0)
.interact()
.unwrap();
match option {
0 => {
// Create a user
let name: String = Input::new()
.with_prompt("Name")
.interact_text()
.expect("Failed to read a line");
let user = User {
name
};
project.users.push(user);
Box::pin(menu(project, path)).await;
},
1 => {
if project.users.is_empty() {
println!("You do not have any users created! No users to be removed!");
Box::pin(menu(project, path)).await;
return;
}
let options_users: Vec<String> = project.users.iter().map(|user| {
user.name.clone()
}).collect();
let option_remove = Select::new()
.with_prompt("Select user to be removed")
.items(&options_users)
.default(0)
.interact()
.unwrap();
project.users.remove(option_remove);
Box::pin(menu(project, path)).await;
},
2 => {
// Save and exit
let _ = project.save(path).await;
},
_ => {
println!("Error! Unknown option!");
Box::pin(menu(project, path)).await;
}
}
}

55
src/main.rs Normal file
View File

@ -0,0 +1,55 @@
mod types;
mod commands;
mod services;
use std::collections::HashSet;
use clap::{command, ArgGroup, Parser};
use commands::{add::add, users::users};
use types::project::Project;
#[derive(Parser, Debug)]
#[command(
name = "msgexport",
version = env!("CARGO_PKG_VERSION"),
about = "Export and merge your messages between platforms",
author = "Jakub Žitník",
group = ArgGroup::new("command")
.args(&["new", "users", "add"])
.multiple(false)
.required(true)
)]
struct Cli {
#[arg(short = 'n', long = "new", help = "Create new msgexport project.", value_name = "OUTPUT_FILE")]
new: Option<String>,
#[arg(long = "users", help = "Add and remove users from the project.", value_name = "PROJECT_FILE")]
users: Option<String>,
#[arg(long = "add", help = "Add messages from a service.", value_name = "PROJECT_FILE")]
add: Option<String>,
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
if let Some(path) = cli.new {
let new_project = Project {
users: Vec::new(),
messages: Vec::new(),
timestamps: HashSet::new(),
media_index: 0,
};
let _ = new_project.save(path).await;
}
if let Some(path) = cli.users {
users(path).await;
}
if let Some(path) = cli.add {
add(path).await;
}
}

217
src/services/instagram.rs Normal file
View File

@ -0,0 +1,217 @@
use std::{collections::HashMap, fs, io, path::PathBuf, process::exit};
use async_trait::async_trait;
use dialoguer::{Input, Select};
use encoding::{all::ISO_8859_1, EncoderTrap, Encoding};
use serde::Deserialize;
use tokio::{fs::File, io::AsyncReadExt};
use crate::types::{message::{Message, Photo}, participant::Participant, project::ProjectRaw, user::User};
use super::service::Service;
fn decode_str(input: &String) -> String {
let vec = ISO_8859_1.encode(input.as_str(), EncoderTrap::Strict).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 {
// TODO: Take into consideration the user_map
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();
original_path.pop();
original_path.push(self.uri.clone());
Photo {
text_content: text_content.clone(),
img_path,
original_img_path: original_path.display().to_string()
}
}
}
#[derive(Deserialize, Debug)]
pub struct InstagramParticipant {
pub name: String,
}
#[derive(Deserialize, Debug)]
struct InstagramDataJson {
messages: Vec<InstagramMessage>,
participants: Vec<InstagramParticipant>
}
pub struct InstagramData {
pub project_path: String,
pub participants: Vec<InstagramParticipant>,
pub messages: Vec<InstagramMessage>,
}
pub struct Instagram {
pub data: Option<InstagramData>,
}
#[async_trait]
impl Service for Instagram {
fn get_name(&self) -> &'static str {
"Instagram"
}
fn setup(&self) -> Box<dyn Service> {
let export_path: String = Input::new()
.with_prompt("Enter path for your instagram export. (your_instagram_activity folder)")
.interact()
.unwrap();
let mut path = PathBuf::from(export_path);
path.push(PathBuf::from("messages/inbox"));
let options: Vec<String> = fs::read_dir(&path).unwrap_or_else(|_| {
eprintln!("Folder not found!");
exit(1);
})
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().is_dir())
.map(|entry| { String::from(entry.file_name().to_str().unwrap()) }).collect();
let option = Select::new()
.with_prompt("Choose a chat")
.items(&options)
.default(0)
.interact()
.unwrap();
let selected = options.get(option).unwrap().clone();
path.push(selected.clone());
Box::new(Instagram {
data: Some(InstagramData {
project_path: path.display().to_string(),
participants: vec![],
messages: vec![],
}),
})
}
async fn load(&mut self) -> io::Result<()> {
let data = self.data.as_mut().unwrap();
let path = PathBuf::from(&data.project_path);
let paths: Vec<PathBuf> = fs::read_dir(&path).unwrap_or_else(|_| {
eprintln!("Folder not found!");
exit(1);
})
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().is_file())
.map(|entry| { entry.path() }).collect();
for (index, path) in paths.iter().enumerate() {
if !path.exists() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!(
"File does not exist: {}",
path.to_str().unwrap(),
),
));
}
let mut file = File::open(path).await?;
let mut contents = String::new();
file.read_to_string(&mut contents).await?;
let jsondata: InstagramDataJson = serde_json::from_str(&contents)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
if index == 0{
// Load participants
for participant in jsondata.participants {
data.participants.push(participant);
}
}
for message in jsondata.messages {
data.messages.push(message);
}
}
Ok(())
}
fn get_participants(&self) -> Vec<Participant> {
let data = self.data.as_ref().unwrap();
data.participants.iter().map(|participant| {
Participant {
name: decode_str(&participant.name)
}
}).collect()
}
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());
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 the file to the media folder
// Update the highest media index of the project if the message was merged
raw_project.project.media_index = media_index_clone;
}
println!("{:?}", msg);
}
}
}

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

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

15
src/services/service.rs Normal file
View File

@ -0,0 +1,15 @@
use std::{collections::HashMap, io};
use async_trait::async_trait;
use crate::types::{participant::Participant, project::ProjectRaw, user::User};
#[async_trait]
pub trait Service {
fn get_name(&self) -> &'static str;
fn setup(&self) -> Box<dyn Service>;
async fn load(&mut self) -> io::Result<()>;
fn get_participants(&self) -> Vec<Participant>;
fn merge_messages(&self, raw_project: &mut ProjectRaw, user_map: &HashMap<Participant, User>);
}

18
src/types/message.rs Normal file
View File

@ -0,0 +1,18 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Message {
pub sender_name: String,
pub timestamp: usize,
pub text_content: Option<String>,
pub photos: Vec<Photo>
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Photo {
pub text_content: Option<String>,
pub img_path: String,
#[serde(skip_serializing)]
pub original_img_path: String,
}

4
src/types/mod.rs Normal file
View File

@ -0,0 +1,4 @@
pub mod project;
pub mod user;
pub mod participant;
pub mod message;

4
src/types/participant.rs Normal file
View File

@ -0,0 +1,4 @@
#[derive(Hash, PartialEq, Eq, Debug, Clone)]
pub struct Participant {
pub name: String
}

92
src/types/project.rs Normal file
View File

@ -0,0 +1,92 @@
use std::{collections::HashSet, io::{self, Cursor, Read, Write}, path::PathBuf, process::exit};
use serde::{Deserialize, Serialize};
use tokio::{fs::File, io::{AsyncReadExt, AsyncWriteExt, BufWriter}};
use zip::{write::FileOptions, ZipArchive, ZipWriter};
use super::{message::Message, user::User};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Project {
pub users: Vec<User>,
pub messages: Vec<Message>,
pub timestamps: HashSet<usize>,
pub media_index: usize,
}
pub struct ProjectRaw {
pub project: Project,
pub zip: ZipArchive<Cursor<Vec<u8>>>
}
impl Project {
pub async fn load(pathstr: String) -> io::Result<ProjectRaw> {
let path = PathBuf::from(pathstr);
if !path.exists() {
return Err(io::Error::new(io::ErrorKind::NotFound, "File does not exist!"));
}
let mut file = File::open(path).await?;
let mut buffer = Vec::new();
file.read_to_end(&mut buffer).await?;
let cursor = Cursor::new(buffer);
let archive = ZipArchive::new(cursor)?;
let mut copy_archive = archive.clone();
let mut project_file = copy_archive.by_name("project.json").unwrap_or_else(|_| {
eprintln!("Invalid project format!");
exit(1);
});
let mut json_content = String::new();
project_file.read_to_string(&mut json_content)?;
let data: Project = serde_json::from_str(&json_content)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
Ok(ProjectRaw {
project: data,
zip: archive
})
}
pub async fn save(&self, file_path: String) -> io::Result<()> {
let contents = serde_json::to_string(&self)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let file = File::create(file_path).await?;
let mut writer = BufWriter::new(file);
let mut buffer = Cursor::new(Vec::new());
{
let mut zip = ZipWriter::new(&mut buffer);
let options = FileOptions::default().unix_permissions(0o755);
zip.add_directory("media/", options)?;
zip.start_file("project.json", options)?;
zip.write_all(contents.as_bytes())?;
zip.finish()?;
}
writer.write_all(&buffer.into_inner()).await?;
writer.flush().await?;
Ok(())
}
pub fn push_msg(&mut self, message: &Message) -> bool {
if self.timestamps.contains(&message.timestamp) {
return false;
}
self.timestamps.insert(message.timestamp);
self.messages.push(message.clone());
return true;
}
}

7
src/types/user.rs Normal file
View File

@ -0,0 +1,7 @@
use serde::{Deserialize, Serialize};
use tabled::Tabled;
#[derive(Serialize, Deserialize, Debug, Tabled, Clone)]
pub struct User {
pub name: String,
}