diff --git a/.gitignore b/.gitignore index c1c87f8..5459a41 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__/ anime_tracker.log todo.txt *.csv +anime_backlog.db.bk diff --git a/backend.py b/backend.py index 8682e75..a981f05 100644 --- a/backend.py +++ b/backend.py @@ -30,28 +30,10 @@ class AnimeBackend: except Exception as e: logging.error(f"Error creating table: {e}") - def get_pre_2010_entries(self, status_filter=None, type_filter=None, search=None, year_filter=None): + def get_pre_2010_entries(self): try: cursor = self.db.cursor() - sql = "SELECT * FROM anime WHERE year < 2010" - add_where = [] - params = [] - if year_filter is not None: - add_where.append("year = ?") - params.append(year_filter) - if status_filter is not None: - add_where.append("status = ?") - params.append(status_filter) - if type_filter is not None: - add_where.append("type = ?") - params.append(type_filter) - if search is not None: - add_where.append("(name LIKE ? OR comment LIKE ?)") - params.append(f"%{search}%") - params.append(f"%{search}%") - where_clause = " AND " + " AND ".join(add_where) if add_where else "" - sql += where_clause + " ORDER BY year DESC, name" - cursor.execute(sql, params) + cursor.execute("SELECT * FROM anime WHERE year < 2010 ORDER BY year DESC, name") return cursor.fetchall() except Exception as e: logging.error(f"Error getting pre-2010 entries: {e}") @@ -66,25 +48,10 @@ class AnimeBackend: logging.error(f"Error getting years: {e}") return [] - def get_entries_for_season(self, year, season, status_filter=None, type_filter=None, search=None): + def get_entries_for_season(self, year, season): try: cursor = self.db.cursor() - sql = "SELECT * FROM anime WHERE year = ? AND season = ?" - params = [year, season] - add_where = [] - if status_filter is not None: - add_where.append("status = ?") - params.append(status_filter) - if type_filter is not None: - add_where.append("type = ?") - params.append(type_filter) - if search is not None: - add_where.append("(name LIKE ? OR comment LIKE ?)") - params.append(f"%{search}%") - params.append(f"%{search}%") - where_clause = " AND " + " AND ".join(add_where) if add_where else "" - sql += where_clause + " ORDER BY name" - cursor.execute(sql, params) + cursor.execute("SELECT * FROM anime WHERE year = ? AND season = ? ORDER BY name", (year, season)) return cursor.fetchall() except Exception as e: logging.error(f"Error getting entries for season {season} in year {year}: {e}") diff --git a/frontend.py b/frontend.py index 8ffef82..aa92f28 100644 --- a/frontend.py +++ b/frontend.py @@ -153,45 +153,18 @@ class AnimeTracker(QMainWindow): central_layout = QVBoxLayout(self.central_widget) self.filter_bar = QWidget() filter_layout = QHBoxLayout(self.filter_bar) - label_year = QLabel("Year:") - self.year_spin = QSpinBox() - self.year_spin.setRange(0, 2100) - self.year_spin.setValue(0) - self.year_spin.setSpecialValueText("All") - filter_layout.addWidget(label_year) - filter_layout.addWidget(self.year_spin) - label_season = QLabel("Season:") - self.season_combo = QComboBox() - self.season_combo.addItems(['All', 'winter', 'spring', 'summer', 'fall', 'Other']) - filter_layout.addWidget(label_season) - filter_layout.addWidget(self.season_combo) - label_status = QLabel("Status:") - self.status_combo = QComboBox() - self.status_combo.addItems(['All', 'unwatched', 'watching', 'completed']) - filter_layout.addWidget(label_status) - filter_layout.addWidget(self.status_combo) - label_type = QLabel("Type:") - self.type_combo = QComboBox() - self.type_combo.addItems(['All', 'TV', 'Movie', 'OVA', 'Special', 'Short TV', 'Other']) - filter_layout.addWidget(label_type) - filter_layout.addWidget(self.type_combo) label_search = QLabel("Search:") self.search_edit = QLineEdit() - self.search_edit.setPlaceholderText("Search name or comment") + self.search_edit.setPlaceholderText("Search name") + self.search_edit.textChanged.connect(self.filter_tables) filter_layout.addWidget(label_search) filter_layout.addWidget(self.search_edit) - apply_btn = QPushButton("Apply") - apply_btn.clicked.connect(self.apply_filters) - filter_layout.addWidget(apply_btn) central_layout.addWidget(self.filter_bar) central_layout.addWidget(self.tab_widget) self.setCentralWidget(self.central_widget) - self.year_filter = None - self.season_filter = None - self.status_filter = None - self.type_filter = None - self.search_text = None + self.search_text = '' self.table_scale = self.settings.value("tableScale", 1.0, type=float) + self.tables = [] self.create_menu() self.load_tabs() self.restoreGeometry(self.settings.value("geometry", self.saveGeometry())) @@ -200,18 +173,14 @@ class AnimeTracker(QMainWindow): self.set_current_tab_by_identifier(last_tab) self.tab_widget.setFocus() - def apply_filters(self): - year_val = self.year_spin.value() - self.year_filter = year_val if year_val > 0 else None - season_val = self.season_combo.currentText() - self.season_filter = None if season_val == 'All' else ('' if season_val == 'Other' else season_val) - status_val = self.status_combo.currentText() - self.status_filter = None if status_val == 'All' else status_val - type_val = self.type_combo.currentText() - self.type_filter = None if type_val == 'All' else type_val - search_val = self.search_edit.text().strip() - self.search_text = search_val if search_val else None - self.load_tabs() + def filter_tables(self, text): + self.search_text = text.strip().lower() + for table in self.tables: + for row in range(table.rowCount()): + name_col = 2 if table.is_pre else 1 + name_text = table.cellWidget(row, name_col).text() + clean_name = re.sub(r'<[^>]+>', '', name_text).lower() + table.setRowHidden(row, self.search_text not in clean_name if self.search_text else False) def closeEvent(self, event): self.settings.setValue("geometry", self.saveGeometry()) @@ -281,143 +250,139 @@ class AnimeTracker(QMainWindow): def load_tabs(self): self.tab_widget.clear() - show_pre = True - if self.season_filter is not None and self.season_filter != '': - show_pre = False - if self.year_filter is not None and self.year_filter >= 2010: - show_pre = False - if show_pre: - pre_entries = self.backend.get_pre_2010_entries(self.status_filter, self.type_filter, self.search_text, self.year_filter if self.year_filter and self.year_filter < 2010 else None) - pre_tab = QScrollArea() - pre_tab.setWidgetResizable(True) - pre_content = QWidget() - pre_layout = QVBoxLayout(pre_content) - if pre_entries: - table = CustomTableWidget(self, is_pre=True) - table.setRowCount(len(pre_entries)) - table.setColumnCount(7) - headers = ['ID', 'Year', 'Name', 'Type', 'Status', 'Comment', 'Actions'] - table.setHorizontalHeaderLabels(headers) - table.setColumnHidden(0, True) - table.setAlternatingRowColors(True) - table.setShowGrid(True) - header = table.horizontalHeader() - header.setStretchLastSection(False) - table.setSelectionBehavior(QAbstractItemView.SelectRows) - table.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - header.setSectionResizeMode(0, QHeaderView.Fixed) # ID hidden - header.setSectionResizeMode(1, QHeaderView.ResizeToContents) # Year - header.setSectionResizeMode(2, QHeaderView.Stretch) # Name - header.setSectionResizeMode(3, QHeaderView.ResizeToContents) # Type - header.setSectionResizeMode(4, QHeaderView.ResizeToContents) # Status - header.setSectionResizeMode(5, QHeaderView.Stretch) # Comment - header.setSectionResizeMode(6, QHeaderView.ResizeToContents) # Actions - table.setColumnWidth(0, 0) # Set ID column width to 0 to ensure it's hidden - font = QFont() - font.setPointSize(int(10 * self.table_scale)) - header_font = QFont() - header_font.setPointSize(int(10 * self.table_scale)) - header_font.setBold(True) - table.setFont(font) - table.horizontalHeader().setFont(header_font) - table.verticalHeader().setFont(font) - for row, entry in enumerate(pre_entries): - col = 0 - # ID - id_item = QTableWidgetItem(str(entry[0])) - table.setItem(row, col, id_item) - col += 1 - # Year - year_item = QTableWidgetItem(str(entry[2])) - year_item.setFont(font) - table.setItem(row, col, year_item) - col += 1 - # Name - name = entry[1] - url = entry[7] - name_label = QLabel() - name_font = QFont(font) - if entry[4] == 'watching': - name_font.setItalic(True) - elif entry[4] == 'completed': - name_font.setStrikeOut(True) - name_label.setFont(name_font) - if url: - name_escaped = html.escape(name) - name_label.setText(f'{name_escaped}') - name_label.setOpenExternalLinks(True) - else: - name_label.setText(html.escape(name)) - name_label.setStyleSheet("padding-left: 10px;") - table.setCellWidget(row, col, name_label) - col += 1 - # Type - type_item = QTableWidgetItem(entry[5] or '') - type_item.setFont(font) - table.setItem(row, col, type_item) - col += 1 - # Status - status_item = QTableWidgetItem(entry[4]) - status_item.setFont(font) - table.setItem(row, col, status_item) - col += 1 - # Comment - comment_item = QTableWidgetItem(entry[6] or '') - comment_item.setFont(font) - table.setItem(row, col, comment_item) - col += 1 - # Actions - actions_widget = self.create_actions_widget(entry[0], entry[4]) - for child in actions_widget.findChildren(QToolButton): - child.setFont(font) - table.setCellWidget(row, col, actions_widget) - table.resizeColumnsToContents() - table.resizeRowsToContents() - # Calculate fixed height - height = table.horizontalHeader().height() + 2 # small margin - for i in range(table.rowCount()): - height += table.rowHeight(i) - table.setFixedHeight(height) - # Apply status colors - status_col = 4 - for r in range(table.rowCount()): - status = table.item(r, status_col).text() - if status == 'unwatched': - color = QColor(255, 255, 255) - elif status == 'watching': - color = QColor(255, 255, 0) - elif status == 'completed': - color = QColor(0, 255, 0) - else: - color = QColor(255, 255, 255) - for c in range(table.columnCount()): - if c == 0: - continue # skip hidden id - item = table.item(r, c) - if item: - item.setBackground(color) - widget = table.cellWidget(r, c) - if widget: - widget.setStyleSheet(f"background-color: {color.name()};") - pre_layout.addWidget(table) - pre_tab.setWidget(pre_content) - tab_text = "Pre-2010" - completed = False - total = len(pre_entries) - if total > 0: - comp = sum(1 for e in pre_entries if e[4] == 'completed') - perc = (comp / total * 100) - tab_text += f" ({perc:.0f}%)" - if comp == total: - completed = True - index = self.tab_widget.addTab(pre_tab, tab_text) - if completed: - self.tab_widget.tabBar().setTabTextColor(index, QColor('gray')) + self.tables = [] + # Pre-2010 tab + pre_entries = self.backend.get_pre_2010_entries() + pre_tab = QScrollArea() + pre_tab.setWidgetResizable(True) + pre_content = QWidget() + pre_layout = QVBoxLayout(pre_content) + if pre_entries: + table = CustomTableWidget(self, is_pre=True) + table.is_pre = True + self.tables.append(table) + table.setRowCount(len(pre_entries)) + table.setColumnCount(7) + headers = ['ID', 'Year', 'Name', 'Type', 'Status', 'Comment', 'Actions'] + table.setHorizontalHeaderLabels(headers) + table.setColumnHidden(0, True) + table.setAlternatingRowColors(True) + table.setShowGrid(True) + header = table.horizontalHeader() + header.setStretchLastSection(False) + table.setSelectionBehavior(QAbstractItemView.SelectRows) + table.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + header.setSectionResizeMode(0, QHeaderView.Fixed) # ID hidden + header.setSectionResizeMode(1, QHeaderView.ResizeToContents) # Year + header.setSectionResizeMode(2, QHeaderView.Stretch) # Name + header.setSectionResizeMode(3, QHeaderView.ResizeToContents) # Type + header.setSectionResizeMode(4, QHeaderView.ResizeToContents) # Status + header.setSectionResizeMode(5, QHeaderView.Stretch) # Comment + header.setSectionResizeMode(6, QHeaderView.ResizeToContents) # Actions + table.setColumnWidth(0, 0) # Set ID column width to 0 to ensure it's hidden + font = QFont() + font.setPointSize(int(10 * self.table_scale)) + header_font = QFont() + header_font.setPointSize(int(10 * self.table_scale)) + header_font.setBold(True) + table.setFont(font) + table.horizontalHeader().setFont(header_font) + table.verticalHeader().setFont(font) + for row, entry in enumerate(pre_entries): + col = 0 + # ID + id_item = QTableWidgetItem(str(entry[0])) + table.setItem(row, col, id_item) + col += 1 + # Year + year_item = QTableWidgetItem(str(entry[2])) + year_item.setFont(font) + table.setItem(row, col, year_item) + col += 1 + # Name + name = entry[1] + url = entry[7] + name_label = QLabel() + name_font = QFont(font) + if entry[4] == 'watching': + name_font.setItalic(True) + elif entry[4] == 'completed': + name_font.setStrikeOut(True) + name_label.setFont(name_font) + if url: + name_escaped = html.escape(name) + name_label.setText(f'{name_escaped}') + name_label.setOpenExternalLinks(True) + else: + name_label.setText(html.escape(name)) + name_label.setStyleSheet("padding-left: 10px;") + table.setCellWidget(row, col, name_label) + col += 1 + # Type + type_item = QTableWidgetItem(entry[5] or '') + type_item.setFont(font) + table.setItem(row, col, type_item) + col += 1 + # Status + status_item = QTableWidgetItem(entry[4]) + status_item.setFont(font) + table.setItem(row, col, status_item) + col += 1 + # Comment + comment_item = QTableWidgetItem(entry[6] or '') + comment_item.setFont(font) + table.setItem(row, col, comment_item) + col += 1 + # Actions + actions_widget = self.create_actions_widget(entry[0], entry[4]) + for child in actions_widget.findChildren(QToolButton): + child.setFont(font) + table.setCellWidget(row, col, actions_widget) + table.resizeColumnsToContents() + table.resizeRowsToContents() + # Calculate fixed height + height = table.horizontalHeader().height() + 2 # small margin + for i in range(table.rowCount()): + height += table.rowHeight(i) + table.setFixedHeight(height) + # Apply status colors + status_col = 4 + for r in range(table.rowCount()): + status = table.item(r, status_col).text() + if status == 'unwatched': + color = QColor(255, 255, 255) + elif status == 'watching': + color = QColor(255, 255, 0) + elif status == 'completed': + color = QColor(0, 255, 0) + else: + color = QColor(255, 255, 255) + for c in range(table.columnCount()): + if c == 0: + continue # skip hidden id + item = table.item(r, c) + if item: + item.setBackground(color) + widget = table.cellWidget(r, c) + if widget: + widget.setStyleSheet(f"background-color: {color.name()};") + pre_layout.addWidget(table) + pre_tab.setWidget(pre_content) + tab_text = "Pre-2010" + completed = False + total = len(pre_entries) + if total > 0: + comp = sum(1 for e in pre_entries if e[4] == 'completed') + perc = (comp / total * 100) + tab_text += f" ({perc:.0f}%)" + if comp == total: + completed = True + index = self.tab_widget.addTab(pre_tab, tab_text) + if completed: + self.tab_widget.tabBar().setTabTextColor(index, QColor('gray')) # Years >= 2010 years = self.backend.get_years() - if self.year_filter is not None: - years = [self.year_filter] if self.year_filter in years else [] for year in years: year_tab = QScrollArea() year_tab.setWidgetResizable(True) @@ -426,126 +391,123 @@ class AnimeTracker(QMainWindow): total_entries = 0 comp_entries = 0 for season in ['winter', 'spring', 'summer', 'fall', '']: - show_section = True - if self.season_filter is not None: - if season != self.season_filter: - show_section = False - if show_section: - entries = self.backend.get_entries_for_season(year, season, self.status_filter, self.type_filter, self.search_text) - if entries: - s_name = season.capitalize() if season else 'Other' - label = QLabel(s_name) - season_font = QFont() - season_font.setPointSize(int(12 * self.table_scale)) - season_font.setBold(True) - label.setFont(season_font) - layout.addWidget(label) - table = CustomTableWidget(self, is_pre=False) - table.setRowCount(len(entries)) - table.setColumnCount(6) - headers = ['ID', 'Name', 'Type', 'Status', 'Comment', 'Actions'] - table.setHorizontalHeaderLabels(headers) - table.setColumnHidden(0, True) - table.setAlternatingRowColors(True) - table.setShowGrid(True) - header = table.horizontalHeader() - header.setStretchLastSection(False) - table.setSelectionBehavior(QAbstractItemView.SelectRows) - table.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - header.setSectionResizeMode(0, QHeaderView.Fixed) # ID hidden - header.setSectionResizeMode(1, QHeaderView.Stretch) # Name - header.setSectionResizeMode(2, QHeaderView.ResizeToContents) # Type - header.setSectionResizeMode(3, QHeaderView.ResizeToContents) # Status - header.setSectionResizeMode(4, QHeaderView.Stretch) # Comment - header.setSectionResizeMode(5, QHeaderView.ResizeToContents) # Actions - table.setColumnWidth(0, 0) # Set ID column width to 0 to ensure it's hidden - font = QFont() - font.setPointSize(int(10 * self.table_scale)) - header_font = QFont() - header_font.setPointSize(int(10 * self.table_scale)) - header_font.setBold(True) - table.setFont(font) - table.horizontalHeader().setFont(header_font) - table.verticalHeader().setFont(font) - for row, entry in enumerate(entries): - col = 0 - # ID - id_item = QTableWidgetItem(str(entry[0])) - table.setItem(row, col, id_item) - col += 1 - # Name - name = entry[1] - url = entry[7] - name_label = QLabel() - name_font = QFont(font) - if entry[4] == 'watching': - name_font.setItalic(True) - elif entry[4] == 'completed': - name_font.setStrikeOut(True) - name_label.setFont(name_font) - if url: - name_escaped = html.escape(name) - name_label.setText(f'{name_escaped}') - name_label.setOpenExternalLinks(True) - else: - name_label.setText(html.escape(name)) - name_label.setStyleSheet("padding-left: 10px;") - table.setCellWidget(row, col, name_label) - col += 1 - # Type - type_item = QTableWidgetItem(entry[5] or '') - type_item.setFont(font) - table.setItem(row, col, type_item) - col += 1 - # Status - status_item = QTableWidgetItem(entry[4]) - status_item.setFont(font) - table.setItem(row, col, status_item) - col += 1 - # Comment - comment_item = QTableWidgetItem(entry[6] or '') - comment_item.setFont(font) - table.setItem(row, col, comment_item) - col += 1 - # Actions - actions_widget = self.create_actions_widget(entry[0], entry[4]) - for child in actions_widget.findChildren(QToolButton): - child.setFont(font) - table.setCellWidget(row, col, actions_widget) - table.resizeColumnsToContents() - table.resizeRowsToContents() - # Calculate fixed height - height = table.horizontalHeader().height() + 2 # small margin - for i in range(table.rowCount()): - height += table.rowHeight(i) - table.setFixedHeight(height) - # Apply status colors - status_col = 3 - for r in range(table.rowCount()): - status = table.item(r, status_col).text() - if status == 'unwatched': - color = QColor(255, 255, 255) - elif status == 'watching': - color = QColor(255, 255, 0) - elif status == 'completed': - color = QColor(0, 255, 0) - else: - color = QColor(255, 255, 255) - for c in range(table.columnCount()): - if c == 0: - continue # skip hidden id - item = table.item(r, c) - if item: - item.setBackground(color) - widget = table.cellWidget(r, c) - if widget: - widget.setStyleSheet(f"background-color: {color.name()};") - layout.addWidget(table) - total_entries += len(entries) - comp_entries += sum(1 for e in entries if e[4] == 'completed') + entries = self.backend.get_entries_for_season(year, season) + if entries: + s_name = season.capitalize() if season else 'Other' + label = QLabel(s_name) + season_font = QFont() + season_font.setPointSize(int(12 * self.table_scale)) + season_font.setBold(True) + label.setFont(season_font) + layout.addWidget(label) + table = CustomTableWidget(self, is_pre=False) + table.is_pre = False + self.tables.append(table) + table.setRowCount(len(entries)) + table.setColumnCount(6) + headers = ['ID', 'Name', 'Type', 'Status', 'Comment', 'Actions'] + table.setHorizontalHeaderLabels(headers) + table.setColumnHidden(0, True) + table.setAlternatingRowColors(True) + table.setShowGrid(True) + header = table.horizontalHeader() + header.setStretchLastSection(False) + table.setSelectionBehavior(QAbstractItemView.SelectRows) + table.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + header.setSectionResizeMode(0, QHeaderView.Fixed) # ID hidden + header.setSectionResizeMode(1, QHeaderView.Stretch) # Name + header.setSectionResizeMode(2, QHeaderView.ResizeToContents) # Type + header.setSectionResizeMode(3, QHeaderView.ResizeToContents) # Status + header.setSectionResizeMode(4, QHeaderView.Stretch) # Comment + header.setSectionResizeMode(5, QHeaderView.ResizeToContents) # Actions + table.setColumnWidth(0, 0) # Set ID column width to 0 to ensure it's hidden + font = QFont() + font.setPointSize(int(10 * self.table_scale)) + header_font = QFont() + header_font.setPointSize(int(10 * self.table_scale)) + header_font.setBold(True) + table.setFont(font) + table.horizontalHeader().setFont(header_font) + table.verticalHeader().setFont(font) + for row, entry in enumerate(entries): + col = 0 + # ID + id_item = QTableWidgetItem(str(entry[0])) + table.setItem(row, col, id_item) + col += 1 + # Name + name = entry[1] + url = entry[7] + name_label = QLabel() + name_font = QFont(font) + if entry[4] == 'watching': + name_font.setItalic(True) + elif entry[4] == 'completed': + name_font.setStrikeOut(True) + name_label.setFont(name_font) + if url: + name_escaped = html.escape(name) + name_label.setText(f'{name_escaped}') + name_label.setOpenExternalLinks(True) + else: + name_label.setText(html.escape(name)) + name_label.setStyleSheet("padding-left: 10px;") + table.setCellWidget(row, col, name_label) + col += 1 + # Type + type_item = QTableWidgetItem(entry[5] or '') + type_item.setFont(font) + table.setItem(row, col, type_item) + col += 1 + # Status + status_item = QTableWidgetItem(entry[4]) + status_item.setFont(font) + table.setItem(row, col, status_item) + col += 1 + # Comment + comment_item = QTableWidgetItem(entry[6] or '') + comment_item.setFont(font) + table.setItem(row, col, comment_item) + col += 1 + # Actions + actions_widget = self.create_actions_widget(entry[0], entry[4]) + for child in actions_widget.findChildren(QToolButton): + child.setFont(font) + table.setCellWidget(row, col, actions_widget) + table.resizeColumnsToContents() + table.resizeRowsToContents() + # Calculate fixed height + height = table.horizontalHeader().height() + 2 # small margin + for i in range(table.rowCount()): + height += table.rowHeight(i) + table.setFixedHeight(height) + # Apply status colors + status_col = 3 + for r in range(table.rowCount()): + status = table.item(r, status_col).text() + if status == 'unwatched': + color = QColor(255, 255, 255) + elif status == 'watching': + color = QColor(255, 255, 0) + elif status == 'completed': + color = QColor(0, 255, 0) + else: + color = QColor(255, 255, 255) + for c in range(table.columnCount()): + if c == 0: + continue # skip hidden id + item = table.item(r, c) + if item: + item.setBackground(color) + widget = table.cellWidget(r, c) + if widget: + widget.setStyleSheet(f"background-color: {color.name()};") + layout.addWidget(table) + total_entries += len(entries) + comp_entries += sum(1 for e in entries if e[4] == 'completed') year_tab.setWidget(content) - if total_entries > 0 or (self.year_filter == year): + if total_entries > 0: perc = (comp_entries / total_entries * 100) if total_entries > 0 else 0 tab_text = f"{year} ({perc:.0f}%)" completed = (comp_entries == total_entries)