diff --git a/dtool_lookup_gui/views/main_window.py b/dtool_lookup_gui/views/main_window.py index 2a31f8f..95f0c39 100644 --- a/dtool_lookup_gui/views/main_window.py +++ b/dtool_lookup_gui/views/main_window.py @@ -266,6 +266,30 @@ def __init__(self, *args, **kwargs): # window-scoped actions + # select base uri row by row index + row_index_variant = GLib.Variant.new_uint32(0) + select_base_uri_action = Gio.SimpleAction.new("select-base-uri", row_index_variant.get_type()) + select_base_uri_action.connect("activate", self.do_select_base_uri_row_by_row_index) + self.add_action(select_base_uri_action) + + # select base uri row by uri + uri_variant = GLib.Variant.new_string('dummy') + select_base_uri_by_uri_action = Gio.SimpleAction.new("select-base-uri-by-uri", uri_variant.get_type()) + select_base_uri_by_uri_action.connect("activate", self.do_select_base_uri_row_by_uri) + self.add_action(select_base_uri_by_uri_action) + + # show base uri row by row index + row_index_variant = GLib.Variant.new_uint32(0) + show_base_uri_action = Gio.SimpleAction.new("show-base-uri", row_index_variant.get_type()) + show_base_uri_action.connect("activate", self.do_show_base_uri_row_by_row_index) + self.add_action(show_base_uri_action) + + # show base uri row by uri + uri_variant = GLib.Variant.new_string('dummy') + show_base_uri_by_uri_action = Gio.SimpleAction.new("show-base-uri-by-uri", uri_variant.get_type()) + show_base_uri_by_uri_action.connect("activate", self.do_show_base_uri_row_by_uri) + self.add_action(show_base_uri_by_uri_action) + # search action search_text_variant = GLib.Variant.new_string("dummy") search_action = Gio.SimpleAction.new("search", search_text_variant.get_type()) @@ -366,844 +390,889 @@ def __init__(self, *args, **kwargs): self.linting_problems = None self.linting_errors_button.set_sensitive(False) - # utility methods - def refresh(self): - """Refresh view.""" + # actions - dataset_row = self.dataset_list_box.get_selected_row() - dataset_uri = None - if dataset_row is not None: - dataset_uri = dataset_row.dataset.uri - _logger.debug(f"Keep '{dataset_uri}' for dataset refresh.") + # dataset selection actions + def do_select_dataset_row_by_row_index(self, action, value): + """Select dataset row by index.""" + row_index = value.get_uint32() + self._select_dataset_row_by_row_index(row_index) - async def _refresh(): - # first, refresh base uri list and its selection - await self._refresh_base_uri_list_box() - self._select_and_load_first_uri() + def do_select_dataset_row_by_uri(self, action, value): + """Select dataset row by uri.""" + uri = value.get_string() + self._select_dataset_row_by_uri(uri) - _logger.debug(f"Done refreshing base URIs.") - # on_base_uri_selected(self, list_box, row) called by selection - # above already + def do_show_dataset_details_by_row_index(self, action, value): + """Show dataset details by row index.""" + row_index = value.get_uint32() + self._show_dataset_details_by_row_index(row_index) - # TODO: following restration of selected dataset needs to happen - # after base URI has been loaded, but on_base_uri_selected - # spawns another task, hence above "await" won't wait for the - # process to complete. Need a signal insted. - # if dataset_uri is not None: - # _logger.debug(f"Select and show '{dataset_uri}'.") - # self._select_and_show_by_uri(dataset_uri) + def do_build_dependency_graph_by_row_index(self, action, value): + """Build the dependency graph by row index.""" + row_index = value.get_uint32() + self._build_dependency_graph_by_row_index(row_index) - asyncio.create_task(_refresh()) + def do_show_dataset_details_by_uri(self, action, value): + """Show dataset details by uri.""" + uri = value.get_string() + self._show_dataset_details_by_uri(uri) - async def _refresh_base_uri_list_box(self): - # bookkeeping of current state - base_uri_row = self.base_uri_list_box.get_selected_row() - base_uri = None + def do_build_dependency_graph_by_uri(self, action, value): + """Build the dependency graph by uri.""" + uri = value.get_string() + self._build_dependency_graph_by_uri(uri) - if isinstance(base_uri_row, DtoolBaseURIRow): - base_uri = str(base_uri_row.base_uri) - elif isinstance(base_uri_row, DtoolSearchResultsRow): - base_uri = LOOKUP_BASE_URI + # search actions + def do_search(self, action, value): + """Evoke search tas for specific search text.""" + search_text = value.get_string() + self._search(search_text) - # first, refresh list box - await self.base_uri_list_box.refresh() - # second, refresh base uri list selection - if base_uri is not None: - _logger.debug(f"Reselect base URI '{base_uri}") - self._select_base_uri_row_by_uri(base_uri) + def do_search_select_and_show(self, action, value): + """Evoke search task for specific search text, select and show 1st row of resuls subsequntly.""" + search_text = value.get_string() + self._search_select_and_show(search_text) - # removed these utility functions from inner scope of on_search_activate - # in order to decouple actual signal handler and functionality - def _update_search_summary(self, datasets): - row = self.base_uri_list_box.search_results_row - total_value = self.search_state.total_number_of_entries - row.info_label.set_text(f'{total_value} datasets') + # base uri selection actions + def do_select_base_uri_row_by_row_index(self, action, value): + """Select base uri row by index.""" + row_index = value.get_uint32() + self._select_base_uri_row_by_row_index(row_index) - def _update_main_statusbar(self, datasets): - total_number = self.search_state.total_number_of_entries - current_page = self.search_state.current_page - last_page = self.search_state.last_page - page_size = self.search_state.page_size - total_size = sum([0 if dataset.size_int is None else dataset.size_int for dataset in datasets]) - self.main_statusbar.push(0, - f"{total_number} datasets in total at {page_size} per page, " - f"{sizeof_fmt(total_size).strip()} total size of {len(datasets)} datasets on current page, " - f"on page {current_page} of {last_page}") + def do_select_base_uri_row_by_uri(self, action, value): + """Select base uri row by uri.""" + uri = value.get_string() + self._select_base_uri_row_by_uri(uri) - async def _fetch_search_results(self, on_show=None): - """Retrieve search results from lookup server.""" + def do_show_base_uri_row_by_row_index(self, action, value): + """Show base uri by row index""" + row_index = value.get_uint32() + self._show_base_uri_row_by_row_index(row_index) - self._disable_pagination_buttons() + def do_show_base_uri_row_by_uri(self, action, value): + """Show base uri by uri""" + uri = value.get_string() + self._show_base_uri_row_by_row_index(uri) - # Here sort order 1 implies ascending - row = self.base_uri_list_box.search_results_row - row.start_spinner() - self.main_spinner.start() + # pagination actions + def do_show_page(self, action, value): + """Show page of specific index""" + page_index = value.get_uint32() + self._show_page(page_index) - pagination = {} - sorting = {} - try: - if self.search_state.search_text: - if is_valid_query(self.search_state.search_text): - _logger.debug("Valid query specified.") - datasets = await DatasetModel.get_datasets_by_mongo_query( - query=self.search_state.search_text, - page_number=self.search_state.current_page, - page_size=self.search_state.page_size, - sort_fields=self.search_state.sort_fields, - sort_order=self.search_state.sort_order, - pagination=pagination, - sorting=sorting + def do_show_current_page(self, action, value): + """Show current page""" + page_index = self.search_state.current_page + self._show_page(page_index) - ) - else: - _logger.debug("Specified search text is not a valid query, just perform free text search.") - datasets = await DatasetModel.get_datasets( - free_text=self.search_state.search_text, - page_number=self.search_state.current_page, - page_size=self.search_state.page_size, - sort_fields=self.search_state.sort_fields, - sort_order=self.search_state.sort_order, - pagination=pagination, - sorting=sorting - ) - else: - _logger.debug("No keyword specified, list all datasets.") - datasets = await DatasetModel.get_datasets( - page_number=self.search_state.current_page, - page_size=self.search_state.page_size, - sort_fields=self.search_state.sort_fields, - sort_order=self.search_state.sort_order, - pagination=pagination, - sorting=sorting - ) + def do_show_first_page(self, action, value): + """Show first page""" + page_index = self.search_state.first_page + self._show_page(page_index) - # if pagination == {}: # server did not provide pagination information - # pagination = { - # 'total': len(datasets), - # 'page': 1, - # 'last_page': 1 - # } + def do_show_last_page(self, action, value): + """Show last page""" + page_index = self.search_state.last_page + self._show_page(page_index) - self.search_state.ingest_pagination_information(pagination) - self.search_state.ingest_sorting_information(sorting) + def do_show_next_page(self, action, value): + """Show next page""" + page_index = self.search_state.next_page + self._show_page(page_index) - if len(datasets) > self._max_nb_datasets: - _logger.warning( - f"{len(datasets)} search results exceed allowed displayed maximum of {self._max_nb_datasets}. " - f"Only the first {self._max_nb_datasets} results are shown. Narrow down your search." - ) - datasets = datasets[:self._max_nb_datasets] # Limit number of datasets that are shown + def do_show_previous_page(self, action, value): + """Show previous page""" + page_index = self.search_state.previous_page + self._show_page(page_index) - row.search_results = datasets # Cache datasets + # other actions + def do_get_item(self, action, value): + """"Copy currently selected manifest item in currently selected dataset to specified destination.""" - self._update_search_summary(datasets) - self._update_main_statusbar(datasets) + dest_file = value.get_string() - if self.base_uri_list_box.get_selected_row() == row: - # Only update if the row is still selected - self.dataset_list_box.fill(datasets, on_show=on_show) - except RuntimeError as e: - # TODO: There should probably be a more explicit test on authentication failure. - self.show_error(e) + dataset = self.dataset_list_box.get_selected_row().dataset - async def retry(): - await asyncio.sleep(0.5) # TODO: This is a dirty workaround for not having the login window pop up twice - await self._fetch_search_results(on_show) + items = self._get_selected_items() + if len(items) != 1: + raise ValueError("Can only get one item at a time.") + item_name, item_uuid = items[0] - # What happens is that the LoginWindow evokes the renew-token action via Gtk framework. - # This happens asynchronously as well. This means _fetch_search_results called again - # within the retry() function would open another LoginWindow here as the token renewal does - # not happen "quick" enough. Hence there is the asyncio.sleep(1). - LoginWindow(application=self.application, follow_up_action=lambda: asyncio.create_task(retry())).show() + async def _get_item(dataset, item_uuid): + cached_file = await dataset.get_item(item_uuid) + shutil.copyfile(cached_file, dest_file) - except Exception as e: - self.show_error(e) + if settings.open_downloaded_item: + # try to launch default application for downloaded item if desired + _logger.debug("Try to open '%s' with default application.", dest_file) + launch_default_app_for_uri(dest_file) - self.base_uri_list_box.select_search_results_row() - self.main_stack.set_visible_child(self.main_paned) - row.stop_spinner() - self.main_spinner.stop() + asyncio.create_task(_get_item(dataset, item_uuid)) - self._enable_pagination_buttons() - self._update_pagination_buttons() + def do_refresh_view(self, action, value): + """Refresh view by reloading base uri list, """ + self.refresh() - def _search_by_uuid(self, uuid): - search_text = dump_single_line_query_text({"uuid": uuid}) - self._search_by_search_text(search_text) + # signal handlers - def _search_by_search_text(self, search_text): - self.activate_action('search-select-show', GLib.Variant.new_string(search_text)) + @Gtk.Template.Callback() + def on_settings_clicked(self, widget): + """Setting menu item clicked.""" + self.settings_dialog.show() - # utility methods - dataset selection - def _select_dataset_row_by_row_index(self, index): - """Select dataset row in dataset list box by index.""" - row = self.dataset_list_box.get_row_at_index(index) - if row is not None: - _logger.debug(f"Dataset row {index} selected.") - self.dataset_list_box.select_row(row) - else: - _logger.info(f"No dataset row with index {index} available for selection.") + @Gtk.Template.Callback() + def version_button_clicked(self, widget): + """Server versions menu item clicked.""" + self.server_versions_dialog.show() - def _select_dataset_row_by_uri(self, uri): - """Select dataset row in dataset list box by uri.""" - index = self.dataset_list_box.get_row_index_from_uri(uri) - self._select_dataset_row_by_row_index(index) + @Gtk.Template.Callback() + def config_button_clicked(self, widget): + """Server config menu item clicked.""" + self.config_details.show() - def _show_dataset_details(self, dataset): - """Kick off asynchronous task to show dataset details.""" - asyncio.create_task(self._update_dataset_view(dataset)) - self.dataset_stack.set_visible_child(self.dataset_box) + @Gtk.Template.Callback() + def on_logging_clicked(self, widget): + """Log window menu item clicked.""" + self.log_window.show() - def _build_dependency_graph(self, dataset): - """Kick off asynchronous task to build dependency graph.""" - asyncio.create_task(self._compute_dependencies(dataset)) + @Gtk.Template.Callback() + def on_about_clicked(self, widget): + """About dialog menu item clicked""" + self.about_dialog.show() - def _show_dataset_details_by_row_index(self, index): - """Show dataset details by row index.""" - row = self.dataset_list_box.get_row_at_index(index) + @Gtk.Template.Callback() + def on_base_uri_selected(self, list_box, row): + """Entry on base URI list clicked.""" if row is not None: - _logger.debug(f"{row.dataset.name} shown.") - self._show_dataset_details(row.dataset) + row_index = row.get_index() + _logger.debug(f"Selected base uri row {row_index}.") + self.activate_action('show-base-uri', GLib.Variant.new_uint32(row_index)) + + @Gtk.Template.Callback() + def on_search_activate(self, widget): + """Search activated (usually by hitting Enter after typing in the search entry).""" + search_text = self.search_entry.get_text() + self.activate_action('search-select-show', GLib.Variant.new_string(search_text)) + + @Gtk.Template.Callback() + def on_search_drop_down_clicked(self, widget): + """Drop down button next to search field clicked for opening larger popover.""" + if self.search_popover.get_visible(): + _logger.debug( + f"Search entry drop down icon pressed, hide popover.") + self.search_popover.popdown() else: - _logger.info(f"No dataset row with index {index} available for selection.") + _logger.debug(f"Search entry drop down icon pressed, show popover.") + self.search_popover.popup_at(widget) - def _build_dependency_graph_by_row_index(self, index): - """Build dependency graph by row index.""" - row = self.dataset_list_box.get_row_at_index(index) + @Gtk.Template.Callback() + def on_dataset_selected(self, list_box, row): + """Entry on dataset list clicked.""" if row is not None: - _logger.debug(f"{row.dataset.name} shown.") - self._build_dependency_graph(row.dataset) - else: - _logger.info(f"No dataset row with index {index} available for selection.") + row_index = row.get_index() + _logger.debug(f"Selected row {row_index}.") + self.activate_action('show-dataset', GLib.Variant.new_uint32(row_index)) - def _show_dataset_details_by_uri(self, uri): - """Select dataset row in dataset list box by uri.""" - index = self.dataset_list_box.get_row_index_from_uri(uri) - self._show_dataset_details_by_row_index(index) + @Gtk.Template.Callback() + def on_open_local_directory_clicked(self, widget): + """Open directory button as local base URI clicked.""" + # File chooser dialog (select directory) + dialog = Gtk.FileChooserDialog( + title="Open local directory", + parent=self, + action=Gtk.FileChooserAction.SELECT_FOLDER + ) + dialog.add_buttons( + Gtk.STOCK_CANCEL, + Gtk.ResponseType.CANCEL, + Gtk.STOCK_OPEN, + Gtk.ResponseType.OK, + ) - def _build_dependency_graph_by_uri(self, uri): - """Build dependency graph by uri.""" - index = self.dataset_list_box.get_row_index_from_uri(uri) - self._build_dependency_graph_by_row_index(index) + # Attention: Avoid run method! + # Unlike GLib, Python does not support running the EventLoop recursively. + # Gbulb uses the GLib event loop, hence this works. If we move to another + # implementation (e.g. https://gitlab.gnome.org/GNOME/pygobject/-/merge_requests/189) + # that uses the asyncio event loop this will break. + response = dialog.run() + if response == Gtk.ResponseType.OK: + # Quote from https://athenajc.gitbooks.io/python-gtk-3-api/content/gtk-group/gtkfilechooser.html: + # + # When the user is finished selecting files in a Gtk.FileChooser, your program can get the selected names + # either as filenames or as URIs. For URIs, the normal escaping rules are applied if the URI contains + # non-ASCII characters. + # + # However, filenames are always returned in the character set specified by the G_FILENAME_ENCODING + # environment variable. + # + # This means that while you can pass the result of Gtk.FileChooser::get_filename() to open() or fopen(), + # you may not be able to directly set it as the text of a Gtk.Label widget unless you convert it first to + # UTF-8, which all GTK+ widgets expect. You should use g_filename_to_utf8() to convert filenames into + # strings that can be passed to GTK+ widgets. + uri, = dialog.get_uris() + # For using URI scheme on local paths, we have to unquote characters to be + uri = urllib.parse.unquote(uri, encoding='utf-8', errors='replace') + # Add directory to local inventory + try: + LocalBaseURIModel.add_directory(uri) + except ValueError as err: + _logger.warning(str(err)) + elif response == Gtk.ResponseType.CANCEL: + uri = None + dialog.destroy() - def _select_and_show_by_row_index(self, index=0): - """Select dataset entry by row index and show details.""" - self._select_dataset_row_by_row_index(index) - self._show_dataset_details_by_row_index(index) + # Refresh view of base URIs + asyncio.create_task(self._refresh_base_uri_list_box()) - def _select_and_show_by_uri(self, uri): - """Select dataset entry by URI and show details.""" - self._select_dataset_row_by_uri(uri) - self._show_dataset_details_by_uri(uri) + @Gtk.Template.Callback() + def on_create_dataset_clicked(self, widget): + """Dataset creation button clicked.""" + DatasetNameDialog(on_confirmation=self._create_dataset).show() - def _search(self, search_text, on_show=None): - """Get datasets by text search.""" - self.search_state.search_text = search_text - self.search_state.reset_pagination() - # self.search_state.current_page = 1 - self._refresh_datasets(on_show=on_show) + @Gtk.Template.Callback() + def on_refresh_clicked(self, widget): + """Refresh button clicked.""" + self.get_action_group("win").activate_action('refresh-view', None) - def _refresh_datasets(self, on_show=None): - """Reset dataset list, show spinner, and kick off async task for retrieving dataset entries.""" - self.main_stack.set_visible_child(self.main_spinner) - row = self.base_uri_list_box.search_results_row - row.search_results = None - asyncio.create_task(self._fetch_search_results(on_show=on_show)) + @Gtk.Template.Callback() + def on_show_clicked(self, widget): + uri = str(self.dataset_list_box.get_selected_row().dataset) + launch_default_app_for_uri(uri) - def _search_select_and_show(self, search_text): - """Get datasets by text search, select first row and show dataset details.""" - _logger.debug(f"Search '{search_text}'...") - self._search(search_text, on_show=lambda _: self._select_and_show_by_row_index()) + @Gtk.Template.Callback() + def on_add_items_clicked(self, widget): + """Add items to dataset button clicked.""" + dialog = Gtk.FileChooserDialog( + title="Add items", parent=self, + action=Gtk.FileChooserAction.OPEN + ) + dialog.add_buttons( + Gtk.STOCK_CANCEL, + Gtk.ResponseType.CANCEL, + Gtk.STOCK_OPEN, + Gtk.ResponseType.OK, + ) + dialog.set_select_multiple(True) - # pagination functionality - def _show_page(self, page_index): - """Get datasets by page, select first row and show dataset details.""" - if not self.search_state.fetching_results: - self.search_state.current_page = page_index - # self._disable_pagination_buttons() - self._refresh_datasets(on_show=lambda _: self._select_and_show_by_row_index()) - # asyncio.create_task(self._fetch_search_results()) - # self._update_pagination_buttons(page_number, widget) + # Attention: Avoid run method! + # Unlike GLib, Python does not support running the EventLoop recursively. + # Gbulb uses the GLib event loop, hence this works. If we move to another + # implementation (e.g. https://gitlab.gnome.org/GNOME/pygobject/-/merge_requests/189) + # that uses the asyncio event loop this will break. + response = dialog.run() + if response == Gtk.ResponseType.OK: + uris = dialog.get_uris() + fpaths = dialog.get_filenames() + for fpath in fpaths: + # uri = urllib.parse.unquote(uri, encoding='utf-8', errors='replace') + self._add_item(fpath) + elif response == Gtk.ResponseType.CANCEL: + pass + dialog.destroy() - def _update_pagination_buttons(self): - """Update pagination buttons to match current search state.""" + @Gtk.Template.Callback() + def on_manifest_row_activated(self, tree_view, path, column): + """Handler for "row-activated" signal. - self.current_page_button.set_label(str(self.search_state.current_page)) - self.next_page_button.set_label(str(self.search_state.next_page)) - self.previous_page_button.set_label(str(self.search_state.previous_page)) + Signal emitted when the method gtk-tree-view-row-activated is called or the user double clicks a treeview row. + It is also emitted when a non-editable row is selected and one of the keys: Space, Shift+Space, Return or Enter + is pressed. (https://www.gnu.org/software/guile-gnome/docs/gtk/html/GtkTreeView.html)""" - if self.search_state.current_page >= self.search_state.last_page: - self.next_page_button.set_visible(False) - else: - self.next_page_button.set_visible(True) + items = self._get_selected_items() + if len(items) != 1: + raise ValueError("Can only get one item at a time.") + item_name, item_uuid = items[0] + self._show_get_item_dialog(item_name, item_uuid) - if self.search_state.current_page <= self.search_state.first_page: - self.previous_page_button.set_visible(False) - else: - self.previous_page_button.set_visible(True) + @Gtk.Template.Callback() + def on_save_metadata_button_clicked(self, widget): + """Save button on edited metadata clicked.""" + # Get the YAML content from the source view + text_buffer = self.readme_source_view.get_buffer() + start_iter, end_iter = text_buffer.get_bounds() + yaml_content = text_buffer.get_text(start_iter, end_iter, True) - def _disable_pagination_buttons(self): - """Disable all pagination buttons (typically while fetching results)""" - self.fetching_results = True - self.first_page_button.set_sensitive(False) - self.next_page_button.set_sensitive(False) - self.last_page_button.set_sensitive(False) - self.previous_page_button.set_sensitive(False) - self.current_page_button.set_sensitive(False) - self.decrease_page_button.set_sensitive(False) - self.increase_page_button.set_sensitive(False) - def _enable_pagination_buttons(self): - """Enable all pagination buttons (typically after fetching results)""" - self.fetching_results = False - self.first_page_button.set_sensitive(True) - self.next_page_button.set_sensitive(True) - self.last_page_button.set_sensitive(True) - self.previous_page_button.set_sensitive(True) - self.current_page_button.set_sensitive(True) - self.decrease_page_button.set_sensitive(True) - self.increase_page_button.set_sensitive(True) + # Check the state of the linting switch before linting + if settings.yaml_linting_enabled: + # Lint the YAML content if the above condition wasn't met (i.e., linting is enabled) + conf = YamlLintConfig('extends: default') # using the default config + self.linting_problems = list(yamllint.linter.run(yaml_content, conf)) # Make it an instance variable + _logger.debug(str(self.linting_problems)) + total_errors = len(self.linting_problems) + if total_errors > 0: + self.linting_errors_button.set_sensitive(True) + if total_errors == 1: + error_message = f"YAML Linter Error:\n{str(self.linting_problems[0])}" + else: + other_errors_count = total_errors - 1 # since we're showing the first error + error_message = f"YAML Linter Error:\n{str(self.linting_problems[0])} and {other_errors_count} other YAML linting errors.\nClick here for more details" + self.linting_errors_button.set_label(error_message) + else: + self.linting_errors_button.set_label("No linting issues found!") + self.dataset_list_box.get_selected_row().dataset.put_readme(yaml_content) + else: - # other helper functions - def _get_selected_items(self): - """Returns (name uuid) tuples of items selected in manifest tree store.""" - selection = self.manifest_tree_view.get_selection() - model, paths = selection.get_selected_rows() + # Clear previous linting problems when linting is turned off + self.linting_problems = None + self.linting_errors_button.set_label("YAML linting turned off.") - items = [] - for path in paths: - column_iter = model.get_iter(path) - item_name = model.get_value(column_iter, 0) - item_uuid = model.get_value(column_iter, 3) - items.append((item_name, item_uuid)) + _logger.debug("YAML linting turned off.") + self.dataset_list_box.get_selected_row().dataset.put_readme(yaml_content) - return items + @Gtk.Template.Callback() + def on_linting_errors_button_clicked(self, widget): + """Linting errors clicked, show extended log.""" + # Check if the problems attribute exists + if hasattr(self, 'linting_problems') and self.linting_problems: + # Join the linting error messages into a single string + error_text = '\n\n'.join(str(problem) for problem in self.linting_problems) - # utility methods - base uri selection - def _select_base_uri_row_by_row_index(self, index): - """Select base uri row in base uri list box by index.""" - row = self.base_uri_list_box.get_row_at_index(index) - if row is not None: - _logger.debug(f"Base URI row {index} selected.") - self.base_uri_list_box.select_row(row) + # Set the linting error text to the dialog + self.error_linting_dialog.set_error_text(error_text) + + # Show the dialog + self.error_linting_dialog.show() else: - _logger.info(f"No base URI row with index {index} available for selection.") + pass - def _select_base_uri_row_by_uri(self, uri): - """Select base uri row in dataset list box by uri.""" - index = self.base_uri_list_box.get_row_index_from_uri(uri) - self._select_base_uri_row_by_row_index(index) + @Gtk.Template.Callback() + def on_freeze_clicked(self, widget): + """Freeze dataset button clicked.""" + row = self.dataset_list_box.get_selected_row() + dialog = Gtk.MessageDialog(self, Gtk.DialogFlags.MODAL, Gtk.MessageType.QUESTION, Gtk.ButtonsType.OK_CANCEL, + f'You are about to freeze dataset "{row.dataset.name}". Items can no longer be ' + 'added, removed or modified after freezing the dataset. (You will still be able to ' + 'edit the metadata README.yml.) Please confirm freezing of this dataset.') + # Attention: Avoid run method! + # Unlike GLib, Python does not support running the EventLoop recursively. + # Gbulb uses the GLib event loop, hence this works. If we move to another + # implementation (e.g. https://gitlab.gnome.org/GNOME/pygobject/-/merge_requests/189) + # that uses the asyncio event loop this will break. + response = dialog.run() + dialog.destroy() + if response == Gtk.ResponseType.OK: + uri = row.dataset.uri # URI won't change in freeze process + row.freeze() + self.dataset_list_box.show_all() + self.get_action_group("win").activate_action('select-dataset-by-uri', GLib.Variant.new_string(uri)) + self.get_action_group("win").activate_action('show-dataset-by-uri', GLib.Variant.new_string(uri)) - def _select_and_load_first_uri(self): - """ - This function automatically reloads the data and selects the first URI. - """ - first_row = self.base_uri_list_box.get_children()[0] - self.base_uri_list_box.select_row(first_row) - self.on_base_uri_selected(self.base_uri_list_box, first_row) + @Gtk.Template.Callback() + def on_error_bar_close(self, widget): + """Close error bar button clicked.""" + _logger.debug("Hide error bar.") + self.error_bar.set_revealed(False) - # actions + @Gtk.Template.Callback() + def on_error_bar_response(self, widget, response_id): + if response_id == Gtk.ResponseType.CLOSE: + self.error_bar.set_revealed(False) - # dataset selection actions - def do_select_dataset_row_by_row_index(self, action, value): - """Select dataset row by index.""" - row_index = value.get_uint32() - self._select_dataset_row_by_row_index(row_index) + # sort signal handlers + # @Gtk.Template.Callback() + # def on_sort_field_combo_box_changed(self, widget): + # sort_field = widget.get_active_text() + # _logger.debug("sort field changed to %s", sort_field) - def do_select_dataset_row_by_uri(self, action, value): - """Select dataset row by uri.""" - uri = value.get_string() - self._select_dataset_row_by_uri(uri) + @Gtk.Template.Callback() + def on_sort_order_switch_state_set(self, widget, state): + """Sort order ascending&/descending switch toggled.""" + # Toggle sort order based on the switch state + if state: + sort_order = -1 # Switch is on, use ascending order + else: + sort_order = 1 # Switch is off, use descending order - def do_show_dataset_details_by_row_index(self, action, value): - """Show dataset details by row index.""" - row_index = value.get_uint32() - self._show_dataset_details_by_row_index(row_index) + self.search_state.sort_order = [sort_order] + self.activate_action('show-current-page') + # self.on_sort_field_combo_box_changed(self.sort_field_combo_box) - def do_build_dependency_graph_by_row_index(self, action, value): - """Build the dependency graph by row index.""" - row_index = value.get_uint32() - self._build_dependency_graph_by_row_index(row_index) + @Gtk.Template.Callback() + def on_contents_per_page_combo_box_changed(self, widget): + """Contents per page combo box entry changed.""" + # Get the active iter and retrieve the key from the first column + model = widget.get_model() + active_iter = widget.get_active_iter() + if active_iter is not None: + selected_key = model[active_iter][0] # This is the key - def do_show_dataset_details_by_uri(self, action, value): - """Show dataset details by uri.""" - uri = value.get_string() - self._show_dataset_details_by_uri(uri) + self.search_state.page_size = selected_key + self.activate_action('show-first-page') - def do_build_dependency_graph_by_uri(self, action, value): - """Build the dependency graph by uri.""" - uri = value.get_string() - self._build_dependency_graph_by_uri(uri) + @Gtk.Template.Callback() + def on_sort_field_combo_box_changed(self, widget): + """Sort field combo box entry changed.""" + # Get the active iter and retrieve the key from the first column + model = widget.get_model() + active_iter = widget.get_active_iter() + if active_iter is not None: + selected_key = model[active_iter][0] # This is the key - # search actions - def do_search(self, action, value): - """Evoke search tas for specific search text.""" - search_text = value.get_string() - self._search(search_text) + self.search_state.sort_fields = [selected_key] + self.activate_action('show-current-page') - def do_search_select_and_show(self, action, value): - """Evoke search task for specific search text, select and show 1st row of resuls subsequntly.""" - search_text = value.get_string() - self._search_select_and_show(search_text) + # pagination signal handlers - # base uri selection actions - def do_select_base_uri_row_by_row_index(self, action, value): - """Select base uri row by index.""" - row_index = value.get_uint32() - self._select_base_uri_row_by_row_index(row_index) + @Gtk.Template.Callback() + def on_first_page_button_clicked(self, widget): + """Navigate to the first page""" + self.activate_action('show-first-page') - def do_select_base_uri_row_by_uri(self, action, value): - """Select base uri row by uri.""" - uri = value.get_string() - self._select_base_uri_row_by_uri(uri) + @Gtk.Template.Callback() + def on_decrease_page_button_clicked(self, widget): + """Navigate to the previous page if it exists""" + self.activate_action('show-previous-page') - def do_show_base_uri_by_row_index(self, action, value): - """Show base uri by row index""" - row_index = value.get_uint32() - self._show_base_uri_by_row_index(row_index) + @Gtk.Template.Callback() + def on_previous_page_button_clicked(self, widget): + """Navigate to the previous page if it exists""" + self.activate_action('show-previous-page') - def do_show_baser_uri_by_uri(self, action, value): - """Show base uri by uri.""" - uri = value.get_string() - self._show_base_uri_details_by_uri(uri) + @Gtk.Template.Callback() + def on_current_page_button_clicked(self, widget): + """Highlight the current page button and fetch its results""" + style_context = self.current_page_button.get_style_context() + style_context.add_class('suggested-action') + self.activate_action('show-current-page') - # pagination actions - def do_show_page(self, action, value): - """Show page of specific index""" - page_index = value.get_uint32() - self._show_page(page_index) + @Gtk.Template.Callback() + def on_next_page_button_clicked(self, widget): + # Navigate to the next page if available + self.activate_action('show-next-page') - def do_show_current_page(self, action, value): - """Show current page""" - page_index = self.search_state.current_page - self._show_page(page_index) + @Gtk.Template.Callback() + def on_increase_page_button_clicked(self, widget): + """Navigate to the next page uif it exists""" + self.activate_action('show-next-page') - def do_show_first_page(self, action, value): - """Show first page""" - page_index = self.search_state.first_page - self._show_page(page_index) + @Gtk.Template.Callback() + def on_last_page_button_clicked(self, widget): + """Navigate to the last page""" + self.activate_action('show-last-page') - def do_show_last_page(self, action, value): - """Show last page""" - page_index = self.search_state.last_page - self._show_page(page_index) + def on_readme_buffer_changed(self, buffer): + self.save_metadata_button.set_sensitive(True) - def do_show_next_page(self, action, value): - """Show next page""" - page_index = self.search_state.next_page - self._show_page(page_index) + # TODO: this should be an action do_copy + # if it is possible to hand two strings, e.g. source and destination to an action, then this action should + # go to the main app. + # @Gtk.Template.Callback(), not in .ui + def on_copy_clicked(self, widget): + """Dataset copy button clicked.""" + async def _copy(): + try: + await self._copy_manager.copy(self.dataset_list_box.get_selected_row().dataset, widget.destination) + except Exception as e: + self.show_error(e) - def do_show_previous_page(self, action, value): - """Show previous page""" - page_index = self.search_state.previous_page - self._show_page(page_index) + asyncio.create_task(_copy()) - # other actions - def do_get_item(self, action, value): - """"Copy currently selected manifest item in currently selected dataset to specified destination.""" + # public methods - dest_file = value.get_string() + def refresh(self): + """Refresh view.""" - dataset = self.dataset_list_box.get_selected_row().dataset + dataset_row = self.dataset_list_box.get_selected_row() + dataset_uri = None + if dataset_row is not None: + dataset_uri = dataset_row.dataset.uri + _logger.debug(f"Keep '{dataset_uri}' for dataset refresh.") - items = self._get_selected_items() - if len(items) != 1: - raise ValueError("Can only get one item at a time.") - item_name, item_uuid = items[0] + async def _refresh(): + # first, refresh base uri list and its selection + await self._refresh_base_uri_list_box() + self._select_and_load_first_uri() - async def _get_item(dataset, item_uuid): - cached_file = await dataset.get_item(item_uuid) - shutil.copyfile(cached_file, dest_file) + _logger.debug(f"Done refreshing base URIs.") + # on_base_uri_selected(self, list_box, row) called by selection + # above already - if settings.open_downloaded_item: - # try to launch default application for downloaded item if desired - _logger.debug("Try to open '%s' with default application.", dest_file) - launch_default_app_for_uri(dest_file) + # TODO: following restoration of selected dataset needs to happen + # after base URI has been loaded, but on_base_uri_selected + # spawns another task, hence above "await" won't wait for the + # process to complete. Need a signal instead. + # if dataset_uri is not None: + # _logger.debug(f"Select and show '{dataset_uri}'.") + # self._select_and_show_by_uri(dataset_uri) - asyncio.create_task(_get_item(dataset, item_uuid)) + asyncio.create_task(_refresh()) - def do_refresh_view(self, action, value): - """Refresh view by reloading base uri list, """ - self.refresh() + def show_error(self, exception): + _logger.error(traceback.format_exc()) - # signal handlers + # private methods - @Gtk.Template.Callback() - def on_settings_clicked(self, widget): - self.settings_dialog.show() + async def _refresh_base_uri_list_box(self): + # book keeping of current state + base_uri_row = self.base_uri_list_box.get_selected_row() + base_uri = None - @Gtk.Template.Callback() - def version_button_clicked(self, widget): - self.server_versions_dialog.show() + if isinstance(base_uri_row, DtoolBaseURIRow): + base_uri = str(base_uri_row.base_uri) + elif isinstance(base_uri_row, DtoolSearchResultsRow): + base_uri = LOOKUP_BASE_URI - @Gtk.Template.Callback() - def config_button_clicked(self, widget): - self.config_details.show() + # first, refresh list box + await self.base_uri_list_box.refresh() + # second, refresh base uri list selection + if base_uri is not None: + _logger.debug(f"Reselect base URI '{base_uri}") + self._select_base_uri_row_by_uri(base_uri) - @Gtk.Template.Callback() - def on_logging_clicked(self, widget): - self.log_window.show() + # removed these utility functions from inner scope of on_search_activate + # in order to decouple actual signal handler and functionality + def _update_search_summary(self, datasets): + row = self.base_uri_list_box.search_results_row + total_value = self.search_state.total_number_of_entries + row.info_label.set_text(f'{total_value} datasets') - @Gtk.Template.Callback() - def on_about_clicked(self, widget): - self.about_dialog.show() + def _update_main_statusbar(self, datasets): + total_number = self.search_state.total_number_of_entries + current_page = self.search_state.current_page + last_page = self.search_state.last_page + page_size = self.search_state.page_size + total_size = sum([0 if dataset.size_int is None else dataset.size_int for dataset in datasets]) + self.main_statusbar.push(0, + f"{total_number} datasets in total at {page_size} per page, " + f"{sizeof_fmt(total_size).strip()} total size of {len(datasets)} datasets on current page, " + f"on page {current_page} of {last_page}") - @Gtk.Template.Callback() - def on_base_uri_selected(self, list_box, row): - if row is None: - # this callback apparently gets evoked with row=None when an entry is deleted / unselected (?) from the base URI list - return + async def _fetch_search_results(self, on_show=None): + """Retrieve search results from lookup server.""" - def update_base_uri_summary(datasets): - total_size = sum([0 if dataset.size_int is None else dataset.size_int for dataset in datasets]) - row.info_label.set_text(f'{len(datasets)} datasets, {sizeof_fmt(total_size).strip()}') + self._disable_pagination_buttons() - async def _select_base_uri(): - row.start_spinner() + # Here sort order 1 implies ascending + row = self.base_uri_list_box.search_results_row + row.start_spinner() + self.main_spinner.start() - if isinstance(row, DtoolBaseURIRow): - try: - _logger.debug(f"Selected base URI {row.base_uri}.") - datasets = await row.base_uri.all_datasets() - _logger.debug(f"Found {len(datasets)} datasets.") - update_base_uri_summary(datasets) - if self.base_uri_list_box.get_selected_row() == row: - # Only update if the row is still selected - self.dataset_list_box.fill(datasets) - except Exception as e: - self.show_error(e) - self.main_stack.set_visible_child(self.main_paned) - elif isinstance(row, DtoolSearchResultsRow): - _logger.debug("Selected search results.") - # This is the search result - if row.search_results is not None: - _logger.debug(f"Fill dataset list with {len(row.search_results)} search results.") - self.dataset_list_box.fill(row.search_results) - self.main_stack.set_visible_child(self.main_paned) + pagination = {} + sorting = {} + try: + if self.search_state.search_text: + if is_valid_query(self.search_state.search_text): + _logger.debug("Valid query specified.") + datasets = await DatasetModel.get_datasets_by_mongo_query( + query=self.search_state.search_text, + page_number=self.search_state.current_page, + page_size=self.search_state.page_size, + sort_fields=self.search_state.sort_fields, + sort_order=self.search_state.sort_order, + pagination=pagination, + sorting=sorting + + ) else: - _logger.debug("No search results cached (likely first activation after app startup).") - _logger.debug("Mock emit search_entry activate signal once.") - self.main_stack.set_visible_child(self.main_label) - self.search_entry.emit("activate") + _logger.debug("Specified search text is not a valid query, just perform free text search.") + datasets = await DatasetModel.get_datasets( + free_text=self.search_state.search_text, + page_number=self.search_state.current_page, + page_size=self.search_state.page_size, + sort_fields=self.search_state.sort_fields, + sort_order=self.search_state.sort_order, + pagination=pagination, + sorting=sorting + ) else: - raise TypeError(f"Handling of {type(row)} not implemented.") - - row.stop_spinner() - row.task = None + _logger.debug("No keyword specified, list all datasets.") + datasets = await DatasetModel.get_datasets( + page_number=self.search_state.current_page, + page_size=self.search_state.page_size, + sort_fields=self.search_state.sort_fields, + sort_order=self.search_state.sort_order, + pagination=pagination, + sorting=sorting + ) - self.main_stack.set_visible_child(self.main_spinner) - self.create_dataset_button.set_sensitive(not isinstance(row, DtoolSearchResultsRow) and - row.base_uri.editable) - if row.task is None: - _logger.debug("Spawn select_base_uri task.") - row.task = asyncio.create_task(_select_base_uri()) + self.search_state.ingest_pagination_information(pagination) + self.search_state.ingest_sorting_information(sorting) - @Gtk.Template.Callback() - def on_search_activate(self, widget): - """Search activated (usually by hitting Enter after typing in the search entry).""" - search_text = self.search_entry.get_text() - self.activate_action('search-select-show', GLib.Variant.new_string(search_text)) + if len(datasets) > self._max_nb_datasets: + _logger.warning( + f"{len(datasets)} search results exceed allowed displayed maximum of {self._max_nb_datasets}. " + f"Only the first {self._max_nb_datasets} results are shown. Narrow down your search." + ) + datasets = datasets[:self._max_nb_datasets] # Limit number of datasets that are shown - @Gtk.Template.Callback() - def on_search_drop_down_clicked(self, widget): - if self.search_popover.get_visible(): - _logger.debug( - f"Search entry drop down icon pressed, hide popover.") - self.search_popover.popdown() - else: - _logger.debug(f"Search entry drop down icon pressed, show popover.") - self.search_popover.popup_at(widget) + row.search_results = datasets # Cache datasets - @Gtk.Template.Callback() - def on_dataset_selected(self, list_box, row): - if row is not None: - row_index = row.get_index() - _logger.debug(f"Selected row {row_index}.") - self.activate_action('show-dataset', GLib.Variant.new_uint32(row_index)) + self._update_search_summary(datasets) + self._update_main_statusbar(datasets) - @Gtk.Template.Callback() - def on_open_local_directory_clicked(self, widget): - # File chooser dialog (select directory) - dialog = Gtk.FileChooserDialog( - title="Open local directory", - parent=self, - action=Gtk.FileChooserAction.SELECT_FOLDER - ) - dialog.add_buttons( - Gtk.STOCK_CANCEL, - Gtk.ResponseType.CANCEL, - Gtk.STOCK_OPEN, - Gtk.ResponseType.OK, - ) + if self.base_uri_list_box.get_selected_row() == row: + # Only update if the row is still selected + self.dataset_list_box.fill(datasets, on_show=on_show) + except RuntimeError as e: + # TODO: There should probably be a more explicit test on authentication failure. + self.show_error(e) - # Attention: Avoid run method! - # Unlike GLib, Python does not support running the EventLoop recursively. - # Gbulb uses the GLib event loop, hence this works. If we move to another - # implementation (e.g. https://gitlab.gnome.org/GNOME/pygobject/-/merge_requests/189) - # that uses the asyncio event loop this will break. - response = dialog.run() - if response == Gtk.ResponseType.OK: - # Quote from https://athenajc.gitbooks.io/python-gtk-3-api/content/gtk-group/gtkfilechooser.html: - # - # When the user is finished selecting files in a Gtk.FileChooser, your program can get the selected names - # either as filenames or as URIs. For URIs, the normal escaping rules are applied if the URI contains - # non-ASCII characters. - # - # However, filenames are always returned in the character set specified by the G_FILENAME_ENCODING - # environment variable. - # - # This means that while you can pass the result of Gtk.FileChooser::get_filename() to open() or fopen(), - # you may not be able to directly set it as the text of a Gtk.Label widget unless you convert it first to - # UTF-8, which all GTK+ widgets expect. You should use g_filename_to_utf8() to convert filenames into - # strings that can be passed to GTK+ widgets. - uri, = dialog.get_uris() - # For using URI scheme on local paths, we have to unquote characters to be - uri = urllib.parse.unquote(uri, encoding='utf-8', errors='replace') - # Add directory to local inventory - try: - LocalBaseURIModel.add_directory(uri) - except ValueError as err: - _logger.warning(str(err)) - elif response == Gtk.ResponseType.CANCEL: - uri = None - dialog.destroy() + async def retry(): + await asyncio.sleep( + 0.5) # TODO: This is a dirty workaround for not having the login window pop up twice + await self._fetch_search_results(on_show=on_show) - # Refresh view of base URIs - asyncio.create_task(self._refresh_base_uri_list_box()) + # What happens is that the LoginWindow evokes the renew-token action via Gtk framework. + # This happens asynchronously as well. This means _fetch_search_results called again + # within the retry() function would open another LoginWindow here as the token renewal does + # not happen "quick" enough. Hence there is the asyncio.sleep(1). + LoginWindow(application=self.application, follow_up_action=lambda: asyncio.create_task(retry())).show() - @Gtk.Template.Callback() - def on_create_dataset_clicked(self, widget): - DatasetNameDialog(on_confirmation=self._create_dataset).show() + except Exception as e: + self.show_error(e) - @Gtk.Template.Callback() - def on_refresh_clicked(self, widget): - self.get_action_group("win").activate_action('refresh-view', None) + self.base_uri_list_box.select_search_results_row() + self.main_stack.set_visible_child(self.main_paned) + row.stop_spinner() + self.main_spinner.stop() - @Gtk.Template.Callback() - def on_show_clicked(self, widget): - uri = str(self.dataset_list_box.get_selected_row().dataset) - launch_default_app_for_uri(uri) + self._enable_pagination_buttons() + self._update_pagination_buttons() - @Gtk.Template.Callback() - def on_add_items_clicked(self, widget): - dialog = Gtk.FileChooserDialog( - title="Add items", parent=self, - action=Gtk.FileChooserAction.OPEN - ) - dialog.add_buttons( - Gtk.STOCK_CANCEL, - Gtk.ResponseType.CANCEL, - Gtk.STOCK_OPEN, - Gtk.ResponseType.OK, - ) - dialog.set_select_multiple(True) + def _search_by_uuid(self, uuid): + search_text = dump_single_line_query_text({"uuid": uuid}) + self._search_by_search_text(search_text) - # Attention: Avoid run method! - # Unlike GLib, Python does not support running the EventLoop recursively. - # Gbulb uses the GLib event loop, hence this works. If we move to another - # implementation (e.g. https://gitlab.gnome.org/GNOME/pygobject/-/merge_requests/189) - # that uses the asyncio event loop this will break. - response = dialog.run() - if response == Gtk.ResponseType.OK: - uris = dialog.get_uris() - fpaths = dialog.get_filenames() - for fpath in fpaths: - # uri = urllib.parse.unquote(uri, encoding='utf-8', errors='replace') - self._add_item(fpath) - elif response == Gtk.ResponseType.CANCEL: - pass - dialog.destroy() + def _search_by_search_text(self, search_text): + self.activate_action('search-select-show', GLib.Variant.new_string(search_text)) - @Gtk.Template.Callback() - def on_manifest_row_activated(self, tree_view, path, column): - """Handler for "row-activated" signal. + # utility methods - dataset selection + def _select_dataset_row_by_row_index(self, index): + """Select dataset row in dataset list box by index.""" + row = self.dataset_list_box.get_row_at_index(index) + if row is not None: + _logger.debug(f"Dataset row {index} selected.") + self.dataset_list_box.select_row(row) + else: + _logger.info(f"No dataset row with index {index} available for selection.") - Signal emitted when the method gtk-tree-view-row-activated is called or the user double clicks a treeview row. - It is also emitted when a non-editable row is selected and one of the keys: Space, Shift+Space, Return or Enter - is pressed. (https://www.gnu.org/software/guile-gnome/docs/gtk/html/GtkTreeView.html)""" + def _select_dataset_row_by_uri(self, uri): + """Select dataset row in dataset list box by uri.""" + index = self.dataset_list_box.get_row_index_from_uri(uri) + self._select_dataset_row_by_row_index(index) - items = self._get_selected_items() - if len(items) != 1: - raise ValueError("Can only get one item at a time.") - item_name, item_uuid = items[0] - self._show_get_item_dialog(item_name, item_uuid) + def _show_dataset_details(self, dataset): + """Kick off asynchronous task to show dataset details.""" + asyncio.create_task(self._update_dataset_view(dataset)) + self.dataset_stack.set_visible_child(self.dataset_box) - @Gtk.Template.Callback() - def on_save_metadata_button_clicked(self, widget): - # Get the YAML content from the source view - text_buffer = self.readme_source_view.get_buffer() - start_iter, end_iter = text_buffer.get_bounds() - yaml_content = text_buffer.get_text(start_iter, end_iter, True) + def _build_dependency_graph(self, dataset): + """Kick off asynchronous task to build dependency graph.""" + asyncio.create_task(self._compute_dependencies(dataset)) + def _show_dataset_details_by_row_index(self, index): + """Show dataset details by row index.""" + row = self.dataset_list_box.get_row_at_index(index) + if row is not None: + _logger.debug(f"{row.dataset.name} shown.") + self._show_dataset_details(row.dataset) + else: + _logger.info(f"No dataset row with index {index} available for selection.") - # Check the state of the linting switch before linting - if settings.yaml_linting_enabled: - # Lint the YAML content if the above condition wasn't met (i.e., linting is enabled) - conf = YamlLintConfig('extends: default') # using the default config - self.linting_problems = list(yamllint.linter.run(yaml_content, conf)) # Make it an instance variable - _logger.debug(str(self.linting_problems)) - total_errors = len(self.linting_problems) - if total_errors > 0: - self.linting_errors_button.set_sensitive(True) - if total_errors == 1: - error_message = f"YAML Linter Error:\n{str(self.linting_problems[0])}" - else: - other_errors_count = total_errors - 1 # since we're showing the first error - error_message = f"YAML Linter Error:\n{str(self.linting_problems[0])} and {other_errors_count} other YAML linting errors.\nClick here for more details" - self.linting_errors_button.set_label(error_message) - else: - self.linting_errors_button.set_label("No linting issues found!") - self.dataset_list_box.get_selected_row().dataset.put_readme(yaml_content) + def _build_dependency_graph_by_row_index(self, index): + """Build dependency graph by row index.""" + row = self.dataset_list_box.get_row_at_index(index) + if row is not None: + _logger.debug(f"{row.dataset.name} shown.") + self._build_dependency_graph(row.dataset) else: + _logger.info(f"No dataset row with index {index} available for selection.") - # Clear previous linting problems when linting is turned off - self.linting_problems = None - self.linting_errors_button.set_label("YAML linting turned off.") + def _show_dataset_details_by_uri(self, uri): + """Select dataset row in dataset list box by uri.""" + index = self.dataset_list_box.get_row_index_from_uri(uri) + self._show_dataset_details_by_row_index(index) - _logger.debug("YAML linting turned off.") - self.dataset_list_box.get_selected_row().dataset.put_readme(yaml_content) + def _build_dependency_graph_by_uri(self, uri): + """Build dependency graph by uri.""" + index = self.dataset_list_box.get_row_index_from_uri(uri) + self._build_dependency_graph_by_row_index(index) + + def _select_and_show_by_row_index(self, index=0): + """Select dataset entry by row index and show details.""" + self._select_dataset_row_by_row_index(index) + self._show_dataset_details_by_row_index(index) + + def _select_and_show_by_uri(self, uri): + """Select dataset entry by URI and show details.""" + self._select_dataset_row_by_uri(uri) + self._show_dataset_details_by_uri(uri) - @Gtk.Template.Callback() - def on_linting_errors_button_clicked(self, widget): - # Check if the problems attribute exists - if hasattr(self, 'linting_problems') and self.linting_problems: - # Join the linting error messages into a single string - error_text = '\n\n'.join(str(problem) for problem in self.linting_problems) + def _search(self, search_text, on_show=None): + """Get datasets by text search.""" + self.search_state.search_text = search_text + self.search_state.reset_pagination() + # self.search_state.current_page = 1 + self._refresh_datasets(on_show=on_show) - # Set the linting error text to the dialog - self.error_linting_dialog.set_error_text(error_text) + def _refresh_datasets(self, on_show=None): + """Reset dataset list, show spinner, and kick off async task for retrieving dataset entries.""" + self.main_stack.set_visible_child(self.main_spinner) + row = self.base_uri_list_box.search_results_row + row.search_results = None + asyncio.create_task(self._fetch_search_results(on_show=on_show)) - # Show the dialog - self.error_linting_dialog.show() - else: - pass + def _search_select_and_show(self, search_text): + """Get datasets by text search, select first row and show dataset details.""" + _logger.debug(f"Search '{search_text}'...") + self._search(search_text, on_show=lambda _: self._select_and_show_by_row_index()) - @Gtk.Template.Callback() - def on_freeze_clicked(self, widget): - row = self.dataset_list_box.get_selected_row() - dialog = Gtk.MessageDialog(self, Gtk.DialogFlags.MODAL, Gtk.MessageType.QUESTION, Gtk.ButtonsType.OK_CANCEL, - f'You are about to freeze dataset "{row.dataset.name}". Items can no longer be ' - 'added, removed or modified after freezing the dataset. (You will still be able to ' - 'edit the metadata README.yml.) Please confirm freezing of this dataset.') - # Attention: Avoid run method! - # Unlike GLib, Python does not support running the EventLoop recursively. - # Gbulb uses the GLib event loop, hence this works. If we move to another - # implementation (e.g. https://gitlab.gnome.org/GNOME/pygobject/-/merge_requests/189) - # that uses the asyncio event loop this will break. - response = dialog.run() - dialog.destroy() - if response == Gtk.ResponseType.OK: - uri = row.dataset.uri # URI won't change in freeze process - row.freeze() - self.dataset_list_box.show_all() - self.get_action_group("win").activate_action('select-dataset-by-uri', GLib.Variant.new_string(uri)) - self.get_action_group("win").activate_action('show-dataset-by-uri', GLib.Variant.new_string(uri)) + # pagination functionality + def _show_page(self, page_index): + """Get datasets by page, select first row and show dataset details.""" + if not self.search_state.fetching_results: + self.search_state.current_page = page_index + # self._disable_pagination_buttons() + self._refresh_datasets(on_show=lambda _: self._select_and_show_by_row_index()) + # asyncio.create_task(self._fetch_search_results()) + # self._update_pagination_buttons(page_number, widget) - @Gtk.Template.Callback() - def on_error_bar_close(self, widget): - _logger.debug("Hide error bar.") - self.error_bar.set_revealed(False) + def _update_pagination_buttons(self): + """Update pagination buttons to match current search state.""" - @Gtk.Template.Callback() - def on_error_bar_response(self, widget, response_id): - if response_id == Gtk.ResponseType.CLOSE: - self.error_bar.set_revealed(False) + self.current_page_button.set_label(str(self.search_state.current_page)) + self.next_page_button.set_label(str(self.search_state.next_page)) + self.previous_page_button.set_label(str(self.search_state.previous_page)) - # sort signal handlers - # @Gtk.Template.Callback() - # def on_sort_field_combo_box_changed(self, widget): - # sort_field = widget.get_active_text() - # _logger.debug("sort field changed to %s", sort_field) + if self.search_state.current_page >= self.search_state.last_page: + self.next_page_button.set_visible(False) + else: + self.next_page_button.set_visible(True) - @Gtk.Template.Callback() - def on_sort_order_switch_state_set(self, widget, state): - # Toggle sort order based on the switch state - if state: - sort_order = -1 # Switch is on, use ascending order + if self.search_state.current_page <= self.search_state.first_page: + self.previous_page_button.set_visible(False) else: - sort_order = 1 # Switch is off, use descending order + self.previous_page_button.set_visible(True) - self.search_state.sort_order = [sort_order] - self.activate_action('show-current-page') - # self.on_sort_field_combo_box_changed(self.sort_field_combo_box) + def _disable_pagination_buttons(self): + """Disable all pagination buttons (typically while fetching results)""" + self.fetching_results = True + self.first_page_button.set_sensitive(False) + self.next_page_button.set_sensitive(False) + self.last_page_button.set_sensitive(False) + self.previous_page_button.set_sensitive(False) + self.current_page_button.set_sensitive(False) + self.decrease_page_button.set_sensitive(False) + self.increase_page_button.set_sensitive(False) - @Gtk.Template.Callback() - def on_contents_per_page_combo_box_changed(self, widget): - # Get the active iter and retrieve the key from the first column - model = widget.get_model() - active_iter = widget.get_active_iter() - if active_iter is not None: - selected_key = model[active_iter][0] # This is the key + def _enable_pagination_buttons(self): + """Enable all pagination buttons (typically after fetching results)""" + self.fetching_results = False + self.first_page_button.set_sensitive(True) + self.next_page_button.set_sensitive(True) + self.last_page_button.set_sensitive(True) + self.previous_page_button.set_sensitive(True) + self.current_page_button.set_sensitive(True) + self.decrease_page_button.set_sensitive(True) + self.increase_page_button.set_sensitive(True) - self.search_state.page_size = selected_key - self.activate_action('show-first-page') + # other helper functions + def _get_selected_items(self): + """Returns (name uuid) tuples of items selected in manifest tree store.""" + selection = self.manifest_tree_view.get_selection() + model, paths = selection.get_selected_rows() - @Gtk.Template.Callback() - def on_sort_field_combo_box_changed(self, widget): - # Get the active iter and retrieve the key from the first column - model = widget.get_model() - active_iter = widget.get_active_iter() - if active_iter is not None: - selected_key = model[active_iter][0] # This is the key + items = [] + for path in paths: + column_iter = model.get_iter(path) + item_name = model.get_value(column_iter, 0) + item_uuid = model.get_value(column_iter, 3) + items.append((item_name, item_uuid)) - self.search_state.sort_fields = [selected_key] - self.activate_action('show-current-page') + return items - # pagination signal handlers + # utility methods - base uri selection + def _select_base_uri_row_by_row_index(self, index): + """Select base uri row in base uri list box by index.""" + row = self.base_uri_list_box.get_row_at_index(index) + if row is not None: + _logger.debug(f"Base URI row {index} selected.") + self.base_uri_list_box.select_row(row) + else: + _logger.info(f"No base URI row with index {index} available for selection.") - @Gtk.Template.Callback() - def on_first_page_button_clicked(self, widget): - # Navigate to the first page if results are not currently being fetched - self.activate_action('show-first-page') + def _select_base_uri_row_by_uri(self, uri): + """Select base uri row in dataset list box by uri.""" + index = self.base_uri_list_box.get_row_index_from_uri(uri) + self._select_base_uri_row_by_row_index(index) - @Gtk.Template.Callback() - def on_decrease_page_button_clicked(self, widget): - """Navigate to the previous page if it exists""" - self.activate_action('show-previous-page') + def _show_base_uri_row_by_row_index(self, index): + """Select base uri row in dataset list box by uri.""" + row = self.base_uri_list_box.get_row_at_index(index) + if row is not None: + _logger.debug(f"Base URI row {index} selected.") + self.base_uri_list_box.select_row(row) + self._show_base_uri(row, on_show=lambda _: self._select_and_show_by_row_index()) + else: + _logger.info(f"No base URI row with index {index} available for selection.") - @Gtk.Template.Callback() - def on_previous_page_button_clicked(self, widget): - """Navigate to the previous page if it exists""" - self.activate_action('show-previous-page') + def _show_base_uri_row_by_uri(self, uri): + """Select base uri row in dataset list box by uri.""" + self._select_base_uri_row_by_uri(uri) - @Gtk.Template.Callback() - def on_current_page_button_clicked(self, widget): - """Highlight the current page button and fetch its results""" - style_context = self.current_page_button.get_style_context() - style_context.add_class('suggested-action') - self.activate_action('show-current-page') + # index = self.base_uri_list_box.get_row_index_from_uri(uri) + # self._select_base_uri_row_by_row_index(index) - @Gtk.Template.Callback() - def on_next_page_button_clicked(self, widget): - # Navigate to the next page if available - self.activate_action('show-next-page') + def _show_base_uri(self, row, on_show=None): + """Show datasets in selected base URI.""" + if row is None: + # this callback apparently gets evoked with row=None when an entry is deleted / unselected (?) from the base URI list + return - @Gtk.Template.Callback() - def on_increase_page_button_clicked(self, widget): - """Navigate to the next page uif it exists""" - self.activate_action('show-next-page') + def update_base_uri_summary(datasets): + total_size = sum([0 if dataset.size_int is None else dataset.size_int for dataset in datasets]) + row.info_label.set_text(f'{len(datasets)} datasets, {sizeof_fmt(total_size).strip()}') - @Gtk.Template.Callback() - def on_last_page_button_clicked(self, widget): - """Navigate to the last page""" - self.activate_action('show-last-page') + async def _select_base_uri(): + row.start_spinner() - def on_readme_buffer_changed(self, buffer): - self.save_metadata_button.set_sensitive(True) + if isinstance(row, DtoolBaseURIRow): + try: + _logger.debug(f"Selected base URI {row.base_uri}.") + datasets = await row.base_uri.all_datasets() + _logger.debug(f"Found {len(datasets)} datasets.") + update_base_uri_summary(datasets) + if self.base_uri_list_box.get_selected_row() == row: + # Only update if the row is still selected + self.dataset_list_box.fill(datasets, on_show=on_show) + except Exception as e: + self.show_error(e) + self.main_stack.set_visible_child(self.main_paned) + elif isinstance(row, DtoolSearchResultsRow): + _logger.debug("Selected search results.") + # This is the search result + if row.search_results is not None: + _logger.debug(f"Fill dataset list with {len(row.search_results)} search results.") + self.dataset_list_box.fill(row.search_results, on_show=on_show) + self.main_stack.set_visible_child(self.main_paned) + else: + _logger.debug("No search results cached (likely first activation after app startup).") + # _logger.debug("Mock emit search_entry activate signal once.") + self.main_stack.set_visible_child(self.main_label) + # self.search_entry.emit("activate") + await self._fetch_search_results(on_show=on_show) + else: + raise TypeError(f"Handling of {type(row)} not implemented.") - # TODO: this should be an action do_copy - # if it is possible to hand two strings, e.g. source and destination to an action, then this action should - # go to the main app. - # @Gtk.Template.Callback(), not in .ui - def on_copy_clicked(self, widget): - async def _copy(): - try: - await self._copy_manager.copy(self.dataset_list_box.get_selected_row().dataset, widget.destination) - except Exception as e: - self.show_error(e) + row.stop_spinner() + row.task = None - asyncio.create_task(_copy()) + self.main_stack.set_visible_child(self.main_spinner) + self.create_dataset_button.set_sensitive(not isinstance(row, DtoolSearchResultsRow) and + row.base_uri.editable) + if row.task is None: + _logger.debug("Spawn select_base_uri task.") + row.task = asyncio.create_task(_select_base_uri()) + + def _select_and_load_first_uri(self): + """ + This function automatically reloads the data and selects the first URI. + """ + first_row = self.base_uri_list_box.get_children()[0] + self.base_uri_list_box.select_row(first_row) + self.on_base_uri_selected(self.base_uri_list_box, first_row) def _show_get_item_dialog(self, item_name, item_uuid): default_dir = settings.item_download_directory @@ -1306,7 +1375,8 @@ async def _get_manifest(): if dataset.type == 'lookup': self.dependency_stack.show() _logger.debug("Selected dataset is lookup result.") - self.get_action_group("win").activate_action('build-dependency-graph-by-uri', GLib.Variant.new_string(dataset.uri)) + self.get_action_group("win").activate_action('build-dependency-graph-by-uri', + GLib.Variant.new_string(dataset.uri)) else: _logger.debug("Selected dataset is accessed directly.") self.dependency_stack.hide() @@ -1337,7 +1407,4 @@ async def _compute_dependencies(self, dataset): 'in the database: {}'.format(reduce(lambda a, b: a + ', ' + b, missing_uuids))) self.dependency_graph_widget.graph = dependency_graph.graph - self.dependency_stack.set_visible_child(self.dependency_view) - - def show_error(self, exception): - _logger.error(traceback.format_exc()) \ No newline at end of file + self.dependency_stack.set_visible_child(self.dependency_view) \ No newline at end of file