import sys import os import random import re import html import logging from datetime import datetime 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, QPushButton ) from PyQt5.QtCore import Qt, QSettings from PyQt5.QtGui import QColor, QIcon from backend import AnimeBackend # Set up logging logging.basicConfig(filename='anime_tracker.log', level=logging.ERROR, format='%(asctime)s - %(levelname)s - %(message)s') class AnimeDialog(QDialog): 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() layout.addRow("Name", self.name_edit) self.year_spin = QSpinBox() self.year_spin.setRange(1900, 2100) layout.addRow("Year", self.year_spin) self.season_combo = QComboBox() self.season_combo.addItems(['', 'winter', 'spring', 'summer', 'fall']) 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() layout.addRow("Comment", self.comment_edit) self.url_edit = QLineEdit() 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) if entry: self.name_edit.setText(entry[1]) self.year_spin.setValue(entry[2]) self.season_combo.setCurrentText(entry[3]) self.status_combo.setCurrentText(entry[4]) self.type_combo.setCurrentText(entry[5] or '') self.comment_edit.setText(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(default_season) 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('') else: self.season_combo.setEnabled(True) def get_data(self): return { 'name': self.name_edit.text(), 'year': self.year_spin.value(), 'season': self.season_combo.currentText(), 'status': self.status_combo.currentText(), 'type': self.type_combo.currentText(), 'comment': self.comment_edit.toPlainText(), 'url': self.url_edit.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 - Page Down: Next tab - Page Up: Previous tab - Q: Quit the program - R: Pick random anime 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() anime_id = int(self.item(row, 0).text()) 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.setWindowTitle("Anime Backlog Tracker") 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_year = QLabel("Year:") self.year_spin = QSpinBox() self.year_spin.setRange(0, 2100) self.year_spin.setValue(0) self.year_spin.setSpecialValueText("All") filter_layout.addWidget(label_year) filter_layout.addWidget(self.year_spin) label_season = QLabel("Season:") self.season_combo = QComboBox() self.season_combo.addItems(['All', 'winter', 'spring', 'summer', 'fall', 'Other']) filter_layout.addWidget(label_season) filter_layout.addWidget(self.season_combo) label_status = QLabel("Status:") self.status_combo = QComboBox() self.status_combo.addItems(['All', 'unwatched', 'watching', 'completed']) filter_layout.addWidget(label_status) filter_layout.addWidget(self.status_combo) label_type = QLabel("Type:") self.type_combo = QComboBox() self.type_combo.addItems(['All', 'TV', 'Movie', 'OVA', 'Special', 'Short TV', 'Other']) filter_layout.addWidget(label_type) filter_layout.addWidget(self.type_combo) label_search = QLabel("Search:") self.search_edit = QLineEdit() self.search_edit.setPlaceholderText("Search name or comment") filter_layout.addWidget(label_search) filter_layout.addWidget(self.search_edit) apply_btn = QPushButton("Apply") apply_btn.clicked.connect(self.apply_filters) filter_layout.addWidget(apply_btn) central_layout.addWidget(self.filter_bar) central_layout.addWidget(self.tab_widget) self.setCentralWidget(self.central_widget) self.year_filter = None self.season_filter = None self.status_filter = None self.type_filter = None self.search_text = None self.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) def apply_filters(self): year_val = self.year_spin.value() self.year_filter = year_val if year_val > 0 else None season_val = self.season_combo.currentText() self.season_filter = None if season_val == 'All' else ('' if season_val == 'Other' else season_val) status_val = self.status_combo.currentText() self.status_filter = None if status_val == 'All' else status_val type_val = self.type_combo.currentText() self.type_filter = None if type_val == 'All' else type_val search_val = self.search_edit.text().strip() self.search_text = search_val if search_val else None self.load_tabs() def closeEvent(self, event): self.settings.setValue("geometry", self.saveGeometry()) self.settings.setValue("lastTab", self.get_current_tab_identifier()) super().closeEvent(event) 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) 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_() 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() show_pre = True if self.season_filter is not None and self.season_filter != '': show_pre = False if self.year_filter is not None and self.year_filter >= 2010: show_pre = False if show_pre: pre_entries = self.backend.get_pre_2010_entries(self.status_filter, self.type_filter, self.search_text, self.year_filter if self.year_filter and self.year_filter < 2010 else None) pre_tab = QScrollArea() pre_tab.setWidgetResizable(True) pre_content = QWidget() pre_layout = QVBoxLayout(pre_content) if pre_entries: table = CustomTableWidget(self, is_pre=True) table.setRowCount(len(pre_entries)) table.setColumnCount(7) headers = ['ID', 'Year', 'Name', 'Type', 'Status', 'Comment', 'Actions'] table.setHorizontalHeaderLabels(headers) table.setColumnHidden(0, True) table.setAlternatingRowColors(True) table.setShowGrid(True) header = table.horizontalHeader() header.setStretchLastSection(False) table.setSelectionBehavior(QAbstractItemView.SelectRows) table.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) header.setSectionResizeMode(0, QHeaderView.ResizeToContents) # 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 for row, entry in enumerate(pre_entries): col = 0 # ID id_item = QTableWidgetItem(str(entry[0])) table.setItem(row, col, id_item) col += 1 # Year year_item = QTableWidgetItem(str(entry[2])) table.setItem(row, col, year_item) col += 1 # Name name = entry[1] url = entry[7] name_label = QLabel() 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 '') table.setItem(row, col, type_item) col += 1 # Status status_item = QTableWidgetItem(entry[4]) table.setItem(row, col, status_item) col += 1 # Comment comment_item = QTableWidgetItem(entry[6] or '') table.setItem(row, col, comment_item) col += 1 # Actions actions_widget = self.create_actions_widget(entry[0], entry[4]) table.setCellWidget(row, col, actions_widget) table.resizeColumnsToContents() table.resizeRowsToContents() # Calculate fixed height height = table.horizontalHeader().height() + 2 # small margin for i in range(table.rowCount()): height += table.rowHeight(i) table.setFixedHeight(height) # Apply status colors status_col = 4 for r in range(table.rowCount()): status = table.item(r, status_col).text() if status == 'unwatched': color = QColor(255, 255, 255) elif status == 'watching': color = QColor(255, 255, 0) elif status == 'completed': color = QColor(0, 255, 0) else: color = QColor(255, 255, 255) for c in range(table.columnCount()): if c == 0: continue # skip hidden id item = table.item(r, c) if item: item.setBackground(color) widget = table.cellWidget(r, c) if widget: widget.setStyleSheet(f"background-color: {color.name()};") pre_layout.addWidget(table) pre_tab.setWidget(pre_content) tab_text = "Pre-2010" completed = False total = len(pre_entries) if total > 0: comp = sum(1 for e in pre_entries if e[4] == 'completed') perc = (comp / total * 100) tab_text += f" ({perc:.0f}%)" if comp == total: completed = True index = self.tab_widget.addTab(pre_tab, tab_text) if completed: self.tab_widget.tabBar().setTabTextColor(index, QColor('gray')) # Years >= 2010 years = self.backend.get_years() if self.year_filter is not None: years = [self.year_filter] if self.year_filter in years else [] for year in years: year_tab = QScrollArea() year_tab.setWidgetResizable(True) content = QWidget() layout = QVBoxLayout(content) total_entries = 0 comp_entries = 0 for season in ['winter', 'spring', 'summer', 'fall', '']: show_section = True if self.season_filter is not None: if season != self.season_filter: show_section = False if show_section: entries = self.backend.get_entries_for_season(year, season, self.status_filter, self.type_filter, self.search_text) if entries: s_name = season.capitalize() if season else 'Other' label = QLabel(s_name) layout.addWidget(label) table = CustomTableWidget(self, is_pre=False) table.setRowCount(len(entries)) table.setColumnCount(6) headers = ['ID', 'Name', 'Type', 'Status', 'Comment', 'Actions'] table.setHorizontalHeaderLabels(headers) table.setColumnHidden(0, True) table.setAlternatingRowColors(True) table.setShowGrid(True) header = table.horizontalHeader() header.setStretchLastSection(False) table.setSelectionBehavior(QAbstractItemView.SelectRows) table.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) header.setSectionResizeMode(0, QHeaderView.ResizeToContents) # 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 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() 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 '') table.setItem(row, col, type_item) col += 1 # Status status_item = QTableWidgetItem(entry[4]) table.setItem(row, col, status_item) col += 1 # Comment comment_item = QTableWidgetItem(entry[6] or '') table.setItem(row, col, comment_item) col += 1 # Actions actions_widget = self.create_actions_widget(entry[0], entry[4]) table.setCellWidget(row, col, actions_widget) table.resizeColumnsToContents() table.resizeRowsToContents() # Calculate fixed height height = table.horizontalHeader().height() + 2 # small margin for i in range(table.rowCount()): height += table.rowHeight(i) table.setFixedHeight(height) # Apply status colors status_col = 3 for r in range(table.rowCount()): status = table.item(r, status_col).text() if status == 'unwatched': color = QColor(255, 255, 255) elif status == 'watching': color = QColor(255, 255, 0) elif status == 'completed': color = QColor(0, 255, 0) else: color = QColor(255, 255, 255) for c in range(table.columnCount()): if c == 0: continue # skip hidden id item = table.item(r, c) if item: item.setBackground(color) widget = table.cellWidget(r, c) if widget: widget.setStyleSheet(f"background-color: {color.name()};") layout.addWidget(table) total_entries += len(entries) comp_entries += sum(1 for e in entries if e[4] == 'completed') year_tab.setWidget(content) if total_entries > 0 or (self.year_filter == year): 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) if completed: self.tab_widget.tabBar().setTabTextColor(index, QColor('gray')) 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") 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: # Add U 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(' (') default_year = int(parts[0]) default_season = '' dialog = AnimeDialog(self, None, default_year, default_season) if dialog.exec_() == QDialog.Accepted: current_id = self.get_current_tab_identifier() data = dialog.get_data() self.backend.add_anime(data) self.load_tabs() self.set_current_tab_by_identifier(current_id) def edit_anime(self, anime_id): entry = self.backend.get_anime_by_id(anime_id) if entry: current_id = self.get_current_tab_identifier() dialog = AnimeDialog(self, entry) if dialog.exec_() == QDialog.Accepted: data = dialog.get_data() self.backend.edit_anime(anime_id, data) self.load_tabs() self.set_current_tab_by_identifier(current_id) 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() self.backend.delete_anime(anime_id) self.load_tabs() self.set_current_tab_by_identifier(current_id) def change_status(self, anime_id, new_status): current_id = self.get_current_tab_identifier() self.backend.change_status(anime_id, new_status) self.load_tabs() self.set_current_tab_by_identifier(current_id) 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.set_current_tab_by_identifier(year) def delete_year(self): current_year = datetime.now().year year, ok = QInputDialog.getInt(self, "Delete Year", "Enter the year to delete:", 2010, 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: self.backend.delete_year(year) self.load_tabs() 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 = table.item(row, status_col).text() if status == 'unwatched': name_text = table.cellWidget(row, name_col).text() 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 import_csv(self): file_name, _ = QFileDialog.getOpenFileName(self, "Import CSV", "", "CSV Files (*.csv)") if file_name: current_id = self.get_current_tab_identifier() self.backend.import_from_csv(file_name) self.load_tabs() self.set_current_tab_by_identifier(current_id) 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) 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_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) elif key == Qt.Key_Q: self.close() elif key == Qt.Key_R: self.random_pick() super().keyPressEvent(event) if __name__ == '__main__': app = QApplication(sys.argv) window = AnimeTracker() window.show() sys.exit(app.exec_())