diff --git a/app/controller/controller.py b/app/controller/controller.py index b07d81e..168fa26 100644 --- a/app/controller/controller.py +++ b/app/controller/controller.py @@ -79,11 +79,15 @@ def _connect_sigs( parsed.article_ui_list.itemClicked: self.on_article_clicked, parsed.filter_sig: self.filter_tables, parsed.prune_sig: self.prune_tables_and_columns, + parsed.tags_entry_widget.tagAdded: self.add_tag, + parsed.tags_display_widget.tagRemoved: self.remove_tag, # Pruned page pruned.article_ui_list.itemClicked: self.on_article_clicked, pruned.filter_sig: self.filter_tables, pruned.prune_sig: self.prune_tables_and_columns, + pruned.tags_entry_widget.tagAdded: self.add_tag, + pruned.tags_display_widget.tagRemoved: self.remove_tag, # Search threads search_thread.article_sig: self.display_article_in_list, @@ -98,6 +102,20 @@ def _connect_sigs( for signal, slot in signals_map.items(): signal.connect(slot) self.signal_connections.append((signal, slot)) + + def add_tag(self, tag: 'str'): + self.model.last_selected_table.add_tag(tag) + + self.curr_elems.tags_display_widget.clear() + for tag in self.model.last_selected_table.get_tags(): + self.curr_elems.tags_display_widget.addTag(tag) + + def remove_tag(self, tag: 'str'): + self.model.last_selected_table.remove_tag(tag) + + self.curr_elems.tags_display_widget.clear() + for tag in self.model.last_selected_table.get_tags(): + self.curr_elems.tags_display_widget.addTag(tag) def _disconnect_sigs(self): for signal, slot in self.signal_connections: @@ -121,14 +139,15 @@ def display_article_in_list( def search_articles(self): self._set_state(Mode.SEARCHING) self.model.reset_for_searching() - self.view.tab_widget.setCurrentIndex(0) + self.view.tabbed_pageholder.setCurrentIndex(0) if self.model.search_thread.isRunning(): QMessageBox.warning( self.view, "Search in Progress", "A search is already in progress. " - "Please wait or stop the current search.") + "Please wait or stop the current search." + ) return query = self.output_page.query_field.text() @@ -179,13 +198,18 @@ def on_article_clicked(self, item: 'QListWidgetItem'): self.view.update_article_display(article, elements, data_set) for i in range(elements.data_ui_list.count()): - list_item = elements.data_ui_list.item(i) - widget: 'DataListItem' = elements.data_ui_list.itemWidget( - list_item) + data_list_item: 'DataListItem' = elements.data_ui_list.itemWidget( + elements.data_ui_list.item(i) + ) + if elements.page_identity == PageIdentity.SEARCH: - widget.preview_requested.connect(self.request_suppfile_preview) + data_list_item.preview_requested.connect( + self.request_suppfile_preview + ) else: - widget.preview_requested.connect(self.preview_processed_table) + data_list_item.preview_requested.connect( + self.preview_processed_table + ) def load_preview( self, @@ -217,7 +241,7 @@ def load_preview( self.view.stop_load_animation() # The original signal emits two arguments, but this slot only takes one - # - why does this work? Granted, we don't need the context argument, but + # - why does this work? Granted, we don't *need* the context argument, but # still, it doesn't make sense why this doesn't throw an error. # TODO debug later def request_suppfile_preview(self, file_data: 'SuppFile'): @@ -238,6 +262,12 @@ def request_suppfile_preview(self, file_data: 'SuppFile'): def preview_processed_table( self, table: 'ProcessedTable', context: 'PageIdentity' ): + # hacky to do this here, should have own method + self.model.last_selected_table = table + self.curr_elems.tags_display_widget.clear() + for tag in table.get_tags(): + self.curr_elems.tags_display_widget.addTag(tag) + table_data = { "sheet": self.model.table_db_manager.get_processed_table_data( table.id, context @@ -409,11 +439,11 @@ def save(self): self.model.saves_path, f"session-{idx}-{datetime.now().strftime('%Y%m%d-%H%M%S')}.pkl" ) - + if os.path.exists(self.model.session_file): os.remove(self.model.session_file) else: - idx = len(os.listdir(self.model.saves_path)) +1 + idx = len(os.listdir(self.model.saves_path)) + 1 filepath = os.path.join( self.model.saves_path, f"session-{idx}-{datetime.now().strftime('%Y%m%d-%H%M%S')}.pkl" @@ -443,14 +473,13 @@ def load(self): if not filename: return - # repopulate the GUI # clear all pages # re-init self.model.table_db_manager.delete_dbs() self._disconnect_sigs() - + self.model.load(filename) self.view.reset() diff --git a/app/model/article_managers.py b/app/model/article_managers.py index 89135e8..a28b63e 100644 --- a/app/model/article_managers.py +++ b/app/model/article_managers.py @@ -92,6 +92,7 @@ def __init__( self.supp_file: 'SuppFile' = article.get_file(file_id) self.pruned_columns: 'list[int]' = [] self.observers: 'dict[PageIdentity, DataListItem]' = {} + self.tags = [] def checkbox_toggled(self, context: 'PageIdentity'): self.notify_observers(context) @@ -107,6 +108,17 @@ def set_checked_state(self, checked_state: 'bool', context: 'PageIdentity'): if context in self.observers: self.notify_observers(context) + def add_tag(self, tag: 'str'): + if tag not in self.tags: + self.tags.append(tag) + # self.notify_observers(PageIdentity.PRUNED) + + def remove_tag(self, tag: 'str'): + if tag in self.tags: + self.tags.remove(tag) + + def get_tags(self): + return self.tags class ProcessedTableManager: def __init__(self): diff --git a/app/model/database.py b/app/model/database.py index e8be70c..3a48672 100644 --- a/app/model/database.py +++ b/app/model/database.py @@ -6,6 +6,7 @@ from sqlalchemy import create_engine, Column, String, text from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.dialects.sqlite import JSON from sqlalchemy.orm import sessionmaker from uuid import uuid4 import pandas as pd @@ -21,7 +22,7 @@ class TableDBEntry(Base): __abstract__ = True table_id = Column(String, primary_key=True) original_file_id = Column(String) - sql_table_name = Column(String) + tags = Column(JSON) class ProcessedTableDBEntry(TableDBEntry): @@ -90,16 +91,16 @@ def save_table( table_class: 'TableDBEntry', table_id: 'str', original_file_id: 'UUID', - df: 'DataFrame' + df: 'DataFrame', + tags: 'list[str]' = [] ): engine, Session = self._get_engine_and_session(table_class) with Session() as session: - sql_table_name = f"{table_class.__tablename__}_{table_id}" - df.to_sql(sql_table_name, engine, index=False) + df.to_sql(table_id, engine, index=False) new_table = table_class( table_id=table_id, original_file_id=str(original_file_id), - sql_table_name=sql_table_name + tags=tags ) session.add(new_table) session.commit() @@ -118,7 +119,7 @@ def update_table( if existing_table: df.to_sql( - existing_table.sql_table_name, + existing_table.table_id, engine, if_exists='replace', index=False @@ -146,7 +147,7 @@ def get_table_data( table_id=table_id ).first() if table_entry: - df = pd.read_sql_table(table_entry.sql_table_name, engine) + df = pd.read_sql_table(table_entry.table_id, engine) return df.reset_index(drop=True) return None @@ -173,7 +174,7 @@ def delete_table( if table_entry: with engine.connect() as conn: conn.execute( - text(f"DROP TABLE IF EXISTS \"{table_entry.sql_table_name}\"")) + text(f"DROP TABLE IF EXISTS \"{table_entry.table_id}\"")) session.delete(table_entry) session.commit() @@ -183,7 +184,7 @@ def save_dbs(self) -> 'list[str]': processed_target_path = self.old_fnames[0] pruned_target_path = self.old_fnames[1] - + # Delete old versions of the databases if os.path.exists(processed_target_path): os.remove(processed_target_path) @@ -224,7 +225,7 @@ def reset(self): for entry in table_entries: with engine.connect() as conn: conn.execute( - text(f"DROP TABLE IF EXISTS \"{entry.sql_table_name}\"")) + text(f"DROP TABLE IF EXISTS \"{entry.table_id}\"")) session.query(table_class).delete() session.commit() @@ -245,11 +246,13 @@ def processed_df_to_db( db_manager: 'TableDBManager', table_id: 'str', original_file_id: 'UUID', - df: 'DataFrame' + df: 'DataFrame', + tags: 'list[str]' ): db_manager.save_table( ProcessedTableDBEntry, table_id, original_file_id, - df + df, + tags ) diff --git a/app/model/model.py b/app/model/model.py index 69566b0..53a0d39 100644 --- a/app/model/model.py +++ b/app/model/model.py @@ -37,6 +37,7 @@ def __init__( self.table_db_manager = TableDBManager(db_temp_path, db_perm_path) self.processed_table_manager = ProcessedTableManager() self.processing_thread = FileProcessingThread(self.table_db_manager) + self.last_selected_table: 'ProcessedTable' = None self.n_parse_runs = 0 self.n_prunes = 0 @@ -148,7 +149,8 @@ def prune_tables_and_columns(self, context: 'PageIdentity'): PostPruningTableDBEntry, table.id, table.file_id, - pruned_df + pruned_df, + table.tags ) article.pruned_tables = selected_tables diff --git a/app/model/tabular_operations.py b/app/model/tabular_operations.py index 8472ccf..c1ed494 100644 --- a/app/model/tabular_operations.py +++ b/app/model/tabular_operations.py @@ -83,7 +83,7 @@ def parse_tables( ) processed_df_to_db( - db_manager, unique_id, file.id, data + db_manager, unique_id, file.id, data, [] ) processed_table_ids.append((unique_id, file.id)) diff --git a/app/views/page.py b/app/views/page.py index 7ecefea..af705fe 100644 --- a/app/views/page.py +++ b/app/views/page.py @@ -4,7 +4,7 @@ from PyQt5.QtGui import QKeyEvent from PyQt5.QtCore import Qt, QTimer, QObject, pyqtSignal -from PyQt5.QtWidgets import QLabel, QProgressBar, QPushButton, QListWidget, QTabWidget, QTextBrowser, QLineEdit, QSizePolicy +from PyQt5.QtWidgets import QLabel, QProgressBar, QPushButton, QListWidget, QTabWidget, QTextBrowser, QLineEdit, QSizePolicy, QHBoxLayout, QVBoxLayout, QWidget, QListWidgetItem class QPushButton(QPushButton): @@ -34,6 +34,71 @@ def keyPressEvent(self, event: 'QKeyEvent'): self.itemClicked.emit(self.currentItem()) +class TagEntryWidget(QLineEdit): + tagAdded = pyqtSignal(str) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setPlaceholderText("Type a descriptive tag and press Enter...") + + def keyPressEvent(self, event): + if event.key() == Qt.Key_Return or event.key() == Qt.Key_Enter: + text = self.text().strip().replace(" ", "_") + if text: + self.tagAdded.emit(text) + self.clear() + super().keyPressEvent(event) + + +class TagWidget(QWidget): + # Custom widget that includes a label and a delete button + removeTag = pyqtSignal(str) + + def __init__(self, tag, parent=None): + super().__init__(parent) + self.tag = tag + + # Layout + layout = QHBoxLayout(self) + tag_label = QLabel(f"#{tag}") + remove_button = QPushButton("X") + remove_button.setFixedSize(20, 20) # Small, fixed-size button + layout.addWidget(tag_label) + layout.addWidget(remove_button) + layout.addStretch() + layout.setContentsMargins(0, 0, 0, 0) + + # Connect the remove button signal + remove_button.clicked.connect(self.emitRemoveSignal) + + def emitRemoveSignal(self): + self.removeTag.emit(self.tag) + +class TagsDisplayWidget(QListWidget): + tagRemoved = pyqtSignal(str) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setSelectionMode(QListWidget.NoSelection) + + def addTag(self, tag): + item = QListWidgetItem(self) + tag_widget = TagWidget(tag, self) + item.setSizeHint(tag_widget.sizeHint()) + self.addItem(item) + self.setItemWidget(item, tag_widget) + tag_widget.removeTag.connect(self.removeTag) + + def removeTag(self, tag): + # Find the item and remove it from the list + for index in range(self.count()): + item = self.item(index) + widget = self.itemWidget(item) + if widget.tag == tag: + self.takeItem(index) + break + self.tagRemoved.emit(tag) + class PageElements(QObject): # QObject needed for signalling def __init__(self, parent_tab: 'TabPage'): super().__init__() @@ -58,14 +123,14 @@ def __init__(self, parent_tab: 'TabPage'): self.title_abstract_disp.setFocusPolicy(Qt.NoFocus) - self.outer_tab_widget = QTabWidget(parent_tab) - self.previews = QTabWidget() - - self.outer_tab_widget.addTab(self.previews, "Previews") + self.data_previews = QTabWidget() self.metadata_view = QTextBrowser() self.metadata_view.setOpenExternalLinks(True) - self.outer_tab_widget.addTab(self.metadata_view, "Metadata") + + self.info_tabs_widget = QTabWidget(parent_tab) + self.info_tabs_widget.addTab(self.data_previews, "Previews") + self.info_tabs_widget.addTab(self.metadata_view, "Metadata") self.loading_label = QLabel(parent_tab) self.loading_label.setAlignment(Qt.AlignCenter) @@ -98,6 +163,20 @@ def __init__(self, parent_tab: 'TabPage'): self.prune_btn = QPushButton("Prune Tables and Columns", parent_tab) self.prune_btn.clicked.connect(self.emit_prune_identity) + self.tags_entry_widget = TagEntryWidget(parent_tab) + self.tags_display_widget = TagsDisplayWidget(parent_tab) + self.tags_layout = QHBoxLayout() + self.tags_layout.addWidget(self.tags_entry_widget) + self.tags_display_layout = QVBoxLayout() + self.tags_display_layout.addWidget(self.tags_display_widget) + + self.tags_widget = QWidget() + self.tags_full_layout = QVBoxLayout(self.tags_widget) + self.tags_full_layout.addLayout(self.tags_layout) + self.tags_full_layout.addLayout(self.tags_display_layout) + + self.info_tabs_widget.addTab(self.tags_widget, "Tags") + def emit_filter_identity(self): self.filter_sig.emit(self.page_identity) diff --git a/app/views/view.py b/app/views/view.py index e1f28ab..931be71 100644 --- a/app/views/view.py +++ b/app/views/view.py @@ -50,17 +50,17 @@ def __init__(self): self.load_action.setShortcut("Ctrl+O") self.menu_bar.addMenu(self.file_menu) - self.tab_widget = QTabWidget(self) - self.tab_widget.setTabBar(CustomTabBar()) - self.setCentralWidget(self.tab_widget) + self.tabbed_pageholder = QTabWidget(self) + self.tabbed_pageholder.setTabBar(CustomTabBar()) + self.setCentralWidget(self.tabbed_pageholder) self.search_tab = TabPage(self, PageIdentity.SEARCH) self.parsed_tab = TabPage(self, PageIdentity.PARSED) self.pruned_tab = TabPage(self, PageIdentity.PRUNED) - self.tab_widget.addTab(self.search_tab, "Search") - self.tab_widget.addTab(self.parsed_tab, "Parsing Results") - self.tab_widget.addTab(self.pruned_tab, "Pruned Results") + self.tabbed_pageholder.addTab(self.search_tab, "Search") + self.tabbed_pageholder.addTab(self.parsed_tab, "Parsing Results") + self.tabbed_pageholder.addTab(self.pruned_tab, "Pruned Results") self.search_elems = SearchPageElements(self.search_tab) self.parsed_elems = ProcessedPageElements(self.parsed_tab) @@ -80,7 +80,7 @@ def keyPressEvent(self, event: 'QKeyEvent'): # Getters for active page @property def active_tab(self) -> 'TabPage': - return self.tab_widget.currentWidget() + return self.tabbed_pageholder.currentWidget() @property def active_elements(self) -> 'PageElements': @@ -93,11 +93,11 @@ def active_elements(self) -> 'PageElements': # Set active page def set_active_tab(self, page_identity: 'PageIdentity'): if page_identity == PageIdentity.SEARCH: - self.tab_widget.setCurrentWidget(self.search_tab) + self.tabbed_pageholder.setCurrentWidget(self.search_tab) elif page_identity == PageIdentity.PARSED: - self.tab_widget.setCurrentWidget(self.parsed_tab) + self.tabbed_pageholder.setCurrentWidget(self.parsed_tab) elif page_identity == PageIdentity.PRUNED: - self.tab_widget.setCurrentWidget(self.pruned_tab) + self.tabbed_pageholder.setCurrentWidget(self.pruned_tab) def _init_search_layouts(self, elements: 'SearchPageElements'): left_pane = QVBoxLayout() @@ -156,13 +156,13 @@ def _init_core_layouts( mid_widget.setLayout(mid_pane) # Container for previews - preview_pane = QVBoxLayout() - preview_pane.addWidget(elements.outer_tab_widget) - preview_pane.addWidget(elements.loading_label) - preview_pane.setStretchFactor(elements.outer_tab_widget, 1) - preview_pane.setStretchFactor(elements.loading_label, 0) + data_info_pane = QVBoxLayout() + data_info_pane.addWidget(elements.info_tabs_widget) + data_info_pane.addWidget(elements.loading_label) + data_info_pane.setStretchFactor(elements.info_tabs_widget, 1) + data_info_pane.setStretchFactor(elements.loading_label, 0) preview_widget = QWidget() - preview_widget.setLayout(preview_pane) + preview_widget.setLayout(data_info_pane) # QSplitter for the title/abstract and previews mid_splitter = QSplitter(Qt.Vertical) @@ -275,7 +275,7 @@ def reset(self): for elems in [self.search_elems, self.parsed_elems, self.pruned_elems]: self.clear_page_lists(elems) elems.title_abstract_disp.clear() - elems.previews.clear() + elems.data_previews.clear() def _list_item_factory( self, file_data: 'BaseData', context: 'PageIdentity' @@ -293,7 +293,7 @@ def display_multisheet_table( callback: 'Callable' = None, checked_columns: 'list[int]' = None ): - tab_widget = self.active_elements.previews + tab_widget = self.active_elements.data_previews tab_widget.clear() for sheet, df in df_dict.items():