use crate::app::AppState; use crate::data::entities::WordData; use crate::data::entities::WordGroup; use crate::data::repositories::{BatchedRepository, Repository}; use crate::data_provider::import::{ImportData, ImportGroup, get_words_of_group, load_groups}; use crate::fs::*; use crate::jl_button; use crate::navigation::{NavigatedPage, Page, RootMessage}; use crate::notification::NotificationData::OpenFileError; use crate::notification::{MaybeError, NotificationData}; use crate::pages::import::ImportMessage::*; use crate::push_error; use crate::styling::*; use iced::widget::button::danger; use iced::widget::container::success; use iced::widget::{ Row, button, checkbox, column, container, progress_bar, row, rule, text, text_input, }; use iced::widget::{scrollable, space}; use iced::{Element, Task}; use iced_core::Alignment::Center; use iced_core::Length::Fill; use iced_core::Padding; use log::info; use parking_lot::Mutex; use rfd::AsyncFileDialog; use rusqlite::Connection; use std::fs::{File, OpenOptions}; use std::io::BufReader; use std::path::PathBuf; use std::sync::Arc; use tokio::task::spawn_blocking; use zip::ZipArchive; const DEFAULT_FIELDS: [&str; 6] = ["key", "value", "tags", "reading", "context", "description"]; pub struct ImportState { state: AppState, path: Option, import_data: Option, selected_index: usize, selected_property: Option, custom_property_name: String, skip_empty: bool, separator: String, progress: Option>>, } #[derive(Clone)] pub enum ImportMessage { Back, SelectFile, UpdateFile(PathBuf), UpdateImport(ImportData), NextGroup, PreviousGroup, OpenPropertyMapper(String), EditCustomProperty(String), SetupCustomProperty, SetupDefaultProperty(String), SetupDirect, RemoveMapping(String), SwitchSkipEmpty(bool), EditSeparator(String), StartImport, NextProgress, ImportFinished, } impl NavigatedPage for ImportState { fn navigate(&mut self, message: &ImportMessage) -> Option { if self.progress.is_some() { return None; } if let Back = message { return Some(Page::PreviousPage); } None } fn navigated(&mut self) {} fn update(&mut self, message: ImportMessage) -> Task { match message { Back => {} UpdateFile(path) => { self.path = Some(path); self.import_data = None; return self.load_package(); } SelectFile => return Self::select_import_file(), UpdateImport(import) => { self.selected_index = 0; self.import_data = Some(import); } NextGroup => { self.selected_index += 1; self.selected_property = None; self.custom_property_name.clear(); } PreviousGroup => { self.selected_index -= 1; self.selected_property = None; self.custom_property_name.clear(); } OpenPropertyMapper(prop) => self.selected_property = Some(prop), EditCustomProperty(prop_name) => self.custom_property_name = prop_name, SetupCustomProperty => { self.setup_mapping(self.custom_property_name.clone()); } SetupDefaultProperty(prop_name) => { self.setup_mapping(prop_name); } SetupDirect => { self.setup_mapping(self.selected_property.clone().unwrap()); } RemoveMapping(pro_name) => { self.remove_mapping(pro_name); } SwitchSkipEmpty(skip) => { self.skip_empty = skip; } EditSeparator(separator) => { self.separator = separator.clone(); } StartImport => { self.progress = Some(Arc::new(Mutex::new(0.0))); return Task::batch([self.start_import(), self.next_progress()]); } NextProgress => { if self.progress.is_none() { return Task::none(); } return self.next_progress(); } ImportFinished => { self.progress = None; let group = &mut self.import_data.as_mut().unwrap().0[self.selected_index]; group.imported = true; } } Task::none() } fn view(&self) -> Element<'_, ImportMessage> { back_overlay( { if let Some(value) = &self.progress { column![progress_bar(0f32..=1f32, *value.lock()),] .spacing(QUARTER_SPACING) .into() } else { scrollable( column![ row![ jl_button!("Выбрать файл").on_press(SelectFile), text!("{}", { if let Some(path) = &self.path { path.to_string_lossy().to_string() } else { "Файл не выбран".to_string() } }) ] .align_y(Center) .spacing(DEFAULT_SPACING), self.selected_group() ] .spacing(DEFAULT_SPACING), ) .into() } }, Back, ) } } impl ImportState { fn selected_group(&self) -> Element<'_, ImportMessage> { if let Some(data) = &self.import_data { let group = &data.0[self.selected_index]; return column![ row![ self.back_button(), space().width(Fill), text!("{}", group.name.clone()), space().width(Fill), self.next_button() ] .width(Fill), text!("Количество карточек: {}", group.length), self.property_mapper(group), self.import_settings(), self.import_button(group), ] .spacing(DEFAULT_SPACING) .into(); } space().into() } fn import_button(&self, group: &ImportGroup) -> Element<'_, ImportMessage> { if group.imported { return container(text!("Группа слов успешно импортирована")) .padding(HALF_SPACING) .align_x(Center) .style(success) .width(Fill) .into(); } button("Начать импорт") .style(cta_button) .on_press(StartImport) .into() } fn property_mapper(&self, group: &ImportGroup) -> Element<'_, ImportMessage> { column![ self.property_list(group), rule::horizontal(2), self.property_selector() ] .spacing(HALF_SPACING) .into() } fn property_list(&self, group: &ImportGroup) -> Element<'_, ImportMessage> { let mut row = Row::new(); for prop in &group.fields { let primary_button_name = (*prop).clone(); if let Some(mapped) = group.mapping.get(prop.clone().as_str()) { row = row.push( column![ button(text!("{}", primary_button_name)), "↕", button(text!("{}", mapped)) .style(danger) .on_press(RemoveMapping(mapped.as_str().to_string())), ] .align_x(Center), ); } else { row = row.push( button(text!("{}", primary_button_name)) .on_press(OpenPropertyMapper(prop.clone())), ); } } scrollable(row.spacing(HALF_SPACING).padding(Padding { top: 0.0, right: 0.0, bottom: DEFAULT_SPACING, left: 0.0, })) .horizontal() .into() } fn property_selector(&self) -> Element<'_, ImportMessage> { if self.selected_property.is_none() { return space().into(); } column![ self.available_properties(), button("Добавить напрямую").on_press(SetupDirect), row![ text_input("Пользовательское поле", &self.custom_property_name) .on_input(EditCustomProperty), button("Установить привязку").on_press(SetupCustomProperty) ] .spacing(QUARTER_SPACING) ] .spacing(HALF_SPACING) .into() } fn available_properties(&self) -> Element<'_, ImportMessage> { let mut row = Row::new(); for default in DEFAULT_FIELDS { row = row.push( button(text!("{default}")).on_press(SetupDefaultProperty(default.to_string())), ); } row.spacing(HALF_SPACING).into() } fn import_settings(&self) -> Element<'_, ImportMessage> { column![ checkbox(self.skip_empty) .label("Пропускать пустые строки") .on_toggle(SwitchSkipEmpty), column![ text!("Разделитель при множественном объединении"), text_input("", &self.separator).on_input(EditSeparator) ] ] .spacing(DEFAULT_SPACING) .into() } fn back_button(&self) -> Element<'_, ImportMessage> { let mut button = button("←").style(button::text); if self.selected_index > 0 { button = button.on_press(PreviousGroup); } button.into() } fn next_button(&self) -> Element<'_, ImportMessage> { let mut button = button("→").style(button::text); if self.selected_index < self.import_data.as_ref().unwrap().0.len() - 1 { button = button.on_press(NextGroup); } button.into() } } impl ImportState { pub fn new(state: AppState) -> ImportState { ImportState { state, path: None, import_data: None, selected_index: 0, selected_property: None, custom_property_name: "".to_string(), skip_empty: true, separator: ", ".to_string(), progress: None, } } fn select_import_file() -> Task { Task::perform( async { let file = AsyncFileDialog::new() .add_filter("Anki", &["apkg"]) .pick_file() .await; if let Some(file) = file { return Some(file.path().into()); } None }, |result| { if let Some(path) = result { RootMessage::Import(UpdateFile(path)) } else { RootMessage::None } }, ) } pub fn load_package(&self) -> Task { let path = self.path.clone().unwrap(); Task::perform( async { spawn_blocking(move || { Self::extract_import_file(path)?; let data = Self::read_import_file()?; Ok(data) }) .await .unwrap() }, |result: Result| match result { Ok(import) => RootMessage::Import(UpdateImport(import)), Err(err) => push_error!(err), }, ) } fn extract_import_file(path: PathBuf) -> MaybeError { let file = File::open(path).map_err(|_| OpenFileError)?; let reader = BufReader::new(file); let mut archive = ZipArchive::new(reader).map_err(|_| OpenFileError)?; let temp_file_path = temp_import_path(); let mut temp_file = OpenOptions::new() .write(true) .create(true) .truncate(true) .open(&temp_file_path) .map_err(|_| OpenFileError)?; if let Ok(mut file) = archive.by_name("collection.anki21") { std::io::copy(&mut file, &mut temp_file).map_err(|_| OpenFileError)?; Ok(()) } else if let Ok(mut file) = archive.by_name("collection.anki2") { std::io::copy(&mut file, &mut temp_file).map_err(|_| OpenFileError)?; Ok(()) } else { Err(OpenFileError) } } fn read_import_file() -> Result { let temp_file_path = temp_import_path(); let connection = Connection::open(&temp_file_path).map_err(|_| OpenFileError)?; let data = load_groups(&connection)?; Ok(data) } fn setup_mapping(&mut self, property_name: String) { let group = self.import_data.as_mut().unwrap(); let group = &mut group.0[self.selected_index]; group .mapping .insert(self.selected_property.clone().unwrap(), property_name); self.selected_property = None; self.custom_property_name.clear(); } fn remove_mapping(&mut self, property_name: String) { let group = self.import_data.as_mut().unwrap(); let group = &mut group.0[self.selected_index]; let remove_key = group .mapping .keys() .find(|key| group.mapping[*key] == property_name) .unwrap(); group.mapping.remove(remove_key.clone().as_str()); self.selected_property = None; self.custom_property_name.clear(); } fn start_import(&self) -> Task { let state = self.state.clone(); let separator = self.separator.clone(); let skip_empty = self.skip_empty; let group = self.import_data.as_ref().unwrap().0[self.selected_index].clone(); let progress = self.progress.clone().unwrap(); info!("{}", group.name); Task::perform( async move { spawn_blocking(move || { let temp_file_path = temp_import_path(); let connection = Connection::open(&temp_file_path).map_err(|_| OpenFileError)?; let import_list = get_words_of_group(&connection, group.id)?; let mut words_list = Vec::with_capacity(import_list.len()); let map_indices = Self::get_mapping_indices(&group); let group_handle; { let group_entity = WordGroup { id: 0.into(), name: group.name.clone(), }; let mut groups_repo = state.word_groups.write(); group_handle = groups_repo.create_now(group_entity)?; } let group_id = group_handle.id; for import in import_list { let mut word = WordData::new(); word.tags = import.tags.trim().replace(" ", ", "); word.group_id = group_id; for (dest, indices) in &map_indices { let collected_string = Self::collect_strings( &import.fields, indices, &separator, skip_empty, ); match dest.as_str() { "key" => { word.key = collected_string; } "value" => { word.value = collected_string; } "tags" => { word.tags = collected_string; } &_ => { word.additional.insert((*dest).clone(), collected_string); } } } words_list.push(word); } let mut words_repo = state.dictionary.write(); let mut index = 0; let total_len = words_list.len() as f32 / 1024.0; for words in &mut words_list.chunks_mut(1024) { words_repo.create_now_many(words)?; index += 1; let mut progress = progress.lock(); *progress = (index as f32 / total_len).min(1.0); } Ok(()) }) .await .unwrap() }, |r: Result<(), NotificationData>| match r { Ok(_) => RootMessage::Import(ImportFinished), Err(err) => push_error!(err), }, ) } fn get_mapping_indices(group: &ImportGroup) -> Vec<(String, Vec)> { let mut final_props = group.mapping.values().cloned().collect::>(); final_props.sort(); final_props.dedup(); let mut result = final_props .iter() .map(|name| (name.clone(), Vec::::with_capacity(1))) .collect::>(); for (key, endpoint) in &group.mapping { let property_index = group .fields .iter() .position(|f| f == key) .expect("Unknown property"); let group_index = result .iter() .position(|(name, _)| name == endpoint) .unwrap(); result[group_index].1.push(property_index); } result } fn collect_strings( properties: &[String], indices: &[usize], separator: &str, skip_empty: bool, ) -> String { let mut working_words = Vec::with_capacity(indices.len()); for index in indices { let str = &properties[*index]; if skip_empty && str.is_empty() { continue; } working_words.push(str.as_str()); } working_words.join(separator) } fn next_progress(&self) -> Task { Task::perform( async { tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; }, |_| RootMessage::Import(NextProgress), ) } }