annatomic 0.4.0

The Annatomic annotation editor is intended to be used for the [RIDGES corpus](https://www.linguistik.hu-berlin.de/en/institut-en/professuren-en/korpuslinguistik/research/ridges-projekt). It is based on [graphANNIS](https://github.com/korpling/graphANNIS) and thus is internal data model is in principle suitable for a wide range of annotation concepts. "
Documentation
use std::collections::HashMap;

use annatto::{ReadFrom, importer::graphml::GraphMLImporter};
use anyhow::Context;
use egui::{Button, Color32, Id, InnerResponse, RichText, TextEdit, Widget};

use egui_file_dialog::FileDialog;
use facet::Facet;
use facet_reflect::peek_enum_variants;
use serde::{Deserialize, Serialize};

use crate::app::{messages::Notifier, util};

pub struct ImportConfigWidget<'a> {
    widget_id: Id,
    file_dialog: &'a mut FileDialog,
    notifier: &'a Notifier,
}

#[derive(Serialize, Deserialize, Clone)]
pub struct ImportConfigWidgetState {
    input_path: String,
    current_importer: annatto::ReadFrom,
    config_by_name: HashMap<String, String>,
    overwrite_corpus_name: bool,
    corpus_name: String,
    additional_config: String,
    config_error_message: Option<String>,
}

impl Default for ImportConfigWidgetState {
    fn default() -> Self {
        let graphml_config = GraphMLImporter::default();

        Self {
            input_path: String::default(),
            current_importer: annatto::ReadFrom::GraphML(graphml_config),
            overwrite_corpus_name: false,
            corpus_name: String::default(),
            additional_config: String::default(),
            config_by_name: HashMap::new(),
            config_error_message: None,
        }
    }
}

#[derive(Default)]
pub struct ImportConfigWidgetOutput {
    pub import_step: Option<annatto::ImporterStep>,
    pub overwrite_corpus_name: Option<String>,
}

impl<'a> ImportConfigWidget<'a> {
    pub fn new<I: Into<Id>>(
        widget_id: I,
        notifier: &'a Notifier,
        file_dialog: &'a mut FileDialog,
    ) -> Self {
        Self {
            widget_id: widget_id.into(),
            notifier,
            file_dialog,
        }
    }

    pub fn load_state(&self, ctx: &egui::Context) -> ImportConfigWidgetState {
        ctx.data_mut(|d| d.get_persisted(self.widget_id))
            .unwrap_or_default()
    }

    pub fn store_state(&self, ctx: &egui::Context, state: ImportConfigWidgetState) {
        ctx.data_mut(|d| d.insert_persisted(self.widget_id, state));
    }

    pub fn show(self, ui: &mut egui::Ui) -> InnerResponse<ImportConfigWidgetOutput> {
        let mut state = self.load_state(ui.ctx());

        let response = ui.vertical(|ui| {
            let mut output = ImportConfigWidgetOutput::default();

            let current_importer_name = state.current_importer.name().unwrap_or_default();
            let cb_response = egui::ComboBox::from_label("Format")
                .selected_text(&current_importer_name)
                .show_ui(ui, |ui| {
                    let mut result = None;

                    // Get all importer modules
                    for variant in peek_enum_variants(ReadFrom::SHAPE).unwrap_or_default() {
                        // Only include importer that have a default serialization value
                        if variant.data.fields.len() == 1
                            && variant.data.fields[0].shape().is_default()
                        {
                            let module_name = variant.name.to_lowercase();
                            let config = state
                                .config_by_name
                                .entry(module_name.clone())
                                .or_insert_with(|| {
                                    format!(
                                        "format=\"{module_name}\"\npath=\"{}\"\n\n[config]\n",
                                        &state.input_path.escape_default().to_string()
                                    )
                                });

                            if let Some(deserialized_module) = self.notifier.ok_or_report(
                                toml::from_str::<ReadFrom>(config).with_context(|| {
                                    format!("Creating module {module_name} failed")
                                }),
                            ) && ui
                                .selectable_value(
                                    &mut state.current_importer,
                                    deserialized_module,
                                    module_name,
                                )
                                .clicked()
                            {
                                result = Some(config.clone());
                            }
                        }
                    }

                    result
                });
            if let Some(Some(new_string)) = cb_response.inner
                && let Some(new_string) = self
                    .notifier
                    .ok_or_report(util::strip_module_header(&new_string))
            {
                state.additional_config = new_string;
            }

            ui.horizontal(|ui| {
                TextEdit::singleline(&mut state.input_path)
                    .hint_text("Input path")
                    .show(ui);
                if ui
                    .button("...")
                    .on_hover_text("Select the input path")
                    .clicked()
                {
                    match state.current_importer {
                        ReadFrom::GraphML(_) => self.file_dialog.pick_file(),
                        _ => self.file_dialog.pick_directory(),
                    }
                }
                if let Some(new_path) = self.file_dialog.take_picked() {
                    state.input_path = new_path.to_string_lossy().to_string();
                    if let Some(file_stem) = new_path.file_stem() {
                        state.corpus_name = file_stem.to_string_lossy().to_string();
                    }
                }
            });

            ui.hyperlink_to(
                format!("Documentation for \"{current_importer_name}\""),
                format!("https://github.com/korpling/annatto/blob/main/docs/importers/{current_importer_name}.md")
            );

            ui.label("Configuration");
            let txt_config = TextEdit::multiline(&mut state.additional_config)
                .code_editor()
                .desired_width(ui.available_width())
                .show(ui);
            if txt_config.response.changed() {
                state.config_error_message = match update_config_from_string(&mut state) {
                    Ok(_) => None,
                    Err(err) => Some(err.to_string()),
                };
            }

            if let Some(err) = &state.config_error_message {
                ui.label(RichText::new(err.to_string()).color(Color32::DARK_RED));
            }

            ui.horizontal(|ui| {
                ui.checkbox(&mut state.overwrite_corpus_name, "Overwrite corpus name");
                ui.add_enabled(
                    state.overwrite_corpus_name,
                    TextEdit::singleline(&mut state.corpus_name),
                );
            });

            if ui
                .add_enabled(
                    state.config_error_message.is_none() && !state.input_path.is_empty(),
                    Button::new("Start import"),
                )
                .clicked()
            {
                self.notifier
                    .ok_or_report(update_config_from_string(&mut state));
                // Create a workflow and return it for execution
                output.import_step = Some(annatto::ImporterStep::new(
                    state.current_importer.clone(),
                    state.input_path.clone(),
                ));
                if state.overwrite_corpus_name {
                    output.overwrite_corpus_name = Some(state.corpus_name.clone());
                }
            }

            output
        });

        self.store_state(ui.ctx(), state);

        response
    }
}

fn update_config_from_string(state: &mut ImportConfigWidgetState) -> anyhow::Result<()> {
    let current_importer_name = state.current_importer.name()?;

    // Add the format and path to the string
    let full_string = format!(
        "format=\"{}\"\npath=\"{}\"\n\n[config]\n{}",
        current_importer_name,
        &state.input_path.escape_default().to_string(),
        &state.additional_config
    );

    state.current_importer = toml::from_str(&full_string)?;
    state
        .config_by_name
        .insert(current_importer_name, full_string);

    Ok(())
}

impl<'a> Widget for ImportConfigWidget<'a> {
    fn ui(self, ui: &mut egui::Ui) -> egui::Response {
        self.show(ui).response
    }
}