Can import ZIP
This commit is contained in:
		@@ -6,7 +6,10 @@ use crate::converters::finances_manager_converter::{
 | 
				
			|||||||
};
 | 
					};
 | 
				
			||||||
use crate::extractors::auth_extractor::AuthExtractor;
 | 
					use crate::extractors::auth_extractor::AuthExtractor;
 | 
				
			||||||
use crate::extractors::file_extractor::FileExtractor;
 | 
					use crate::extractors::file_extractor::FileExtractor;
 | 
				
			||||||
use crate::models::accounts::AccountType;
 | 
					use crate::models::accounts::{Account, AccountID, AccountType};
 | 
				
			||||||
 | 
					use crate::models::files;
 | 
				
			||||||
 | 
					use crate::models::files::FileID;
 | 
				
			||||||
 | 
					use crate::models::movements::Movement;
 | 
				
			||||||
use crate::services::accounts_service::UpdateAccountQuery;
 | 
					use crate::services::accounts_service::UpdateAccountQuery;
 | 
				
			||||||
use crate::services::movements_service::UpdateMovementQuery;
 | 
					use crate::services::movements_service::UpdateMovementQuery;
 | 
				
			||||||
use crate::services::{accounts_service, files_service, movements_service};
 | 
					use crate::services::{accounts_service, files_service, movements_service};
 | 
				
			||||||
@@ -14,10 +17,20 @@ use crate::utils::time_utils::{format_date, time};
 | 
				
			|||||||
use actix_files::NamedFile;
 | 
					use actix_files::NamedFile;
 | 
				
			||||||
use actix_web::{HttpRequest, HttpResponse};
 | 
					use actix_web::{HttpRequest, HttpResponse};
 | 
				
			||||||
use serde::Serialize;
 | 
					use serde::Serialize;
 | 
				
			||||||
 | 
					use serde::de::DeserializeOwned;
 | 
				
			||||||
 | 
					use std::collections::HashMap;
 | 
				
			||||||
use std::fs::File;
 | 
					use std::fs::File;
 | 
				
			||||||
use std::io::Write;
 | 
					use std::io::{Cursor, Read, Seek, Write};
 | 
				
			||||||
use zip::ZipWriter;
 | 
					 | 
				
			||||||
use zip::write::SimpleFileOptions;
 | 
					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),
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/// Generate export filename
 | 
					/// Generate export filename
 | 
				
			||||||
