use crate::app_config::AppConfig; use crate::constants; use crate::controllers::HttpResult; use crate::converters::finances_manager_converter::{ FinancesManagerAccount, FinancesManagerFile, FinancesManagerMovement, }; use crate::extractors::auth_extractor::AuthExtractor; use crate::extractors::file_extractor::FileExtractor; use crate::models::accounts::{Account, AccountID, AccountType}; use crate::models::files; use crate::models::files::FileID; use crate::models::inbox::InboxEntry; use crate::models::movements::{Movement, MovementID}; use crate::services::accounts_service::UpdateAccountQuery; use crate::services::inbox_service::UpdateInboxEntryQuery; use crate::services::movements_service::UpdateMovementQuery; use crate::services::{accounts_service, files_service, inbox_service, movements_service}; use crate::utils::time_utils::{format_date, time}; use actix_files::NamedFile; use actix_web::{HttpRequest, HttpResponse}; use rust_xlsxwriter::{Color, Format, Formula, Table, Workbook}; use serde::Serialize; use serde::de::DeserializeOwned; use std::collections::HashMap; use std::fs::File; use std::io::{Cursor, Read, Seek, Write}; use zip::write::SimpleFileOptions; use zip::{ZipArchive, ZipWriter}; #[derive(thiserror::Error, Debug)] pub enum BackupControllerError { #[error("The account with id {0:?} does not exists!")] NonexistentAccountId(AccountID), #[error("The file with id {0:?} does not exists!")] NonexistentFileId(FileID), #[error("The movement with id {0:?} does not exists!")] NonexistentMovementId(MovementID), } /// Generate export filename fn export_filename(ext: &str) -> String { format!( "export_{}.{ext}", format_date(time() as i64).unwrap().replace('/', "-") ) } /// Import data from a [FinancesManager](https://gitlab.com/pierre42100/cpp-financesmanager) file pub async fn finances_manager_import(auth: AuthExtractor, file: FileExtractor) -> HttpResult { let file = FinancesManagerFile::parse(&String::from_utf8_lossy(&file.buff))?; // Create each account & push the movements independently for file_account in file.accounts { let account = accounts_service::create( auth.user_id(), &UpdateAccountQuery { name: file_account.name, r#type: AccountType::Cash, }, ) .await?; for file_movement in file_account.movements { movements_service::create(&UpdateMovementQuery { account_id: account.id(), time: file_movement.time, label: file_movement.label, file_id: None, amount: file_movement.amount, checked: false, }) .await?; } } Ok(HttpResponse::Accepted().finish()) } /// Export data to a [FinancesManager](https://gitlab.com/pierre42100/cpp-financesmanager) file pub async fn finances_manager_export(auth: AuthExtractor) -> HttpResult { let mut accounts = accounts_service::get_list_user(auth.user_id()).await?; accounts.sort_by_key(|a| a.id()); let mut out = FinancesManagerFile { accounts: vec![] }; for account in accounts { let mut movements = movements_service::get_list_account(account.id()).await?; movements.sort_by(|a, b| b.time.cmp(&a.time)); let mut file_account = FinancesManagerAccount { name: account.name, movements: Vec::with_capacity(movements.len()), }; for movement in movements { file_account.movements.push(FinancesManagerMovement { label: movement.label, time: movement.time as u64, amount: movement.amount, }); } out.accounts.push(file_account); } Ok(HttpResponse::Ok() .insert_header(( "Content-Disposition", format!("attachment; filename={}", export_filename("finance")), )) .body(out.encode())) } /// Add JSON file to ZIP fn zip_json( zip: &mut ZipWriter, path: &str, content: &E, ) -> anyhow::Result<()> { let file_encoded = serde_json::to_string(&content)?; let options = SimpleFileOptions::default() .compression_method(zip::CompressionMethod::Deflated) .unix_permissions(0o750); zip.start_file(path, options)?; zip.write_all(file_encoded.as_bytes())?; Ok(()) } /// Export all user data as ZIP pub async fn zip_export(req: HttpRequest, auth: AuthExtractor) -> HttpResult { let dest_dir = tempfile::tempdir_in(&AppConfig::get().temp_dir)?; let zip_path = dest_dir.path().join("export.zip"); let file = File::create(&zip_path)?; let mut zip = ZipWriter::new(file); // Process all JSON documents zip_json( &mut zip, constants::zip_export::ACCOUNTS_FILE, &accounts_service::get_list_user(auth.user_id()).await?, )?; zip_json( &mut zip, constants::zip_export::MOVEMENTS_FILE, &movements_service::get_all_movements_user(auth.user_id()).await?, )?; zip_json( &mut zip, constants::zip_export::INBOX_FILE, &inbox_service::get_list_user(auth.user_id()).await?, )?; let files_list = files_service::get_all_files_user(auth.user_id()).await?; zip_json(&mut zip, constants::zip_export::FILES_FILE, &files_list)?; // Process all files for file in files_list { let buff = files_service::get_file_content(&file).await?; let options = SimpleFileOptions::default() .compression_method(zip::CompressionMethod::Deflated) .unix_permissions(0o750); zip.start_file( format!("{}{}", constants::zip_export::FILES_DIR, file.sha512), options, )?; zip.write_all(&buff)?; } // Finalize ZIP and return response zip.finish()?; let file = File::open(zip_path)?; let file = NamedFile::from_file(file, export_filename("zip"))?; Ok(file.into_response(&req)) } /// Read JSON from archive fn unzip_json( zip: &mut ZipArchive, path: &str, ) -> anyhow::Result { let mut file = zip.by_name(path)?; let mut content = String::with_capacity(file.size() as usize); file.read_to_string(&mut content)?; Ok(serde_json::from_str(&content)?) } /// Replace all data with data included in ZIP file pub async fn zip_import(auth: AuthExtractor, file: FileExtractor) -> HttpResult { // Parse provided files let zip_cursor = Cursor::new(file.buff); let mut zip = ZipArchive::new(zip_cursor)?; let new_accounts: Vec = unzip_json(&mut zip, constants::zip_export::ACCOUNTS_FILE)?; let new_movements: Vec = unzip_json(&mut zip, constants::zip_export::MOVEMENTS_FILE)?; let new_inbox_entries: Vec = unzip_json(&mut zip, constants::zip_export::INBOX_FILE)?; let new_files: Vec = unzip_json(&mut zip, constants::zip_export::FILES_FILE)?; // Delete all data inbox_service::delete_all_user(auth.user_id()).await?; accounts_service::delete_all_user(auth.user_id()).await?; files_service::delete_all_user(auth.user_id()).await?; // Create the files let mut files_mapping = HashMap::new(); for file in new_files { let mut zip_file = zip .by_name(&format!( "{}{}", constants::zip_export::FILES_DIR, file.sha512 )) .map_err(|e| anyhow::anyhow!("Could not find file with hash {}: {}", file.sha512, e))?; let mut file_buff = Vec::with_capacity(zip_file.size() as usize); zip_file.read_to_end(&mut file_buff)?; let created_file = files_service::create_file_with_file_name(auth.user_id(), &file.file_name, &file_buff) .await?; files_mapping.insert(file.id(), created_file); } // Create the accounts let mut accounts_mapping = HashMap::new(); for account in new_accounts { let created_account = accounts_service::create( auth.user_id(), &UpdateAccountQuery { name: account.name.to_string(), r#type: account.account_type(), }, ) .await?; if account.default_account { accounts_service::set_default(auth.user_id(), created_account.id()).await?; } accounts_mapping.insert(account.id(), created_account); } // Create the movements let mut movements_mapping = HashMap::new(); for movement in new_movements { let created_movement = movements_service::create(&UpdateMovementQuery { account_id: accounts_mapping .get(&movement.account_id()) .ok_or_else(|| BackupControllerError::NonexistentAccountId(movement.account_id()))? .id(), time: movement.time as u64, label: movement.label.to_string(), file_id: movement .file_id() .map(|file_id| { files_mapping .get(&file_id) .ok_or(BackupControllerError::NonexistentFileId(file_id)) }) .transpose()? .map(|f| f.id()), amount: movement.amount, checked: movement.checked, }) .await?; movements_mapping.insert(movement.id(), created_movement); } // Create the inbox entries for inbox_entry in new_inbox_entries { inbox_service::create( auth.user_id(), &UpdateInboxEntryQuery { file_id: files_mapping .get(&inbox_entry.file_id()) .ok_or(BackupControllerError::NonexistentFileId( inbox_entry.file_id(), ))? .id(), movement_id: inbox_entry .movement_id() .map(|mov_id| { movements_mapping .get(&mov_id) .ok_or(BackupControllerError::NonexistentMovementId(mov_id)) }) .transpose()? .map(|f| f.id()), time: inbox_entry.time as u64, label: inbox_entry.label, amount: inbox_entry.amount, }, ) .await?; } Ok(HttpResponse::Accepted().finish()) } /// Export all movement to XSLX pub async fn xslx_export(auth: AuthExtractor) -> HttpResult { let mut workbook = Workbook::new(); for account in accounts_service::get_list_user(auth.user_id()).await? { let worksheet = workbook.add_worksheet(); worksheet.set_name(account.name.to_string())?; // Configure columns let header_format = Format::new() .set_bold() .set_background_color(Color::Black) .set_foreground_color(Color::White); worksheet.set_column_width(0, 15)?; worksheet.set_column_width(1, 70)?; worksheet.set_column_width(2, 15)?; // Write headers worksheet.write_with_format(0, 0, "Date", &header_format)?; worksheet.write_with_format(0, 1, "Label", &header_format)?; worksheet.write_with_format(0, 2, "Amount", &header_format)?; // Write movements let mut movements = movements_service::get_list_account(account.id()).await?; movements.sort_by(|a, b| a.time.to_string().cmp(&b.time.to_string())); for (idx, movement) in movements.iter().enumerate() { worksheet.write(idx as u32 + 1, 0, format_date(movement.time)?)?; worksheet.write(idx as u32 + 1, 1, &movement.label)?; worksheet.write(idx as u32 + 1, 2, movement.amount)?; } // Create table let table = Table::new(); worksheet.add_table(0, 0, movements.len() as u32, 2, &table)?; // Add total worksheet.write_with_format(5, 5, "Total", &header_format)?; worksheet.write_formula( 6, 5, Formula::new(format!("SUM(C1,C{})", movements.len())).set_result( movements .iter() .fold(0f32, |acc, m| acc + m.amount) .to_string(), ), )?; } // Save final Excel Document let raw_excel = workbook.save_to_buffer()?; Ok(HttpResponse::Ok() .content_type("application/vnd.ms-excel") .insert_header(( "Content-Disposition", format!("attachment; filename={}", export_filename("xslx")), )) .body(raw_excel)) }