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
)
from PyQt5.QtCore import Qt
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
- C: Set to 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:
self.parent_window.change_status(anime_id, 'watching')
return
elif key == Qt.Key_C:
self.parent_window.change_status(anime_id, 'completed')
return
super().keyPressEvent(event)
class AnimeTracker(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Anime Backlog Tracker")
self.resize(800, 600)
self.backend = AnimeBackend()
self.tab_widget = QTabWidget()
self.setCentralWidget(self.tab_widget)
self.create_menu()
self.load_tabs()
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
def load_tabs(self):
self.tab_widget.clear()
# 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.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()
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', '']:
entries = self.backend.get_entries_for_season(year, season)
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:
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
for st in ['unwatched', 'watching', 'completed']:
btn = QToolButton()
btn.setText(st[0].upper())
btn.setToolTip(f"Set to {st}")
if st == status:
btn.setEnabled(False)
btn.clicked.connect(lambda _, s=st: self.change_status(anime_id, s))
layout.addWidget(btn)
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_())