fn export_filename(ext: &str) -> String {
 | 
					fn export_filename(ext: &str) -> String {
 | 
				
			||||||
@@ -153,3 +166,94 @@ pub async fn zip_export(req: HttpRequest, auth: AuthExtractor) -> HttpResult {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    Ok(file.into_response(&req))
 | 
					    Ok(file.into_response(&req))
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Read JSON from archive
 | 
				
			||||||
 | 
					fn unzip_json<E: DeserializeOwned, R: Read + Seek>(
 | 
				
			||||||
 | 
					    zip: &mut ZipArchive<R>,
 | 
				
			||||||
 | 
					    path: &str,
 | 
				
			||||||
 | 
					) -> anyhow::Result<E> {
 | 
				
			||||||
 | 
					    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<Account> = unzip_json(&mut zip, constants::zip_export::ACCOUNTS_FILE)?;
 | 
				
			||||||
 | 
					    let new_movements: Vec<Movement> = unzip_json(&mut zip, constants::zip_export::MOVEMENTS_FILE)?;
 | 
				
			||||||
 | 
					    let new_files: Vec<files::File> = unzip_json(&mut zip, constants::zip_export::FILES_FILE)?;
 | 
				
			||||||
 | 
					    // TODO : inbox
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Delete all data
 | 
				
			||||||
 | 
					    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
 | 
				
			||||||
 | 
					    for movement in new_movements {
 | 
				
			||||||
 | 
					        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?;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Ok(HttpResponse::Accepted().finish())
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -102,7 +102,7 @@ pub async fn serve_file(req: HttpRequest, file: &File, download_file: bool) -> H
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
/// Delete an uploaded file
 | 
					/// Delete an uploaded file
 | 
				
			||||||
pub async fn delete(file_extractor: FileIdExtractor) -> HttpResult {
 | 
					pub async fn delete(file_extractor: FileIdExtractor) -> HttpResult {
 | 
				
			||||||
    match files_service::delete_file_if_unused(file_extractor.as_ref().id()).await {
 | 
					    match files_service::delete_file_if_unused(file_extractor.as_ref().id(), true).await {
 | 
				
			||||||
        Ok(true) => Ok(HttpResponse::Accepted().finish()),
 | 
					        Ok(true) => Ok(HttpResponse::Accepted().finish()),
 | 
				
			||||||
        Ok(false) => Ok(HttpResponse::Conflict().finish()),
 | 
					        Ok(false) => Ok(HttpResponse::Conflict().finish()),
 | 
				
			||||||
        Err(e) => {
 | 
					        Err(e) => {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,3 +1,4 @@
 | 
				
			|||||||
 | 
					use crate::controllers::backup_controller::BackupControllerError;
 | 
				
			||||||
use actix_web::http::StatusCode;
 | 
					use actix_web::http::StatusCode;
 | 
				
			||||||
use actix_web::{HttpResponse, ResponseError};
 | 
					use actix_web::{HttpResponse, ResponseError};
 | 
				
			||||||
use std::error::Error;
 | 
					use std::error::Error;
 | 
				
			||||||
@@ -35,6 +36,8 @@ pub enum HttpFailure {
 | 
				
			|||||||
    ZipError(#[from] ZipError),
 | 
					    ZipError(#[from] ZipError),
 | 
				
			||||||
    #[error("a standard I/O manipulation error occurred: {0}")]
 | 
					    #[error("a standard I/O manipulation error occurred: {0}")]
 | 
				
			||||||
    StdIoError(#[from] std::io::Error),
 | 
					    StdIoError(#[from] std::io::Error),
 | 
				
			||||||
 | 
					    #[error("an error occurred while performing backup/recovery operation: {0}")]
 | 
				
			||||||
 | 
					    BackupControllerError(#[from] BackupControllerError),
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
impl ResponseError for HttpFailure {
 | 
					impl ResponseError for HttpFailure {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -174,6 +174,10 @@ async fn main() -> std::io::Result<()> {
 | 
				
			|||||||
                "/api/backup/zip/export",
 | 
					                "/api/backup/zip/export",
 | 
				
			||||||
                web::get().to(backup_controller::zip_export),
 | 
					                web::get().to(backup_controller::zip_export),
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
 | 
					            .route(
 | 
				
			||||||
 | 
					                "/api/backup/zip/import",
 | 
				
			||||||
 | 
					                web::post().to(backup_controller::zip_import),
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
            // Static assets
 | 
					            // Static assets
 | 
				
			||||||
            .route("/", web::get().to(static_controller::root_index))
 | 
					            .route("/", web::get().to(static_controller::root_index))
 | 
				
			||||||
            .route(
 | 
					            .route(
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -28,7 +28,7 @@ impl Display for AccountType {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#[derive(Queryable, Debug, Clone, serde::Serialize)]
 | 
					#[derive(Queryable, Debug, Clone, serde::Serialize, serde::Deserialize)]
 | 
				
			||||||
pub struct Account {
 | 
					pub struct Account {
 | 
				
			||||||
    id: i32,
 | 
					    id: i32,
 | 
				
			||||||
    pub name: String,
 | 
					    pub name: String,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,10 +2,12 @@ use crate::models::users::UserID;
 | 
				
			|||||||
use crate::schema::*;
 | 
					use crate::schema::*;
 | 
				
			||||||
use diesel::prelude::*;
 | 
					use diesel::prelude::*;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
 | 
					#[derive(
 | 
				
			||||||
 | 
					    Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord,
 | 
				
			||||||
 | 
					)]
 | 
				
			||||||
pub struct FileID(pub i32);
 | 
					pub struct FileID(pub i32);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#[derive(Queryable, Debug, serde::Serialize)]
 | 
					#[derive(Queryable, Debug, serde::Serialize, serde::Deserialize)]
 | 
				
			||||||
pub struct File {
 | 
					pub struct File {
 | 
				
			||||||
    id: i32,
 | 
					    id: i32,
 | 
				
			||||||
    pub time_create: i64,
 | 
					    pub time_create: i64,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -7,7 +7,7 @@ use diesel::{Insertable, Queryable, QueryableByName};
 | 
				
			|||||||
pub struct MovementID(pub i32);
 | 
					pub struct MovementID(pub i32);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/// Single movement information
 | 
					/// Single movement information
 | 
				
			||||||
#[derive(Queryable, QueryableByName, Debug, Clone, serde::Serialize)]
 | 
					#[derive(Queryable, QueryableByName, Debug, Clone, serde::Serialize, serde::Deserialize)]
 | 
				
			||||||
pub struct Movement {
 | 
					pub struct Movement {
 | 
				
			||||||
    /// The ID of the movement
 | 
					    /// The ID of the movement
 | 
				
			||||||
    id: i32,
 | 
					    id: i32,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -97,3 +97,11 @@ pub async fn delete(id: AccountID) -> anyhow::Result<()> {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    Ok(())
 | 
					    Ok(())
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Delete all the accounts of a user
 | 
				
			||||||
 | 
					pub async fn delete_all_user(user: UserID) -> anyhow::Result<()> {
 | 
				
			||||||
 | 
					    for account in get_list_user(user).await? {
 | 
				
			||||||
 | 
					        delete(account.id()).await?;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    Ok(())
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -13,6 +13,8 @@ use mime_guess::Mime;
 | 
				
			|||||||
enum FilesServiceError {
 | 
					enum FilesServiceError {
 | 
				
			||||||
    #[error("UnknownMimeType!")]
 | 
					    #[error("UnknownMimeType!")]
 | 
				
			||||||
    UnknownMimeType,
 | 
					    UnknownMimeType,
 | 
				
			||||||
 | 
					    #[error("Could not delete all the files of the user: a file is still referenced!")]
 | 
				
			||||||
 | 
					    DeleteAllUserFileStillReferenced,
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
pub async fn create_file_with_file_name(
 | 
					pub async fn create_file_with_file_name(
 | 
				
			||||||
@@ -84,11 +86,11 @@ pub async fn get_file_content(file: &File) -> anyhow::Result<Vec<u8>> {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
/// Delete the file if it is not referenced anymore in the database. Returns true
 | 
					/// Delete the file if it is not referenced anymore in the database. Returns true
 | 
				
			||||||
/// if the file was actually deleted, false otherwise
 | 
					/// if the file was actually deleted, false otherwise
 | 
				
			||||||
pub async fn delete_file_if_unused(id: FileID) -> anyhow::Result<bool> {
 | 
					pub async fn delete_file_if_unused(id: FileID, skip_age_check: bool) -> anyhow::Result<bool> {
 | 
				
			||||||
    let file = get_file_with_id(id)?;
 | 
					    let file = get_file_with_id(id)?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Check if the file is old enough
 | 
					    // Check if the file is old enough
 | 
				
			||||||
    if file.time_create as u64 + constants::MIN_FILE_LIFETIME > time() {
 | 
					    if file.time_create as u64 + constants::MIN_FILE_LIFETIME > time() && !skip_age_check {
 | 
				
			||||||
        return Ok(false);
 | 
					        return Ok(false);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -129,10 +131,21 @@ pub async fn run_garbage_collector() -> anyhow::Result<usize> {
 | 
				
			|||||||
    let mut count_deleted = 0;
 | 
					    let mut count_deleted = 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    for file in get_entire_list().await? {
 | 
					    for file in get_entire_list().await? {
 | 
				
			||||||
        if delete_file_if_unused(file.id()).await? {
 | 
					        if delete_file_if_unused(file.id(), false).await? {
 | 
				
			||||||
            count_deleted += 1;
 | 
					            count_deleted += 1;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Ok(count_deleted)
 | 
					    Ok(count_deleted)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Delete all the files of a given user
 | 
				
			||||||
 | 
					pub async fn delete_all_user(user_id: UserID) -> anyhow::Result<()> {
 | 
				
			||||||
 | 
					    for file in get_all_files_user(user_id).await? {
 | 
				
			||||||
 | 
					        if !delete_file_if_unused(file.id(), true).await? {
 | 
				
			||||||
 | 
					            return Err(FilesServiceError::DeleteAllUserFileStillReferenced.into());
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Ok(())
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -20,4 +20,24 @@ export class BackupApi {
 | 
				
			|||||||
      formData: fd,
 | 
					      formData: fd,
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * ZIP Export
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  static get ZIPExportURL(): string {
 | 
				
			||||||
 | 
					    return APIClient.backendURL() + "/backup/zip/export";
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * ZIP import
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  static async ZIPImport(file: File): Promise<void> {
 | 
				
			||||||
 | 
					    const fd = new FormData();
 | 
				
			||||||
 | 
					    fd.append("file", file);
 | 
				
			||||||
 | 
					    await APIClient.exec({
 | 
				
			||||||
 | 
					      method: "POST",
 | 
				
			||||||
 | 
					      uri: "/backup/zip/import",
 | 
				
			||||||
 | 
					      formData: fd,
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,4 +1,4 @@
 | 
				
			|||||||
import { mdiCash } from "@mdi/js";
 | 
					import { mdiCash, mdiFolderZipOutline } from "@mdi/js";
 | 
				
			||||||
import Icon from "@mdi/react";
 | 
					import Icon from "@mdi/react";
 | 
				
			||||||
import DownloadIcon from "@mui/icons-material/Download";
 | 
					import DownloadIcon from "@mui/icons-material/Download";
 | 
				
			||||||
import UploadIcon from "@mui/icons-material/Upload";
 | 
					import UploadIcon from "@mui/icons-material/Upload";
 | 
				
			||||||
@@ -24,7 +24,23 @@ import { RouterLink } from "../widgets/RouterLink";
 | 
				
			|||||||
export function BackupRoute(): React.ReactElement {
 | 
					export function BackupRoute(): React.ReactElement {
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <MoneyMgrWebRouteContainer label={"Backup & Restore"}>
 | 
					    <MoneyMgrWebRouteContainer label={"Backup & Restore"}>
 | 
				
			||||||
      <Grid container>
 | 
					      <Grid container spacing={2}>
 | 
				
			||||||
 | 
					        {/* ZIP */}
 | 
				
			||||||
 | 
					        <ImportExportModal
 | 
				
			||||||
 | 
					          icon={<Icon path={mdiFolderZipOutline} size={1} />}
 | 
				
			||||||
 | 
					          label="ZIP"
 | 
				
			||||||
 | 
					          description={
 | 
				
			||||||
 | 
					            <>
 | 
				
			||||||
 | 
					              Perform an exhaustive export or import (of accounts, inbox,
 | 
				
			||||||
 | 
					              movements and files).
 | 
				
			||||||
 | 
					            </>
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					          importWarning="Existing data will be COMPLETELY ERASED, before starting import!"
 | 
				
			||||||
 | 
					          exportURL={BackupApi.ZIPExportURL}
 | 
				
			||||||
 | 
					          onImport={BackupApi.ZIPImport}
 | 
				
			||||||
 | 
					          acceptedFiles={"application/zip"}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        {/* FinancesManager */}
 | 
					        {/* FinancesManager */}
 | 
				
			||||||
        <ImportExportModal
 | 
					        <ImportExportModal
 | 
				
			||||||
          icon={<Icon path={mdiCash} size={1} />}
 | 
					          icon={<Icon path={mdiCash} size={1} />}
 | 
				
			||||||
@@ -38,7 +54,7 @@ export function BackupRoute(): React.ReactElement {
 | 
				
			|||||||
                rel="noreferrer"
 | 
					                rel="noreferrer"
 | 
				
			||||||
                style={{ color: "inherit" }}
 | 
					                style={{ color: "inherit" }}
 | 
				
			||||||
              >
 | 
					              >
 | 
				
			||||||
                FinanceManager
 | 
					                FinancesManager
 | 
				
			||||||
              </a>{" "}
 | 
					              </a>{" "}
 | 
				
			||||||
              file format (does not support file attachments).
 | 
					              file format (does not support file attachments).
 | 
				
			||||||
            </>
 | 
					            </>
 | 
				
			||||||
@@ -59,6 +75,7 @@ function ImportExportModal(p: {
 | 
				
			|||||||
  importWarning?: string;
 | 
					  importWarning?: string;
 | 
				
			||||||
  exportURL: string;
 | 
					  exportURL: string;
 | 
				
			||||||
  onImport?: (file: File) => Promise<void>;
 | 
					  onImport?: (file: File) => Promise<void>;
 | 
				
			||||||
 | 
					  acceptedFiles?: string;
 | 
				
			||||||
}): React.ReactElement {
 | 
					}): React.ReactElement {
 | 
				
			||||||
  const confirm = useConfirm();
 | 
					  const confirm = useConfirm();
 | 
				
			||||||
  const alert = useAlert();
 | 
					  const alert = useAlert();
 | 
				
			||||||
@@ -70,6 +87,7 @@ function ImportExportModal(p: {
 | 
				
			|||||||
    try {
 | 
					    try {
 | 
				
			||||||
      const fileEl = document.createElement("input");
 | 
					      const fileEl = document.createElement("input");
 | 
				
			||||||
      fileEl.type = "file";
 | 
					      fileEl.type = "file";
 | 
				
			||||||
 | 
					      if (p.acceptedFiles) fileEl.accept = p.acceptedFiles;
 | 
				
			||||||
      fileEl.click();
 | 
					      fileEl.click();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // Wait for a file to be chosen
 | 
					      // Wait for a file to be chosen
 | 
				
			||||||
@@ -108,10 +126,14 @@ function ImportExportModal(p: {
 | 
				
			|||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <Grid size={{ md: 6 }}>
 | 
					    <Grid size={{ md: 6, lg: 3 }} style={{ height: "100%" }}>
 | 
				
			||||||
      <Card sx={{ maxWidth: 345 }} elevation={3} variant="outlined">
 | 
					      <Card
 | 
				
			||||||
 | 
					        elevation={3}
 | 
				
			||||||
 | 
					        variant="outlined"
 | 
				
			||||||
 | 
					        style={{ height: "100%", display: "flex", flexDirection: "column" }}
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
        <CardHeader avatar={p.icon} title={p.label} />
 | 
					        <CardHeader avatar={p.icon} title={p.label} />
 | 
				
			||||||
        <CardContent>
 | 
					        <CardContent style={{ flex: 1 }}>
 | 
				
			||||||
          <Typography style={{ textAlign: "justify" }}>
 | 
					          <Typography style={{ textAlign: "justify" }}>
 | 
				
			||||||
            {p.description}
 | 
					            {p.description}
 | 
				
			||||||
          </Typography>
 | 
					          </Typography>
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user