initial commit
This commit is contained in:
commit
386e8abe5b
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/target
|
1180
Cargo.lock
generated
Normal file
1180
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
Cargo.toml
Normal file
16
Cargo.toml
Normal 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"
|
63
src/commands/add.rs
Normal file
63
src/commands/add.rs
Normal 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
2
src/commands/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod users;
|
||||
pub mod add;
|
88
src/commands/users.rs
Normal file
88
src/commands/users.rs
Normal 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
55
src/main.rs
Normal 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
217
src/services/instagram.rs
Normal 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
2
src/services/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod service;
|
||||
pub mod instagram;
|
15
src/services/service.rs
Normal file
15
src/services/service.rs
Normal 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
18
src/types/message.rs
Normal 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
4
src/types/mod.rs
Normal file
@ -0,0 +1,4 @@
|
||||
pub mod project;
|
||||
pub mod user;
|
||||
pub mod participant;
|
||||
pub mod message;
|
4
src/types/participant.rs
Normal file
4
src/types/participant.rs
Normal file
@ -0,0 +1,4 @@
|
||||
#[derive(Hash, PartialEq, Eq, Debug, Clone)]
|
||||
pub struct Participant {
|
||||
pub name: String
|
||||
}
|
92
src/types/project.rs
Normal file
92
src/types/project.rs
Normal 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
7
src/types/user.rs
Normal file
@ -0,0 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tabled::Tabled;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Tabled, Clone)]
|
||||
pub struct User {
|
||||
pub name: String,
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user