From a9d7e3783fed12b60620a396f2f05da730bb02c8 Mon Sep 17 00:00:00 2001 From: Bernd Date: Fri, 8 Aug 2025 16:31:49 +0500 Subject: [PATCH] code refactoring --- backend.py | 231 ++++++++------ frontend.py | 855 ++++++++++++++++++++++++++++++---------------------- readme.md | 2 +- 3 files changed, 638 insertions(+), 450 deletions(-) diff --git a/backend.py b/backend.py index 2c7245a..a9461ec 100644 --- a/backend.py +++ b/backend.py @@ -1,17 +1,26 @@ +# backend.py import sqlite3 import csv import logging import html +from typing import Dict, Any, Optional # Set up logging -logging.basicConfig(filename='anime_tracker.log', level=logging.ERROR, +logging.basicConfig(filename='anime_tracker.log', level=logging.ERROR, format='%(asctime)s - %(levelname)s - %(message)s') +ALLOWED_STATUSES = {'unwatched', 'watching', 'completed'} + + class AnimeBackend: - def __init__(self): - self.db = sqlite3.connect('anime_backlog.db', isolation_level=None) # Autocommit mode to prevent locks - self.db.execute('PRAGMA journal_mode=WAL') # Use WAL mode for better concurrency + def __init__(self, db_path: str = 'anime_backlog.db'): + # Use autocommit mode (isolation_level=None). Keep commits explicit where needed. + self.db_path = db_path + self.db = sqlite3.connect(self.db_path, isolation_level=None, check_same_thread=False) + self.db.execute('PRAGMA journal_mode=WAL') # better concurrency + # Enable returning rows as tuples (default). Create table and useful indexes. self.create_table() + self.create_indexes() def create_table(self): try: @@ -28,10 +37,51 @@ class AnimeBackend: url TEXT ) """) + # No explicit commit required in autocommit mode, but keep for clarity self.db.commit() except Exception as e: logging.error(f"Error creating table: {e}") + def create_indexes(self): + try: + cursor = self.db.cursor() + cursor.execute("CREATE INDEX IF NOT EXISTS idx_year_season ON anime(year, season)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_name_year_season ON anime(name, year, season)") + self.db.commit() + except Exception as e: + logging.error(f"Error creating indexes: {e}") + + def sanitize_data(self, data: Dict[str, Any]) -> Dict[str, Any]: + """ + Prepare and validate incoming data (from frontend or CSV) before writing to DB. + Backend is responsible for HTML-escaping text fields to avoid double-escaping problems. + """ + name = data.get('name') or '' + name = name.strip() + year = data.get('year') or 0 + try: + year = int(year) + except Exception: + year = 0 + season = (data.get('season') or '').strip() + status = (data.get('status') or 'unwatched').strip() + if status not in ALLOWED_STATUSES: + status = 'unwatched' + type_ = (data.get('type') or '').strip() + comment = (data.get('comment') or '').strip() + url = (data.get('url') or '').strip() + + # HTML-escape user visible fields to prevent HTML injection in UI. + return { + 'name': html.escape(name), + 'year': year, + 'season': season, + 'status': status, + 'type': html.escape(type_), + 'comment': html.escape(comment), + 'url': url + } + def get_pre_2010_entries(self): try: cursor = self.db.cursor() @@ -50,7 +100,7 @@ class AnimeBackend: logging.error(f"Error getting years: {e}") return [] - def get_entries_for_season(self, year, season): + def get_entries_for_season(self, year: int, season: str): try: cursor = self.db.cursor() cursor.execute("SELECT * FROM anime WHERE year = ? AND season = ? ORDER BY name", (year, season)) @@ -59,7 +109,7 @@ class AnimeBackend: logging.error(f"Error getting entries for season {season} in year {year}: {e}") return [] - def get_anime_by_id(self, anime_id): + def get_anime_by_id(self, anime_id: int) -> Optional[tuple]: try: cursor = self.db.cursor() cursor.execute("SELECT * FROM anime WHERE id = ?", (anime_id,)) @@ -68,86 +118,52 @@ class AnimeBackend: logging.error(f"Error getting anime by id {anime_id}: {e}") return None - def add_anime(self, data): + def add_anime(self, data: Dict[str, Any]): try: - # Sanitize string inputs - sanitized_data = { - 'name': html.escape(data['name'].strip()) if data['name'] else '', - 'year': data['year'], - 'season': data['season'].strip() if data['season'] else '', - 'status': data['status'].strip() if data['status'] else 'unwatched', - 'type': data['type'].strip() if data['type'] else '', - 'comment': html.escape(data['comment'].strip()) if data['comment'] else '', - 'url': data['url'].strip() if data['url'] else '' - } + d = self.sanitize_data(data) cursor = self.db.cursor() cursor.execute( "INSERT INTO anime (name, year, season, status, type, comment, url) VALUES (?, ?, ?, ?, ?, ?, ?)", - ( - sanitized_data['name'], - sanitized_data['year'], - sanitized_data['season'], - sanitized_data['status'], - sanitized_data['type'], - sanitized_data['comment'], - sanitized_data['url'] - ) + (d['name'], d['year'], d['season'], d['status'], d['type'], d['comment'], d['url']) ) + # autocommit mode ensures immediate write, but call commit for clarity self.db.commit() + return cursor.lastrowid except Exception as e: logging.error(f"Error adding anime: {e}") - self.db.rollback() - def edit_anime(self, anime_id, data): + def edit_anime(self, anime_id: int, data: Dict[str, Any]): try: - # Sanitize string inputs - sanitized_data = { - 'name': html.escape(data['name'].strip()) if data['name'] else '', - 'year': data['year'], - 'season': data['season'].strip() if data['season'] else '', - 'status': data['status'].strip() if data['status'] else 'unwatched', - 'type': data['type'].strip() if data['type'] else '', - 'comment': html.escape(data['comment'].strip()) if data['comment'] else '', - 'url': data['url'].strip() if data['url'] else '' - } + d = self.sanitize_data(data) cursor = self.db.cursor() cursor.execute( "UPDATE anime SET name=?, year=?, season=?, status=?, type=?, comment=?, url=? WHERE id=?", - ( - sanitized_data['name'], - sanitized_data['year'], - sanitized_data['season'], - sanitized_data['status'], - sanitized_data['type'], - sanitized_data['comment'], - sanitized_data['url'], - anime_id - ) + (d['name'], d['year'], d['season'], d['status'], d['type'], d['comment'], d['url'], anime_id) ) self.db.commit() except Exception as e: logging.error(f"Error editing anime id {anime_id}: {e}") - self.db.rollback() - def delete_anime(self, anime_id): + def delete_anime(self, anime_id: int): try: cursor = self.db.cursor() cursor.execute("DELETE FROM anime WHERE id = ?", (anime_id,)) self.db.commit() except Exception as e: logging.error(f"Error deleting anime id {anime_id}: {e}") - self.db.rollback() - def change_status(self, anime_id, new_status): + def change_status(self, anime_id: int, new_status: str): try: + if new_status not in ALLOWED_STATUSES: + logging.error(f"Attempt to set invalid status: {new_status}") + return cursor = self.db.cursor() cursor.execute("UPDATE anime SET status = ? WHERE id = ?", (new_status, anime_id)) self.db.commit() except Exception as e: logging.error(f"Error changing status for anime id {anime_id}: {e}") - self.db.rollback() - def add_placeholders_for_year(self, year): + def add_placeholders_for_year(self, year: int): try: cursor = self.db.cursor() for season in ['winter', 'spring', 'summer', 'fall', '']: @@ -158,69 +174,80 @@ class AnimeBackend: self.db.commit() except Exception as e: logging.error(f"Error adding placeholders for year {year}: {e}") - self.db.rollback() - def import_from_csv(self, file_name): + def import_from_csv(self, file_name: str): + skipped = 0 + inserted = 0 try: - with open(file_name, 'r', newline='') as f: + with open(file_name, 'r', newline='', encoding='utf-8') as f: reader = csv.reader(f) header = next(reader, None) - if header: - cursor = self.db.cursor() - for row in reader: - if len(row) == 7: - name, year_str, season, status, type_, comment, url = row - elif len(row) == 8: - _, name, year_str, season, status, type_, comment, url = row - else: - continue - try: - year = int(year_str) - except ValueError: - continue - # Sanitize CSV inputs - name = html.escape(name.strip()) if name else '' - season = season.strip() if season else '' - status = status.strip() if status else 'unwatched' - type_ = type_.strip() if type_ else '' - comment = html.escape(comment.strip()) if comment else '' - url = url.strip() if url else '' + cursor = self.db.cursor() + for row in reader: + # Accept either 7 columns (no id) or 8 columns (with id) + if len(row) == 7: + name, year_str, season, status, type_, comment, url = row + elif len(row) == 8: + _, name, year_str, season, status, type_, comment, url = row + else: + skipped += 1 + logging.error(f"Skipping CSV row with unexpected length {len(row)}: {row}") + continue + try: + year = int(year_str) + except ValueError: + skipped += 1 + logging.error(f"Skipping CSV row with invalid year: {row}") + continue + # Prepare and sanitize + data = { + 'name': name, + 'year': year, + 'season': season, + 'status': status, + 'type': type_, + 'comment': comment, + 'url': url + } + d = self.sanitize_data(data) + # Avoid duplicates by name/year/season + cursor.execute( + "SELECT id FROM anime WHERE name = ? AND year = ? AND season = ?", + (d['name'], d['year'], d['season']) + ) + if not cursor.fetchone(): cursor.execute( - "SELECT id FROM anime WHERE name = ? AND year = ? AND season = ?", - (name, year, season) + "INSERT INTO anime (name, year, season, status, type, comment, url) VALUES (?, ?, ?, ?, ?, ?, ?)", + (d['name'], d['year'], d['season'], d['status'], d['type'], d['comment'], d['url']) ) - if not cursor.fetchone(): - cursor.execute( - "INSERT INTO anime (name, year, season, status, type, comment, url) VALUES (?, ?, ?, ?, ?, ?, ?)", - (name, year, season, status, type_, comment, url) - ) - self.db.commit() + inserted += 1 + self.db.commit() except Exception as e: logging.error(f"Error importing from CSV {file_name}: {e}") - self.db.rollback() + finally: + logging.info(f"CSV import finished: inserted={inserted}, skipped={skipped}") - def export_to_csv(self, file_name): + def export_to_csv(self, file_name: str): try: cursor = self.db.cursor() cursor.execute("SELECT * FROM anime") rows = cursor.fetchall() - with open(file_name, 'w', newline='') as f: + with open(file_name, 'w', newline='', encoding='utf-8') as f: writer = csv.writer(f, quoting=csv.QUOTE_MINIMAL) writer.writerow(['id', 'name', 'year', 'season', 'status', 'type', 'comment', 'url']) writer.writerows(rows) except Exception as e: logging.error(f"Error exporting to CSV {file_name}: {e}") - def delete_year(self, year): + def delete_year(self, year: int): try: cursor = self.db.cursor() cursor.execute("DELETE FROM anime WHERE year = ?", (year,)) self.db.commit() except Exception as e: logging.error(f"Error deleting year {year}: {e}") - self.db.rollback() - def get_total_entries(self): + def get_total_entries(self) -> int: try: cursor = self.db.cursor() cursor.execute("SELECT COUNT(*) FROM anime") @@ -229,7 +256,7 @@ class AnimeBackend: logging.error(f"Error getting total entries: {e}") return 0 - def get_completed_entries(self): + def get_completed_entries(self) -> int: try: cursor = self.db.cursor() cursor.execute("SELECT COUNT(*) FROM anime WHERE status = 'completed'") @@ -242,7 +269,23 @@ class AnimeBackend: try: cursor = self.db.cursor() cursor.execute("SELECT type, COUNT(*) FROM anime GROUP BY type ORDER BY 2 DESC") - return cursor.fetchall() # Returns list of tuples: (type, count) + return cursor.fetchall() # list of (type, count) except Exception as e: logging.error(f"Error getting entries by type: {e}") - return [] \ No newline at end of file + return [] + + def close(self): + try: + if self.db: + self.db.close() + self.db = None + except Exception as e: + logging.error(f"Error closing DB: {e}") + + def __del__(self): + try: + if getattr(self, 'db', None): + self.db.close() + except Exception: + pass + diff --git a/frontend.py b/frontend.py index 4e3ca6b..2b01f52 100644 --- a/frontend.py +++ b/frontend.py @@ -1,47 +1,62 @@ + +# frontend.py import sys import os import random import re -import html import logging from datetime import datetime +from urllib.parse import urlparse + from PyQt5.QtWidgets import ( QMainWindow, QTabWidget, QScrollArea, QWidget, QVBoxLayout, QTableWidget, QTableWidgetItem, QLabel, QToolButton, QHBoxLayout, QDialog, QFormLayout, QLineEdit, QSpinBox, QComboBox, QTextEdit, QDialogButtonBox, QAction, QFileDialog, QMessageBox, - QInputDialog, QApplication, QAbstractItemView, QSizePolicy, QHeaderView + QInputDialog, QApplication, QAbstractItemView, QSizePolicy, QHeaderView, QShortcut ) from PyQt5.QtCore import Qt, QSettings -from PyQt5.QtGui import QColor, QIcon, QFont +from PyQt5.QtGui import QColor, QIcon, QFont, QKeySequence from backend import AnimeBackend # Set up logging -logging.basicConfig(filename='anime_tracker.log', level=logging.ERROR, +logging.basicConfig(filename='anime_tracker.log', level=logging.ERROR, format='%(asctime)s - %(levelname)s - %(message)s') + +def is_valid_url(url: str) -> bool: + """Simple URL validation.""" + if not url: + return True + try: + p = urlparse(url) + return p.scheme in ('http', 'https') and bool(p.netloc) + except Exception: + return False + + class AnimeDialog(QDialog): + # season mapping shared across instances + SEASON_MAP = { + 'Winter': 'winter', + 'Spring': 'spring', + 'Summer': 'summer', + 'Fall': 'fall', + 'Other': '' + } + REVERSE_SEASON_MAP = {v: k for k, v in SEASON_MAP.items()} + def __init__(self, parent, entry=None, default_year=None, default_season=None): super().__init__(parent) self.setWindowTitle("Add Anime" if entry is None else "Edit Anime") layout = QFormLayout(self) self.name_edit = QLineEdit() - self.name_edit.setMaxLength(255) # Prevent overly long inputs + self.name_edit.setMaxLength(255) layout.addRow("Name", self.name_edit) self.year_spin = QSpinBox() self.year_spin.setRange(1960, 2073) layout.addRow("Year", self.year_spin) self.season_combo = QComboBox() self.season_combo.addItems(['Winter', 'Spring', 'Summer', 'Fall', 'Other']) - # Map display names to database values - self.season_map = { - 'Winter': 'winter', - 'Spring': 'spring', - 'Summer': 'summer', - 'Fall': 'fall', - 'Other': '' - } - # Reverse map for setting current selection - self.reverse_season_map = {v: k for k, v in self.season_map.items()} layout.addRow("Season", self.season_combo) self.type_combo = QComboBox() self.type_combo.addItems(['TV', 'Movie', 'OVA', 'Special', 'Short TV', 'Other']) @@ -50,30 +65,37 @@ class AnimeDialog(QDialog): self.status_combo.addItems(['unwatched', 'watching', 'completed']) layout.addRow("Status", self.status_combo) self.comment_edit = QTextEdit() - self.comment_edit.setAcceptRichText(False) # Prevent HTML injection + self.comment_edit.setAcceptRichText(False) layout.addRow("Comment", self.comment_edit) self.url_edit = QLineEdit() - self.url_edit.setMaxLength(2048) # Reasonable limit for URLs + self.url_edit.setMaxLength(2048) layout.addRow("MAL URL", self.url_edit) buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addRow(buttons) + + # trigger the OK button when Ctrl+Enter (or Ctrl+Return) is pressed + ok_button = buttons.button(QDialogButtonBox.Ok) + QShortcut(QKeySequence("Ctrl+Return"), self, activated=ok_button.click) + QShortcut(QKeySequence("Ctrl+Enter"), self, activated=ok_button.click) + if entry: - # Unescape for display in input fields - self.name_edit.setText(html.unescape(entry[1])) + # Backend stores HTML-escaped text; unescape for display + import html as _html + self.name_edit.setText(_html.unescape(entry[1])) self.year_spin.setValue(entry[2]) - self.season_combo.setCurrentText((entry[3] or "Other").capitalize()) + display_season = (entry[3] or '') + self.season_combo.setCurrentText(self.REVERSE_SEASON_MAP.get(display_season, 'Other')) self.status_combo.setCurrentText(entry[4]) self.type_combo.setCurrentText(entry[5] or '') - self.comment_edit.setPlainText(html.unescape(entry[6] or '')) + self.comment_edit.setPlainText(_html.unescape(entry[6] or '')) self.url_edit.setText(entry[7] or '') else: if default_year is not None: self.year_spin.setValue(default_year) if default_season is not None: - # Map database season value to display name for default - display_season = self.reverse_season_map.get(default_season, 'Other') + self.season_combo.setCurrentText(self.REVERSE_SEASON_MAP.get(default_season, 'Other')) self.year_spin.valueChanged.connect(self.update_season) self.update_season(self.year_spin.value()) @@ -85,21 +107,27 @@ class AnimeDialog(QDialog): self.season_combo.setEnabled(True) def get_data(self): - # Convert display season name back to database value + """ + Return raw user inputs (no escaping here). + Backend will be responsible for sanitization/escaping. + """ display_season = self.season_combo.currentText().strip() - db_season = self.season_map.get(display_season, '') - - # Sanitize inputs by escaping special characters + db_season = self.SEASON_MAP.get(display_season, '') + url_text = self.url_edit.text().strip() + if url_text and not is_valid_url(url_text): + # Let the caller handle validation (here we still return data but invalid URL will be flagged by caller if needed) + pass return { - 'name': html.escape(self.name_edit.text().strip()), + 'name': self.name_edit.text().strip(), 'year': self.year_spin.value(), - 'season': html.escape(db_season), # Use mapped database value - 'status': html.escape(self.status_combo.currentText().strip()), - 'type': html.escape(self.type_combo.currentText().strip()), - 'comment': html.escape(self.comment_edit.toPlainText().strip()), - 'url': html.escape(self.url_edit.text().strip()) + 'season': db_season, + 'status': self.status_combo.currentText().strip(), + 'type': self.type_combo.currentText().strip(), + 'comment': self.comment_edit.toPlainText().strip(), + 'url': url_text } + class ShortcutsDialog(QDialog): def __init__(self, parent): super().__init__(parent) @@ -128,6 +156,7 @@ Table-specific Shortcuts (apply to selected entry): buttons.accepted.connect(self.accept) layout.addWidget(buttons) + class CustomTableWidget(QTableWidget): def __init__(self, parent, is_pre): super().__init__() @@ -140,7 +169,10 @@ class CustomTableWidget(QTableWidget): selected_rows = self.selectionModel().selectedRows() if selected_rows: row = selected_rows[0].row() - anime_id = int(self.item(row, 0).text()) + try: + anime_id = int(self.item(row, 0).text()) + except Exception: + return if key == Qt.Key_Delete: self.parent_window.delete_anime(anime_id) return @@ -161,13 +193,13 @@ class CustomTableWidget(QTableWidget): return super().keyPressEvent(event) + class AnimeTracker(QMainWindow): def __init__(self): super().__init__() self.settings = QSettings("xAI", "AnimeBacklogTracker") self.last_used_season = self.settings.value("lastUsedSeason", "winter") self.setWindowTitle("Anime Backlog Tracker") - # Add application icon icon_path = os.path.join(os.path.dirname(__file__), './icons/anime-app-icon.png') if os.path.exists(icon_path): self.setWindowIcon(QIcon(icon_path)) @@ -179,7 +211,8 @@ class AnimeTracker(QMainWindow): self.filter_bar = QWidget() filter_layout = QHBoxLayout(self.filter_bar) label_search = QLabel("Search:") - self.search_edit = QLineEdit() + from PyQt5.QtWidgets import QLineEdit as _QLineEdit + self.search_edit = _QLineEdit() self.search_edit.setPlaceholderText("Search name") self.search_edit.textChanged.connect(self.filter_tables) filter_layout.addWidget(label_search) @@ -198,32 +231,72 @@ class AnimeTracker(QMainWindow): self.set_current_tab_by_identifier(last_tab) self.tab_widget.setFocus() + # ---------------------------- + # Utility: scroll/identify tab + # ---------------------------- def get_current_scroll_pos(self): widget = self.tab_widget.currentWidget() - if widget: + if isinstance(widget, QScrollArea): return widget.verticalScrollBar().value() return 0 def set_current_scroll_pos(self, pos): widget = self.tab_widget.currentWidget() - if widget: + if isinstance(widget, QScrollArea): widget.verticalScrollBar().setValue(pos) + def get_current_tab_identifier(self): + index = self.tab_widget.currentIndex() + if index == -1: + return None + tab_text = self.tab_widget.tabText(index) + if "Pre-2010" in tab_text: + return "pre" + else: + try: + return int(tab_text.split(" ")[0]) + except Exception: + return None + + def set_current_tab_by_identifier(self, identifier): + if identifier is None: + return + if identifier == "pre": + for i in range(self.tab_widget.count()): + if "Pre-2010" in self.tab_widget.tabText(i): + self.tab_widget.setCurrentIndex(i) + return + else: + for i in range(self.tab_widget.count()): + t = self.tab_widget.tabText(i) + if t.startswith(str(identifier) + " ("): + self.tab_widget.setCurrentIndex(i) + return + if self.tab_widget.count() > 0: + self.tab_widget.setCurrentIndex(0) + + # ---------------------------- + # Filtering + # ---------------------------- def filter_tables(self, text): self.search_text = text.strip().lower() for table in self.tables: for row in range(table.rowCount()): + # Name column differs for pre vs normal name_col = 2 if table.is_pre else 1 - name_text = table.cellWidget(row, name_col).text() + w = table.cellWidget(row, name_col) + if w: + name_text = w.text() + else: + item = table.item(row, name_col) + name_text = item.text() if item else '' 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()) - self.settings.setValue("lastTab", self.get_current_tab_identifier()) - self.settings.setValue("tableScale", self.table_scale) - super().closeEvent(event) + hide = self.search_text not in clean_name if self.search_text else False + table.setRowHidden(row, hide) + # ---------------------------- + # Menu creation + # ---------------------------- def create_menu(self): menubar = self.menuBar() file_menu = menubar.addMenu('File') @@ -259,368 +332,378 @@ class AnimeTracker(QMainWindow): dialog = ShortcutsDialog(self) dialog.exec_() - def get_current_tab_identifier(self): - index = self.tab_widget.currentIndex() - if index == -1: - return None - tab_text = self.tab_widget.tabText(index) - if "Pre-2010" in tab_text: - return "pre" - else: - return int(tab_text.split(" ")[0]) - - def set_current_tab_by_identifier(self, identifier): - if identifier is None: - return - if identifier == "pre": - for i in range(self.tab_widget.count()): - if "Pre-2010" in self.tab_widget.tabText(i): - self.tab_widget.setCurrentIndex(i) - return - else: - for i in range(self.tab_widget.count()): - t = self.tab_widget.tabText(i) - if t.startswith(str(identifier) + " ("): - self.tab_widget.setCurrentIndex(i) - return - # If the year no longer exists, default to first tab - if self.tab_widget.count() > 0: - self.tab_widget.setCurrentIndex(0) - - def load_tabs(self): - self.tab_widget.clear() - 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) + # ---------------------------- + # Tab / Table builders + # ---------------------------- + def build_table(self, entries, is_pre=False): + """ + Build and return a configured table widget for the provided entries. + Entries expected in DB schema order: + 0:id,1:name,2:year,3:season,4:status,5:type,6:comment,7:url + """ + if is_pre: + columns = 7 headers = ['ID', 'Year', 'Name', 'Type', 'Status', 'Comment', 'Actions'] - table.setHorizontalHeaderLabels(headers) - table.setColumnHidden(4, True) # Hide Status column + else: + columns = 6 + headers = ['ID', 'Name', 'Type', 'Status', 'Comment', 'Actions'] + + table = CustomTableWidget(self, is_pre=is_pre) + table.setRowCount(len(entries)) + table.setColumnCount(columns) + table.setHorizontalHeaderLabels(headers) + + # Hide internal columns + if is_pre: + table.setColumnHidden(4, True) # Hide Status 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) - # Add vertical header styling for index numbers - vheader = table.verticalHeader() - vheader.setDefaultAlignment(Qt.AlignCenter) - vheader.setStyleSheet(""" - QHeaderView::section { - padding-left: 5px; - padding-right: 5px; - } - """) - for row, entry in enumerate(pre_entries): - col = 0 - # ID - id_item = QTableWidgetItem(str(entry[0])) - table.setItem(row, col, id_item) - col += 1 + else: + table.setColumnHidden(3, True) + 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) + + # Column size policy + if is_pre: + header.setSectionResizeMode(0, QHeaderView.Fixed) + header.setSectionResizeMode(1, QHeaderView.ResizeToContents) + header.setSectionResizeMode(2, QHeaderView.Stretch) + header.setSectionResizeMode(3, QHeaderView.ResizeToContents) + header.setSectionResizeMode(4, QHeaderView.ResizeToContents) + header.setSectionResizeMode(5, QHeaderView.Stretch) + header.setSectionResizeMode(6, QHeaderView.ResizeToContents) + table.setColumnWidth(0, 0) + else: + header.setSectionResizeMode(0, QHeaderView.Fixed) + header.setSectionResizeMode(1, QHeaderView.Stretch) + header.setSectionResizeMode(2, QHeaderView.ResizeToContents) + header.setSectionResizeMode(3, QHeaderView.ResizeToContents) + header.setSectionResizeMode(4, QHeaderView.Stretch) + header.setSectionResizeMode(5, QHeaderView.ResizeToContents) + table.setColumnWidth(0, 0) + + # Fonts based on scale + 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) + vheader = table.verticalHeader() + vheader.setDefaultAlignment(Qt.AlignCenter) + vheader.setStyleSheet(""" + QHeaderView::section { + padding-left: 5px; + padding-right: 5px; + } + """) + + for row, entry in enumerate(entries): + col = 0 + # ID + id_item = QTableWidgetItem(str(entry[0])) + table.setItem(row, col, id_item) + col += 1 + if is_pre: # 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)) - 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"padding-left: 2px;background-color: {color.name()};") + # Name (as QLabel to support link and styling) + 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) + # entry[1] from DB is already HTML-escaped by backend; safe to put into link text + if url: + name_label.setText(f' {name}') + name_label.setOpenExternalLinks(True) + else: + name_label.setText(name) + 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() + # Fixed height based on rows + height = table.horizontalHeader().height() + 2 + for i in range(table.rowCount()): + height += table.rowHeight(i) + table.setFixedHeight(height) + + # Apply status colors + status_col = 4 if is_pre else 3 + self.apply_status_colors(table, status_col) + + return table + + def apply_status_colors(self, table: QTableWidget, status_col: int): + for r in range(table.rowCount()): + status_item = table.item(r, status_col) + status = status_item.text() if status_item else '' + 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 + item = table.item(r, c) + if item: + item.setBackground(color) + widget = table.cellWidget(r, c) + if widget: + widget.setStyleSheet(f"padding-left: 2px; background-color: {color.name()};") + + # ---------------------------- + # Loading tabs (refactored) + # ---------------------------- + def load_tabs(self): + """Load all tabs (used at startup).""" + self.tab_widget.clear() + self.tables = [] + # Pre-2010 + pre_entries = self.backend.get_pre_2010_entries() + pre_tab_widget = QScrollArea() + pre_tab_widget.setWidgetResizable(True) + pre_content = QWidget() + pre_layout = QVBoxLayout(pre_content) + if pre_entries: + table = self.build_table(pre_entries, is_pre=True) + self.tables.append(table) pre_layout.addWidget(table) - pre_tab.setWidget(pre_content) + pre_tab_widget.setWidget(pre_content) tab_text = "Pre-2010" - completed = False total = len(pre_entries) + completed = False 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) + index = self.tab_widget.addTab(pre_tab_widget, tab_text) if completed: self.tab_widget.tabBar().setTabTextColor(index, QColor('gray')) + # Years >= 2010 years = self.backend.get_years() for year in years: - year_tab = QScrollArea() - year_tab.setWidgetResizable(True) + year_tab_widget = QScrollArea() + year_tab_widget.setWidgetResizable(True) content = QWidget() layout = QVBoxLayout(content) total_entries = 0 comp_entries = 0 + # seasons includes '' as "Other" for season in ['winter', 'spring', 'summer', 'fall', '']: entries = self.backend.get_entries_for_season(year, season) - # Database schema order: - # 0: id - # 1: name - # 2: year - # 3: season - # 4: status - # 5: type - # 6: comment - # 7: url if entries: - # Season title s_name = season.capitalize() if season else 'Other' label = QLabel() season_font = QFont() season_font.setPointSize(int(12 * self.table_scale)) season_font.setBold(True) label.setFont(season_font) - if season: # Only create links for actual seasons, not 'Other' + if season: mal_url = f"https://myanimelist.net/anime/season/{year}/{season}" label.setText(f'{s_name}') label.setTextFormat(Qt.RichText) - label.setOpenExternalLinks(True) # This makes the link clickable and open in browser + label.setOpenExternalLinks(True) else: - label.setText(s_name) # No link for 'Other' section - + label.setText(s_name) layout.addWidget(label) - - # Season's stats total_anime = len(entries) completed_anime = sum(1 for entry in entries if entry[4] == 'completed') completion_percentage = (completed_anime / total_anime * 100) if total_anime > 0 else 0 s_stat = QLabel(f"Completed: {completed_anime}/{total_anime} ({completion_percentage:.0f}%)") s_stat_font = QFont() - s_stat_font.setPointSize(int(10 * self.table_scale)) # Slightly smaller than season label + s_stat_font.setPointSize(int(10 * self.table_scale)) s_stat.setFont(s_stat_font) layout.addWidget(s_stat) - table = CustomTableWidget(self, is_pre=False) - table.is_pre = False + table = self.build_table(entries, 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(3, True) # Hide Status column - 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) - # Add vertical header styling for index numbers - vheader = table.verticalHeader() - vheader.setDefaultAlignment(Qt.AlignCenter) - vheader.setStyleSheet(""" - QHeaderView::section { - padding-left: 5px; - padding-right: 5px; - } - """) - 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)) - 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"padding-left: 2px; 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) + year_tab_widget.setWidget(content) 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) - index = self.tab_widget.addTab(year_tab, tab_text) + index = self.tab_widget.addTab(year_tab_widget, tab_text) if completed: self.tab_widget.tabBar().setTabTextColor(index, QColor('gray')) - self.filter_tables(self.search_text) # Apply search filter after loading tabs + self.filter_tables(self.search_text) + def reload_tab_by_identifier(self, identifier): + """ + Try to rebuild only the tab identified by identifier ("pre" or year int). + If not found or failure, fall back to full reload. + """ + try: + # find tab index + found_index = -1 + for i in range(self.tab_widget.count()): + text = self.tab_widget.tabText(i) + if identifier == "pre" and "Pre-2010" in text: + found_index = i + break + elif identifier != "pre": + if text.startswith(str(identifier) + " ("): + found_index = i + break + if found_index == -1: + # Tab not present; fallback to full reload + self.load_tabs() + return + + # Save currently selected index to attempt to restore later + current_index = self.tab_widget.currentIndex() + + # Remove the tab and create an updated version + # We'll get properties and insert a freshly built tab in the same place + self.tab_widget.removeTab(found_index) + + if identifier == "pre": + pre_entries = self.backend.get_pre_2010_entries() + pre_tab_widget = QScrollArea() + pre_tab_widget.setWidgetResizable(True) + pre_content = QWidget() + pre_layout = QVBoxLayout(pre_content) + if pre_entries: + table = self.build_table(pre_entries, is_pre=True) + # replace the entry in self.tables: easier to rebuild global list after small replacement + self.tables = [t for t in self.tables if not t.is_pre] # remove pre tables + self.tables.insert(0, table) + pre_layout.addWidget(table) + pre_tab_widget.setWidget(pre_content) + tab_text = "Pre-2010" + total = len(pre_entries) + completed = False + 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.insertTab(found_index, pre_tab_widget, tab_text) + if completed: + self.tab_widget.tabBar().setTabTextColor(index, QColor('gray')) + else: + # Recreate the year tab + year = identifier + year_tab_widget = QScrollArea() + year_tab_widget.setWidgetResizable(True) + content = QWidget() + layout = QVBoxLayout(content) + total_entries = 0 + comp_entries = 0 + for season in ['winter', 'spring', 'summer', 'fall', '']: + entries = self.backend.get_entries_for_season(year, season) + if entries: + s_name = season.capitalize() if season else 'Other' + label = QLabel() + season_font = QFont() + season_font.setPointSize(int(12 * self.table_scale)) + season_font.setBold(True) + label.setFont(season_font) + if season: + mal_url = f"https://myanimelist.net/anime/season/{year}/{season}" + label.setText(f'{s_name}') + label.setTextFormat(Qt.RichText) + label.setOpenExternalLinks(True) + else: + label.setText(s_name) + layout.addWidget(label) + total_anime = len(entries) + completed_anime = sum(1 for entry in entries if entry[4] == 'completed') + completion_percentage = (completed_anime / total_anime * 100) if total_anime > 0 else 0 + s_stat = QLabel(f"Completed: {completed_anime}/{total_anime} ({completion_percentage:.0f}%)") + s_stat_font = QFont() + s_stat_font.setPointSize(int(10 * self.table_scale)) + s_stat.setFont(s_stat_font) + layout.addWidget(s_stat) + table = self.build_table(entries, is_pre=False) + # remove any previous tables for this year from self.tables and append new + self.tables = [t for t in self.tables if t.is_pre is True or t not in self.tables] # simple prune (we will re-run filter soon) + self.tables.append(table) + layout.addWidget(table) + total_entries += len(entries) + comp_entries += sum(1 for e in entries if e[4] == 'completed') + year_tab_widget.setWidget(content) + tab_text = f"{year} ({(comp_entries / total_entries * 100) if total_entries else 0:.0f}%)" + completed = (comp_entries == total_entries) and total_entries > 0 + index = self.tab_widget.insertTab(found_index, year_tab_widget, tab_text) + if completed: + self.tab_widget.tabBar().setTabTextColor(index, QColor('gray')) + + # Try to restore previous current index if possible + if current_index >= 0 and current_index < self.tab_widget.count(): + self.tab_widget.setCurrentIndex(current_index) + self.filter_tables(self.search_text) + except Exception as e: + logging.error(f"Error reloading single tab {identifier}: {e}") + # Fallback to full reload on error + self.load_tabs() + + # ---------------------------- + # Actions (Add/Edit/Delete/Change status) + # ---------------------------- def create_actions_widget(self, anime_id, status): widget = QWidget() layout = QHBoxLayout(widget) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(2) - # Edit edit_btn = QToolButton() edit_btn.setText("E") edit_btn.setToolTip("Edit") edit_btn.clicked.connect(lambda: self.edit_anime(anime_id)) layout.addWidget(edit_btn) - # Delete del_btn = QToolButton() del_btn.setText("D") del_btn.setToolTip("Delete") del_btn.clicked.connect(lambda: self.delete_anime(anime_id)) layout.addWidget(del_btn) - # Status buttons if status == 'unwatched': - # Add W and C btn_w = QToolButton() btn_w.setText("W") btn_w.setToolTip("Set to watching") @@ -632,7 +715,6 @@ class AnimeTracker(QMainWindow): btn_c.clicked.connect(lambda: self.change_status(anime_id, 'completed')) layout.addWidget(btn_c) else: - # Add U btn_u = QToolButton() btn_u.setText("U") btn_u.setToolTip("Set to unwatched") @@ -652,7 +734,10 @@ class AnimeTracker(QMainWindow): default_season = '' else: parts = tab_text.split(' (') - default_year = int(parts[0]) + try: + default_year = int(parts[0]) + except Exception: + default_year = 2010 default_season = '' current_id = self.get_current_tab_identifier() current_scroll = self.get_current_scroll_pos() @@ -665,11 +750,16 @@ class AnimeTracker(QMainWindow): if not data['name']: QMessageBox.warning(self, "Error", "Anime name cannot be empty.") return - self.backend.add_anime(data) + if data['url'] and not is_valid_url(data['url']): + QMessageBox.warning(self, "Error", "Provided URL looks invalid.") + return + new_id = self.backend.add_anime(data) new_year = data['year'] - new_id = "pre" if new_year < 2010 else new_year - self.load_tabs() - self.set_current_tab_by_identifier(new_id) + new_identifier = "pre" if new_year < 2010 else new_year + # Try to reload only affected tab + self.reload_tab_by_identifier(new_identifier) + # If that didn't set the current tab, at least ensure correct selection + self.set_current_tab_by_identifier(new_identifier) if self.get_current_tab_identifier() == current_id: self.set_current_scroll_pos(current_scroll) @@ -684,11 +774,18 @@ class AnimeTracker(QMainWindow): if not data['name']: QMessageBox.warning(self, "Error", "Anime name cannot be empty.") return + if data['url'] and not is_valid_url(data['url']): + QMessageBox.warning(self, "Error", "Provided URL looks invalid.") + return self.backend.edit_anime(anime_id, data) new_year = data['year'] - new_id = "pre" if new_year < 2010 else new_year - self.load_tabs() - self.set_current_tab_by_identifier(new_id) + new_identifier = "pre" if new_year < 2010 else new_year + self.reload_tab_by_identifier(new_identifier) + # If the year changed, also reload old tab (best-effort). We'll attempt to reload current_id. + if current_id != new_identifier and current_id is not None: + # reload the previous tab where the entry was (best-effort) + self.reload_tab_by_identifier(current_id) + self.set_current_tab_by_identifier(new_identifier) if self.get_current_tab_identifier() == current_id: self.set_current_scroll_pos(current_scroll) @@ -696,8 +793,16 @@ class AnimeTracker(QMainWindow): if QMessageBox.question(self, "Confirm Delete", "Are you sure you want to delete this entry?") == QMessageBox.Yes: current_id = self.get_current_tab_identifier() current_scroll = self.get_current_scroll_pos() + # Before deleting, get the year of the entry to know which tab to reload + entry = self.backend.get_anime_by_id(anime_id) + year_of_entry = None + if entry: + year_of_entry = 'pre' if entry[2] < 2010 else entry[2] self.backend.delete_anime(anime_id) - self.load_tabs() + if year_of_entry is not None: + self.reload_tab_by_identifier(year_of_entry) + else: + self.load_tabs() self.set_current_tab_by_identifier(current_id) if self.get_current_tab_identifier() == current_id: self.set_current_scroll_pos(current_scroll) @@ -705,18 +810,29 @@ class AnimeTracker(QMainWindow): def change_status(self, anime_id, new_status): current_id = self.get_current_tab_identifier() current_scroll = self.get_current_scroll_pos() + # Determine affected tab to reload + entry = self.backend.get_anime_by_id(anime_id) + year_of_entry = None + if entry: + year_of_entry = 'pre' if entry[2] < 2010 else entry[2] self.backend.change_status(anime_id, new_status) - self.load_tabs() + if year_of_entry is not None: + self.reload_tab_by_identifier(year_of_entry) + else: + self.load_tabs() self.set_current_tab_by_identifier(current_id) if self.get_current_tab_identifier() == current_id: self.set_current_scroll_pos(current_scroll) + # ---------------------------- + # Year management + # ---------------------------- def add_new_year(self): current_year = datetime.now().year year, ok = QInputDialog.getInt(self, "Add New Year", "Enter the year to add:", current_year + 1, 2010, 2100) if ok: self.backend.add_placeholders_for_year(year) - self.load_tabs() + self.reload_tab_by_identifier(year) self.set_current_tab_by_identifier(year) def delete_year(self): @@ -733,11 +849,15 @@ class AnimeTracker(QMainWindow): current_id = self.get_current_tab_identifier() current_scroll = self.get_current_scroll_pos() self.backend.delete_year(year) + # After deleting a year, remove its tab; easier to reload all tabs to ensure correctness self.load_tabs() self.set_current_tab_by_identifier(current_id) if self.get_current_tab_identifier() == current_id: self.set_current_scroll_pos(current_scroll) + # ---------------------------- + # Random pick / statistics / CSV + # ---------------------------- def random_pick(self): if self.tab_widget.currentIndex() == -1: QMessageBox.information(self, "Random Pick", "No tab selected.") @@ -751,9 +871,15 @@ class AnimeTracker(QMainWindow): unwatched = [] for table in tables: for row in range(table.rowCount()): - status = table.item(row, status_col).text() + status_item = table.item(row, status_col) + status = status_item.text() if status_item else '' if status == 'unwatched': - name_text = table.cellWidget(row, name_col).text() + w = table.cellWidget(row, name_col) + if w: + name_text = w.text() + else: + it = table.item(row, name_col) + name_text = it.text() if it else '' clean_name = re.sub(r'<[^>]+>', '', name_text) unwatched.append(clean_name) if unwatched: @@ -779,12 +905,12 @@ class AnimeTracker(QMainWindow): types_data = self.backend.get_entries_by_type() if types_data: for typ, count in types_data: - display_type = typ if typ else "None" # Handle empty type as 'None' + display_type = typ if typ else "None" layout.addWidget(QLabel(f" {display_type}: {count}")) else: layout.addWidget(QLabel(" No data available")) - db_path = 'anime_backlog.db' + db_path = self.backend.db_path if os.path.exists(db_path): db_size_kb = os.path.getsize(db_path) / 1024 layout.addWidget(QLabel(f"Size of the database: {db_size_kb:.2f} Kb")) @@ -803,7 +929,11 @@ class AnimeTracker(QMainWindow): current_id = self.get_current_tab_identifier() current_scroll = self.get_current_scroll_pos() self.backend.import_from_csv(file_name) - self.load_tabs() + # After import it's easier to reload all; but we can try to reload current tab + if current_id is None: + self.load_tabs() + else: + self.reload_tab_by_identifier(current_id) self.set_current_tab_by_identifier(current_id) if self.get_current_tab_identifier() == current_id: self.set_current_scroll_pos(current_scroll) @@ -813,6 +943,9 @@ class AnimeTracker(QMainWindow): if file_name: self.backend.export_to_csv(file_name) + # ---------------------------- + # Key handling + # ---------------------------- def keyPressEvent(self, event): key = event.key() modifiers = event.modifiers() @@ -821,9 +954,9 @@ class AnimeTracker(QMainWindow): self.add_anime() elif key == Qt.Key_R: self.random_pick() - elif key == Qt.Key_Q: + elif key == Qt.Key_Q: self.close() - + elif modifiers == Qt.ControlModifier: if key == Qt.Key_Plus or key == Qt.Key_Equal: current_id = self.get_current_tab_identifier() @@ -835,7 +968,7 @@ class AnimeTracker(QMainWindow): self.table_scale = max(self.table_scale - 0.1, 0.5) self.load_tabs() self.set_current_tab_by_identifier(current_id) - elif key == Qt.Key_Q: + elif key == Qt.Key_Q: self.close() elif key == Qt.Key_PageDown: current = self.tab_widget.currentIndex() @@ -848,6 +981,18 @@ class AnimeTracker(QMainWindow): super().keyPressEvent(event) + def closeEvent(self, event): + self.settings.setValue("geometry", self.saveGeometry()) + self.settings.setValue("lastTab", self.get_current_tab_identifier()) + self.settings.setValue("tableScale", self.table_scale) + # Close DB gracefully + try: + self.backend.close() + except Exception: + pass + super().closeEvent(event) + + if __name__ == '__main__': app = QApplication(sys.argv) window = AnimeTracker() diff --git a/readme.md b/readme.md index 285adaf..1c113f8 100644 --- a/readme.md +++ b/readme.md @@ -11,7 +11,7 @@ Then run a binary: `./dist/AnimeTracker/AnimeTracker` ## How to update the binary file -`rm -rf ~/Applications/AnimeTracker/AnimeTracker/_internal && cp -r ~/Documents/programs/python/anime-tracker/dist/AnimeTracker ~/Applications/AnimeTracker` +`rm -rf ~/Applications/AnimeTracker/_internal && cp -r ~/Documents/programs/python/anime-tracker/dist/AnimeTracker ~/Applications/` ## How to run this app without building: `python frontend.py`