1001 lines
43 KiB
Python
1001 lines
43 KiB
Python
|
|
# frontend.py
|
|
import sys
|
|
import os
|
|
import random
|
|
import re
|
|
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, QShortcut
|
|
)
|
|
from PyQt5.QtCore import Qt, QSettings
|
|
from PyQt5.QtGui import QColor, QIcon, QFont, QKeySequence
|
|
from backend import AnimeBackend
|
|
|
|
# Set up logging
|
|
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)
|
|
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'])
|
|
layout.addRow("Season", self.season_combo)
|
|
self.type_combo = QComboBox()
|
|
self.type_combo.addItems(['TV', 'Movie', 'OVA', 'Special', 'Short TV', 'Other'])
|
|
layout.addRow("Type", self.type_combo)
|
|
self.status_combo = QComboBox()
|
|
self.status_combo.addItems(['unwatched', 'watching', 'completed'])
|
|
layout.addRow("Status", self.status_combo)
|
|
self.comment_edit = QTextEdit()
|
|
self.comment_edit.setAcceptRichText(False)
|
|
layout.addRow("Comment", self.comment_edit)
|
|
self.url_edit = QLineEdit()
|
|
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:
|
|
# 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])
|
|
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.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:
|
|
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())
|
|
|
|
def update_season(self, year):
|
|
if year < 2010:
|
|
self.season_combo.setEnabled(False)
|
|
self.season_combo.setCurrentText('Other')
|
|
else:
|
|
self.season_combo.setEnabled(True)
|
|
|
|
def get_data(self):
|
|
"""
|
|
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, '')
|
|
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': self.name_edit.text().strip(),
|
|
'year': self.year_spin.value(),
|
|
'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)
|
|
self.setWindowTitle("Keyboard Shortcuts")
|
|
layout = QVBoxLayout(self)
|
|
shortcuts_text = QTextEdit()
|
|
shortcuts_text.setReadOnly(True)
|
|
shortcuts_text.setPlainText("""
|
|
Global Shortcuts:
|
|
- A: Add an anime
|
|
- Ctrl + Page Down: Next tab
|
|
- Ctrl + Page Up: Previous tab
|
|
- Ctrl + Q: Quit the program
|
|
- R: Pick random anime
|
|
- Ctrl + + : Increase table scale
|
|
- Ctrl + - : Decrease table scale
|
|
|
|
Table-specific Shortcuts (apply to selected entry):
|
|
- Delete: Delete an anime
|
|
- E: Edit
|
|
- W: Set to watching / Set to unwatched (toggle if watching)
|
|
- C: Set to completed / Set to unwatched (toggle if completed)
|
|
""")
|
|
layout.addWidget(shortcuts_text)
|
|
buttons = QDialogButtonBox(QDialogButtonBox.Ok)
|
|
buttons.accepted.connect(self.accept)
|
|
layout.addWidget(buttons)
|
|
|
|
|
|
class CustomTableWidget(QTableWidget):
|
|
def __init__(self, parent, is_pre):
|
|
super().__init__()
|
|
self.parent_window = parent
|
|
self.is_pre = is_pre
|
|
|
|
def keyPressEvent(self, event):
|
|
key = event.key()
|
|
if event.modifiers() == Qt.NoModifier:
|
|
selected_rows = self.selectionModel().selectedRows()
|
|
if selected_rows:
|
|
row = selected_rows[0].row()
|
|
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
|
|
elif key == Qt.Key_E:
|
|
self.parent_window.edit_anime(anime_id)
|
|
return
|
|
elif key == Qt.Key_W:
|
|
status_col = 4 if self.is_pre else 3
|
|
status = self.item(row, status_col).text()
|
|
new_status = 'watching' if status != 'watching' else 'unwatched'
|
|
self.parent_window.change_status(anime_id, new_status)
|
|
return
|
|
elif key == Qt.Key_C:
|
|
status_col = 4 if self.is_pre else 3
|
|
status = self.item(row, status_col).text()
|
|
new_status = 'completed' if status != 'completed' else 'unwatched'
|
|
self.parent_window.change_status(anime_id, new_status)
|
|
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")
|
|
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))
|
|
self.resize(800, 600)
|
|
self.backend = AnimeBackend()
|
|
self.tab_widget = QTabWidget()
|
|
self.central_widget = QWidget()
|
|
central_layout = QVBoxLayout(self.central_widget)
|
|
self.filter_bar = QWidget()
|
|
filter_layout = QHBoxLayout(self.filter_bar)
|
|
label_search = QLabel("Search:")
|
|
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)
|
|
filter_layout.addWidget(self.search_edit)
|
|
central_layout.addWidget(self.filter_bar)
|
|
central_layout.addWidget(self.tab_widget)
|
|
self.setCentralWidget(self.central_widget)
|
|
self.search_text = ''
|
|
self.table_scale = self.settings.value("tableScale", 1.0, type=float)
|
|
self.tables = []
|
|
self.create_menu()
|
|
self.load_tabs()
|
|
self.restoreGeometry(self.settings.value("geometry", self.saveGeometry()))
|
|
last_tab = self.settings.value("lastTab", None)
|
|
if last_tab is not None:
|
|
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 isinstance(widget, QScrollArea):
|
|
return widget.verticalScrollBar().value()
|
|
return 0
|
|
|
|
def set_current_scroll_pos(self, pos):
|
|
widget = self.tab_widget.currentWidget()
|
|
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
|
|
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()
|
|
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')
|
|
import_act = QAction('Import CSV', self)
|
|
import_act.triggered.connect(self.import_csv)
|
|
file_menu.addAction(import_act)
|
|
export_act = QAction('Export CSV', self)
|
|
export_act.triggered.connect(self.export_csv)
|
|
file_menu.addAction(export_act)
|
|
edit_menu = menubar.addMenu('Edit')
|
|
add_act = QAction('Add Anime', self)
|
|
add_act.triggered.connect(self.add_anime)
|
|
edit_menu.addAction(add_act)
|
|
add_year_act = QAction('Add New Year', self)
|
|
add_year_act.triggered.connect(self.add_new_year)
|
|
edit_menu.addAction(add_year_act)
|
|
del_year_act = QAction('Delete Year', self)
|
|
del_year_act.triggered.connect(self.delete_year)
|
|
edit_menu.addAction(del_year_act)
|
|
tools_menu = menubar.addMenu('Tools')
|
|
random_act = QAction('Random Pick', self)
|
|
random_act.triggered.connect(self.random_pick)
|
|
tools_menu.addAction(random_act)
|
|
statistics_act = QAction('Statistics', self)
|
|
statistics_act.triggered.connect(self.show_statistics)
|
|
tools_menu.addAction(statistics_act)
|
|
help_menu = menubar.addMenu('Help')
|
|
shortcuts_act = QAction('Shortcuts', self)
|
|
shortcuts_act.triggered.connect(self.show_shortcuts)
|
|
help_menu.addAction(shortcuts_act)
|
|
|
|
def show_shortcuts(self):
|
|
dialog = ShortcutsDialog(self)
|
|
dialog.exec_()
|
|
|
|
# ----------------------------
|
|
# 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']
|
|
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)
|
|
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 (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' <a href="{url}" style="color: #0000FF; text-decoration: none;">{name}</a>')
|
|
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_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.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_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)
|
|
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'<a href="{mal_url}" style="color: #0000FF; text-decoration: none; font-weight: bold;">{s_name}</a>')
|
|
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: <b>{completed_anime}/{total_anime} ({completion_percentage:.0f}%)</b>")
|
|
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)
|
|
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)
|
|
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_widget, tab_text)
|
|
if completed:
|
|
self.tab_widget.tabBar().setTabTextColor(index, QColor('gray'))
|
|
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'<a href="{mal_url}" style="color: #0000FF; text-decoration: none; font-weight: bold;">{s_name}</a>')
|
|
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: <b>{completed_anime}/{total_anime} ({completion_percentage:.0f}%)</b>")
|
|
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_btn = QToolButton()
|
|
edit_btn.setText("E")
|
|
edit_btn.setToolTip("Edit")
|
|
edit_btn.clicked.connect(lambda: self.edit_anime(anime_id))
|
|
layout.addWidget(edit_btn)
|
|
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)
|
|
if status == 'unwatched':
|
|
btn_w = QToolButton()
|
|
btn_w.setText("W")
|
|
btn_w.setToolTip("Set to watching")
|
|
btn_w.clicked.connect(lambda: self.change_status(anime_id, 'watching'))
|
|
layout.addWidget(btn_w)
|
|
btn_c = QToolButton()
|
|
btn_c.setText("C")
|
|
btn_c.setToolTip("Set to completed")
|
|
btn_c.clicked.connect(lambda: self.change_status(anime_id, 'completed'))
|
|
layout.addWidget(btn_c)
|
|
else:
|
|
btn_u = QToolButton()
|
|
btn_u.setText("U")
|
|
btn_u.setToolTip("Set to unwatched")
|
|
btn_u.clicked.connect(lambda: self.change_status(anime_id, 'unwatched'))
|
|
layout.addWidget(btn_u)
|
|
return widget
|
|
|
|
def add_anime(self):
|
|
tab_index = self.tab_widget.currentIndex()
|
|
if tab_index == -1:
|
|
default_year = 2010
|
|
default_season = ''
|
|
else:
|
|
tab_text = self.tab_widget.tabText(tab_index)
|
|
if 'Pre-2010' in tab_text:
|
|
default_year = 2009
|
|
default_season = ''
|
|
else:
|
|
parts = tab_text.split(' (')
|
|
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()
|
|
dialog = AnimeDialog(self, None, default_year, self.last_used_season)
|
|
if dialog.exec_() == QDialog.Accepted:
|
|
data = dialog.get_data()
|
|
if data['season']:
|
|
self.last_used_season = data['season']
|
|
self.settings.setValue("lastUsedSeason", self.last_used_season)
|
|
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
|
|
new_id = self.backend.add_anime(data)
|
|
new_year = data['year']
|
|
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)
|
|
|
|
def edit_anime(self, anime_id):
|
|
entry = self.backend.get_anime_by_id(anime_id)
|
|
if entry:
|
|
current_id = self.get_current_tab_identifier()
|
|
current_scroll = self.get_current_scroll_pos()
|
|
dialog = AnimeDialog(self, entry)
|
|
if dialog.exec_() == QDialog.Accepted:
|
|
data = dialog.get_data()
|
|
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_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)
|
|
|
|
def delete_anime(self, anime_id):
|
|
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)
|
|
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)
|
|
|
|
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)
|
|
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.reload_tab_by_identifier(year)
|
|
self.set_current_tab_by_identifier(year)
|
|
|
|
def delete_year(self):
|
|
current_year = datetime.now().year
|
|
current_id = self.get_current_tab_identifier()
|
|
default_year = 2010 if current_id == 'pre' else current_id
|
|
year, ok = QInputDialog.getInt(self, "Delete Year", "Enter the year to delete:", default_year, 2010, current_year + 10)
|
|
if ok:
|
|
years = self.backend.get_years()
|
|
if year not in years:
|
|
QMessageBox.warning(self, "Error", f"Year {year} does not exist.")
|
|
return
|
|
if QMessageBox.question(self, "Confirm Delete", f"Are you sure you want to delete all entries for {year}?") == QMessageBox.Yes:
|
|
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.")
|
|
return
|
|
tab_text = self.tab_widget.tabText(self.tab_widget.currentIndex())
|
|
is_pre = 'Pre-2010' in tab_text
|
|
current_widget = self.tab_widget.currentWidget().widget() if isinstance(self.tab_widget.currentWidget(), QScrollArea) else self.tab_widget.currentWidget()
|
|
tables = current_widget.findChildren(CustomTableWidget)
|
|
name_col = 2 if is_pre else 1
|
|
status_col = 4 if is_pre else 3
|
|
unwatched = []
|
|
for table in tables:
|
|
for row in range(table.rowCount()):
|
|
status_item = table.item(row, status_col)
|
|
status = status_item.text() if status_item else ''
|
|
if status == 'unwatched':
|
|
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:
|
|
random_name = random.choice(unwatched)
|
|
QMessageBox.information(self, "Random Pick", f"Watch: {random_name}")
|
|
else:
|
|
QMessageBox.information(self, "Random Pick", "No unwatched anime in this tab.")
|
|
|
|
def show_statistics(self):
|
|
dialog = QDialog(self)
|
|
dialog.setWindowTitle("Anime Statistics")
|
|
layout = QVBoxLayout(dialog)
|
|
|
|
total = self.backend.get_total_entries()
|
|
completed = self.backend.get_completed_entries()
|
|
percentage = (completed / total * 100) if total > 0 else 0
|
|
|
|
layout.addWidget(QLabel(f"Total anime entries: <b>{total}</b>"))
|
|
layout.addWidget(QLabel(f"Total completed entries: <b>{completed}</b>"))
|
|
layout.addWidget(QLabel(f"Percentage of completed entries: <b>{percentage:.2f}%</b>"))
|
|
|
|
layout.addWidget(QLabel("Number of anime entries by type:"))
|
|
types_data = self.backend.get_entries_by_type()
|
|
if types_data:
|
|
for typ, count in types_data:
|
|
display_type = typ if typ else "None"
|
|
layout.addWidget(QLabel(f" {display_type}: <b>{count}</b>"))
|
|
else:
|
|
layout.addWidget(QLabel(" No data available"))
|
|
|
|
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: <b>{db_size_kb:.2f} Kb</b>"))
|
|
else:
|
|
layout.addWidget(QLabel("Size of the database: N/A (database file not found)"))
|
|
|
|
buttons = QDialogButtonBox(QDialogButtonBox.Ok)
|
|
buttons.accepted.connect(dialog.accept)
|
|
layout.addWidget(buttons)
|
|
|
|
dialog.exec_()
|
|
|
|
def import_csv(self):
|
|
file_name, _ = QFileDialog.getOpenFileName(self, "Import CSV", "", "CSV Files (*.csv)")
|
|
if file_name:
|
|
current_id = self.get_current_tab_identifier()
|
|
current_scroll = self.get_current_scroll_pos()
|
|
self.backend.import_from_csv(file_name)
|
|
# 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)
|
|
|
|
def export_csv(self):
|
|
file_name, _ = QFileDialog.getSaveFileName(self, "Export CSV", "anime_backlog.csv", "CSV Files (*.csv)")
|
|
if file_name:
|
|
self.backend.export_to_csv(file_name)
|
|
|
|
# ----------------------------
|
|
# Key handling
|
|
# ----------------------------
|
|
def keyPressEvent(self, event):
|
|
key = event.key()
|
|
modifiers = event.modifiers()
|
|
if modifiers == Qt.NoModifier:
|
|
if key == Qt.Key_A:
|
|
self.add_anime()
|
|
elif key == Qt.Key_R:
|
|
self.random_pick()
|
|
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()
|
|
self.table_scale = min(self.table_scale + 0.1, 2.0)
|
|
self.load_tabs()
|
|
self.set_current_tab_by_identifier(current_id)
|
|
elif key == Qt.Key_Minus:
|
|
current_id = self.get_current_tab_identifier()
|
|
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:
|
|
self.close()
|
|
elif key == Qt.Key_PageDown:
|
|
current = self.tab_widget.currentIndex()
|
|
if current < self.tab_widget.count() - 1:
|
|
self.tab_widget.setCurrentIndex(current + 1)
|
|
elif key == Qt.Key_PageUp:
|
|
current = self.tab_widget.currentIndex()
|
|
if current > 0:
|
|
self.tab_widget.setCurrentIndex(current - 1)
|
|
|
|
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()
|
|
window.show()
|
|
sys.exit(app.exec_())
|