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`