Compare commits

..

10 Commits

15 changed files with 540 additions and 352 deletions

6
.gitignore vendored
View File

@ -4,3 +4,9 @@ __pycache__/
anime_tracker.log anime_tracker.log
todo.txt todo.txt
*.csv *.csv
anime_backlog.db.bk
*.db-wal
*.db-shm
dist/
build/
anime_env/

BIN
anime_backlog.db-shm Normal file

Binary file not shown.

BIN
anime_backlog.db-wal Normal file

Binary file not shown.

View File

@ -1,6 +1,7 @@
import sqlite3 import sqlite3
import csv import csv
import logging import logging
import html
# Set up logging # Set up logging
logging.basicConfig(filename='anime_tracker.log', level=logging.ERROR, logging.basicConfig(filename='anime_tracker.log', level=logging.ERROR,
@ -8,7 +9,8 @@ logging.basicConfig(filename='anime_tracker.log', level=logging.ERROR,
class AnimeBackend: class AnimeBackend:
def __init__(self): def __init__(self):
self.db = sqlite3.connect('anime_backlog.db') 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
self.create_table() self.create_table()
def create_table(self): def create_table(self):
@ -30,28 +32,10 @@ class AnimeBackend:
except Exception as e: except Exception as e:
logging.error(f"Error creating table: {e}") logging.error(f"Error creating table: {e}")
def get_pre_2010_entries(self, status_filter=None, type_filter=None, search=None, year_filter=None): def get_pre_2010_entries(self):
try: try:
cursor = self.db.cursor() cursor = self.db.cursor()
sql = "SELECT * FROM anime WHERE year < 2010" cursor.execute("SELECT * FROM anime WHERE year < 2010 ORDER BY year DESC, name")
add_where = []
params = []
if year_filter is not None:
add_where.append("year = ?")
params.append(year_filter)
if status_filter is not None:
add_where.append("status = ?")
params.append(status_filter)
if type_filter is not None:
add_where.append("type = ?")
params.append(type_filter)
if search is not None:
add_where.append("(name LIKE ? OR comment LIKE ?)")
params.append(f"%{search}%")
params.append(f"%{search}%")
where_clause = " AND " + " AND ".join(add_where) if add_where else ""
sql += where_clause + " ORDER BY year DESC, name"
cursor.execute(sql, params)
return cursor.fetchall() return cursor.fetchall()
except Exception as e: except Exception as e:
logging.error(f"Error getting pre-2010 entries: {e}") logging.error(f"Error getting pre-2010 entries: {e}")
@ -66,25 +50,10 @@ class AnimeBackend:
logging.error(f"Error getting years: {e}") logging.error(f"Error getting years: {e}")
return [] return []
def get_entries_for_season(self, year, season, status_filter=None, type_filter=None, search=None): def get_entries_for_season(self, year, season):
try: try:
cursor = self.db.cursor() cursor = self.db.cursor()
sql = "SELECT * FROM anime WHERE year = ? AND season = ?" cursor.execute("SELECT * FROM anime WHERE year = ? AND season = ? ORDER BY name", (year, season))
params = [year, season]
add_where = []
if status_filter is not None:
add_where.append("status = ?")
params.append(status_filter)
if type_filter is not None:
add_where.append("type = ?")
params.append(type_filter)
if search is not None:
add_where.append("(name LIKE ? OR comment LIKE ?)")
params.append(f"%{search}%")
params.append(f"%{search}%")
where_clause = " AND " + " AND ".join(add_where) if add_where else ""
sql += where_clause + " ORDER BY name"
cursor.execute(sql, params)
return cursor.fetchall() return cursor.fetchall()
except Exception as e: except Exception as e:
logging.error(f"Error getting entries for season {season} in year {year}: {e}") logging.error(f"Error getting entries for season {season} in year {year}: {e}")
@ -101,25 +70,64 @@ class AnimeBackend:
def add_anime(self, data): def add_anime(self, data):
try: 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 ''
}
cursor = self.db.cursor() cursor = self.db.cursor()
cursor.execute( cursor.execute(
"INSERT INTO anime (name, year, season, status, type, comment, url) VALUES (?, ?, ?, ?, ?, ?, ?)", "INSERT INTO anime (name, year, season, status, type, comment, url) VALUES (?, ?, ?, ?, ?, ?, ?)",
(data['name'], data['year'], data['season'], data['status'], data['type'], data['comment'], data['url']) (
sanitized_data['name'],
sanitized_data['year'],
sanitized_data['season'],
sanitized_data['status'],
sanitized_data['type'],
sanitized_data['comment'],
sanitized_data['url']
)
) )
self.db.commit() self.db.commit()
except Exception as e: except Exception as e:
logging.error(f"Error adding anime: {e}") logging.error(f"Error adding anime: {e}")
self.db.rollback()
def edit_anime(self, anime_id, data): def edit_anime(self, anime_id, data):
try: 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 ''
}
cursor = self.db.cursor() cursor = self.db.cursor()
cursor.execute( cursor.execute(
"UPDATE anime SET name=?, year=?, season=?, status=?, type=?, comment=?, url=? WHERE id=?", "UPDATE anime SET name=?, year=?, season=?, status=?, type=?, comment=?, url=? WHERE id=?",
(data['name'], data['year'], data['season'], data['status'], data['type'], data['comment'], data['url'], anime_id) (
sanitized_data['name'],
sanitized_data['year'],
sanitized_data['season'],
sanitized_data['status'],
sanitized_data['type'],
sanitized_data['comment'],
sanitized_data['url'],
anime_id
)
) )
self.db.commit() self.db.commit()
except Exception as e: except Exception as e:
logging.error(f"Error editing anime id {anime_id}: {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):
try: try:
@ -128,6 +136,7 @@ class AnimeBackend:
self.db.commit() self.db.commit()
except Exception as e: except Exception as e:
logging.error(f"Error deleting anime id {anime_id}: {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, new_status):
try: try:
@ -136,6 +145,7 @@ class AnimeBackend:
self.db.commit() self.db.commit()
except Exception as e: except Exception as e:
logging.error(f"Error changing status for anime id {anime_id}: {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):
try: try:
@ -148,6 +158,7 @@ class AnimeBackend:
self.db.commit() self.db.commit()
except Exception as e: except Exception as e:
logging.error(f"Error adding placeholders for year {year}: {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):
try: try:
@ -167,6 +178,13 @@ class AnimeBackend:
year = int(year_str) year = int(year_str)
except ValueError: except ValueError:
continue 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.execute( cursor.execute(
"SELECT id FROM anime WHERE name = ? AND year = ? AND season = ?", "SELECT id FROM anime WHERE name = ? AND year = ? AND season = ?",
(name, year, season) (name, year, season)
@ -179,6 +197,7 @@ class AnimeBackend:
self.db.commit() self.db.commit()
except Exception as e: except Exception as e:
logging.error(f"Error importing from CSV {file_name}: {e}") logging.error(f"Error importing from CSV {file_name}: {e}")
self.db.rollback()
def export_to_csv(self, file_name): def export_to_csv(self, file_name):
try: try:
@ -186,7 +205,7 @@ class AnimeBackend:
cursor.execute("SELECT * FROM anime") cursor.execute("SELECT * FROM anime")
rows = cursor.fetchall() rows = cursor.fetchall()
with open(file_name, 'w', newline='') as f: with open(file_name, 'w', newline='') as f:
writer = csv.writer(f) writer = csv.writer(f, quoting=csv.QUOTE_MINIMAL)
writer.writerow(['id', 'name', 'year', 'season', 'status', 'type', 'comment', 'url']) writer.writerow(['id', 'name', 'year', 'season', 'status', 'type', 'comment', 'url'])
writer.writerows(rows) writer.writerows(rows)
except Exception as e: except Exception as e:
@ -199,3 +218,31 @@ class AnimeBackend:
self.db.commit() self.db.commit()
except Exception as e: except Exception as e:
logging.error(f"Error deleting year {year}: {e}") logging.error(f"Error deleting year {year}: {e}")
self.db.rollback()
def get_total_entries(self):
try:
cursor = self.db.cursor()
cursor.execute("SELECT COUNT(*) FROM anime")
return cursor.fetchone()[0]
except Exception as e:
logging.error(f"Error getting total entries: {e}")
return 0
def get_completed_entries(self):
try:
cursor = self.db.cursor()
cursor.execute("SELECT COUNT(*) FROM anime WHERE status = 'completed'")
return cursor.fetchone()[0]
except Exception as e:
logging.error(f"Error getting completed entries: {e}")
return 0
def get_entries_by_type(self):
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)
except Exception as e:
logging.error(f"Error getting entries by type: {e}")
return []

55
build.spec Normal file
View File

@ -0,0 +1,55 @@
# -*- mode: python -*-
from PyInstaller.utils.hooks import collect_data_files, collect_submodules
block_cipher = None
a = Analysis(
['frontend.py'],
pathex=[],
binaries=[],
datas=[
('icons', 'icons'),
('anime_backlog.db', '.')
],
hiddenimports=[
'backend',
'sqlite3',
*collect_submodules('PyQt5')
],
hookspath=['.'],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='AnimeTracker',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False,
disable_windowed_tracker=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon='icons/anime-app-icon.png'
)
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
name='AnimeTracker',
)

View File

@ -9,7 +9,7 @@ from PyQt5.QtWidgets import (
QMainWindow, QTabWidget, QScrollArea, QWidget, QVBoxLayout, QTableWidget, QTableWidgetItem, QMainWindow, QTabWidget, QScrollArea, QWidget, QVBoxLayout, QTableWidget, QTableWidgetItem,
QLabel, QToolButton, QHBoxLayout, QDialog, QFormLayout, QLineEdit, QSpinBox, QLabel, QToolButton, QHBoxLayout, QDialog, QFormLayout, QLineEdit, QSpinBox,
QComboBox, QTextEdit, QDialogButtonBox, QAction, QFileDialog, QMessageBox, QComboBox, QTextEdit, QDialogButtonBox, QAction, QFileDialog, QMessageBox,
QInputDialog, QApplication, QAbstractItemView, QSizePolicy, QHeaderView, QPushButton QInputDialog, QApplication, QAbstractItemView, QSizePolicy, QHeaderView
) )
from PyQt5.QtCore import Qt, QSettings from PyQt5.QtCore import Qt, QSettings
from PyQt5.QtGui import QColor, QIcon, QFont from PyQt5.QtGui import QColor, QIcon, QFont
@ -25,6 +25,7 @@ class AnimeDialog(QDialog):
self.setWindowTitle("Add Anime" if entry is None else "Edit Anime") self.setWindowTitle("Add Anime" if entry is None else "Edit Anime")
layout = QFormLayout(self) layout = QFormLayout(self)
self.name_edit = QLineEdit() self.name_edit = QLineEdit()
self.name_edit.setMaxLength(255) # Prevent overly long inputs
layout.addRow("Name", self.name_edit) layout.addRow("Name", self.name_edit)
self.year_spin = QSpinBox() self.year_spin = QSpinBox()
self.year_spin.setRange(1900, 2100) self.year_spin.setRange(1900, 2100)
@ -39,20 +40,23 @@ class AnimeDialog(QDialog):
self.status_combo.addItems(['unwatched', 'watching', 'completed']) self.status_combo.addItems(['unwatched', 'watching', 'completed'])
layout.addRow("Status", self.status_combo) layout.addRow("Status", self.status_combo)
self.comment_edit = QTextEdit() self.comment_edit = QTextEdit()
self.comment_edit.setAcceptRichText(False) # Prevent HTML injection
layout.addRow("Comment", self.comment_edit) layout.addRow("Comment", self.comment_edit)
self.url_edit = QLineEdit() self.url_edit = QLineEdit()
self.url_edit.setMaxLength(2048) # Reasonable limit for URLs
layout.addRow("MAL URL", self.url_edit) layout.addRow("MAL URL", self.url_edit)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept) buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)
layout.addRow(buttons) layout.addRow(buttons)
if entry: if entry:
self.name_edit.setText(entry[1]) # Unescape for display in input fields
self.name_edit.setText(html.unescape(entry[1]))
self.year_spin.setValue(entry[2]) self.year_spin.setValue(entry[2])
self.season_combo.setCurrentText(entry[3]) self.season_combo.setCurrentText(entry[3])
self.status_combo.setCurrentText(entry[4]) self.status_combo.setCurrentText(entry[4])
self.type_combo.setCurrentText(entry[5] or '') self.type_combo.setCurrentText(entry[5] or '')
self.comment_edit.setText(entry[6] or '') self.comment_edit.setPlainText(html.unescape(entry[6] or ''))
self.url_edit.setText(entry[7] or '') self.url_edit.setText(entry[7] or '')
else: else:
if default_year is not None: if default_year is not None:
@ -70,14 +74,15 @@ class AnimeDialog(QDialog):
self.season_combo.setEnabled(True) self.season_combo.setEnabled(True)
def get_data(self): def get_data(self):
# Sanitize inputs by escaping special characters
return { return {
'name': self.name_edit.text(), 'name': html.escape(self.name_edit.text().strip()),
'year': self.year_spin.value(), 'year': self.year_spin.value(),
'season': self.season_combo.currentText(), 'season': html.escape(self.season_combo.currentText().strip()),
'status': self.status_combo.currentText(), 'status': html.escape(self.status_combo.currentText().strip()),
'type': self.type_combo.currentText(), 'type': html.escape(self.type_combo.currentText().strip()),
'comment': self.comment_edit.toPlainText(), 'comment': html.escape(self.comment_edit.toPlainText().strip()),
'url': self.url_edit.text() 'url': html.escape(self.url_edit.text().strip())
} }
class ShortcutsDialog(QDialog): class ShortcutsDialog(QDialog):
@ -92,7 +97,7 @@ Global Shortcuts:
- A: Add an anime - A: Add an anime
- Page Down: Next tab - Page Down: Next tab
- Page Up: Previous tab - Page Up: Previous tab
- Q: Quit the program - Ctrl + Q: Quit the program
- R: Pick random anime - R: Pick random anime
- Ctrl + + : Increase table scale - Ctrl + + : Increase table scale
- Ctrl + - : Decrease table scale - Ctrl + - : Decrease table scale
@ -145,7 +150,12 @@ class AnimeTracker(QMainWindow):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.settings = QSettings("xAI", "AnimeBacklogTracker") self.settings = QSettings("xAI", "AnimeBacklogTracker")
self.last_used_season = self.settings.value("lastUsedSeason", "winter")
self.setWindowTitle("Anime Backlog Tracker") 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))
self.resize(800, 600) self.resize(800, 600)
self.backend = AnimeBackend() self.backend = AnimeBackend()
self.tab_widget = QTabWidget() self.tab_widget = QTabWidget()
@ -153,45 +163,18 @@ class AnimeTracker(QMainWindow):
central_layout = QVBoxLayout(self.central_widget) central_layout = QVBoxLayout(self.central_widget)
self.filter_bar = QWidget() self.filter_bar = QWidget()
filter_layout = QHBoxLayout(self.filter_bar) 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:") label_search = QLabel("Search:")
self.search_edit = QLineEdit() self.search_edit = QLineEdit()
self.search_edit.setPlaceholderText("Search name or comment") self.search_edit.setPlaceholderText("Search name")
self.search_edit.textChanged.connect(self.filter_tables)
filter_layout.addWidget(label_search) filter_layout.addWidget(label_search)
filter_layout.addWidget(self.search_edit) 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.filter_bar)
central_layout.addWidget(self.tab_widget) central_layout.addWidget(self.tab_widget)
self.setCentralWidget(self.central_widget) self.setCentralWidget(self.central_widget)
self.year_filter = None self.search_text = ''
self.season_filter = None
self.status_filter = None
self.type_filter = None
self.search_text = None
self.table_scale = self.settings.value("tableScale", 1.0, type=float) self.table_scale = self.settings.value("tableScale", 1.0, type=float)
self.tables = []
self.create_menu() self.create_menu()
self.load_tabs() self.load_tabs()
self.restoreGeometry(self.settings.value("geometry", self.saveGeometry())) self.restoreGeometry(self.settings.value("geometry", self.saveGeometry()))
@ -200,18 +183,14 @@ class AnimeTracker(QMainWindow):
self.set_current_tab_by_identifier(last_tab) self.set_current_tab_by_identifier(last_tab)
self.tab_widget.setFocus() self.tab_widget.setFocus()
def apply_filters(self): def filter_tables(self, text):
year_val = self.year_spin.value() self.search_text = text.strip().lower()
self.year_filter = year_val if year_val > 0 else None for table in self.tables:
season_val = self.season_combo.currentText() for row in range(table.rowCount()):
self.season_filter = None if season_val == 'All' else ('' if season_val == 'Other' else season_val) name_col = 2 if table.is_pre else 1
status_val = self.status_combo.currentText() name_text = table.cellWidget(row, name_col).text()
self.status_filter = None if status_val == 'All' else status_val clean_name = re.sub(r'<[^>]+>', '', name_text).lower()
type_val = self.type_combo.currentText() table.setRowHidden(row, self.search_text not in clean_name if self.search_text else False)
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): def closeEvent(self, event):
self.settings.setValue("geometry", self.saveGeometry()) self.settings.setValue("geometry", self.saveGeometry())
@ -242,6 +221,9 @@ class AnimeTracker(QMainWindow):
random_act = QAction('Random Pick', self) random_act = QAction('Random Pick', self)
random_act.triggered.connect(self.random_pick) random_act.triggered.connect(self.random_pick)
tools_menu.addAction(random_act) 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') help_menu = menubar.addMenu('Help')
shortcuts_act = QAction('Shortcuts', self) shortcuts_act = QAction('Shortcuts', self)
shortcuts_act.triggered.connect(self.show_shortcuts) shortcuts_act.triggered.connect(self.show_shortcuts)
@ -281,23 +263,22 @@ class AnimeTracker(QMainWindow):
def load_tabs(self): def load_tabs(self):
self.tab_widget.clear() self.tab_widget.clear()
show_pre = True self.tables = []
if self.season_filter is not None and self.season_filter != '': # Pre-2010 tab
show_pre = False pre_entries = self.backend.get_pre_2010_entries()
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 = QScrollArea()
pre_tab.setWidgetResizable(True) pre_tab.setWidgetResizable(True)
pre_content = QWidget() pre_content = QWidget()
pre_layout = QVBoxLayout(pre_content) pre_layout = QVBoxLayout(pre_content)
if pre_entries: if pre_entries:
table = CustomTableWidget(self, is_pre=True) table = CustomTableWidget(self, is_pre=True)
table.is_pre = True
self.tables.append(table)
table.setRowCount(len(pre_entries)) table.setRowCount(len(pre_entries))
table.setColumnCount(7) table.setColumnCount(7)
headers = ['ID', 'Year', 'Name', 'Type', 'Status', 'Comment', 'Actions'] headers = ['ID', 'Year', 'Name', 'Type', 'Status', 'Comment', 'Actions']
table.setHorizontalHeaderLabels(headers) table.setHorizontalHeaderLabels(headers)
table.setColumnHidden(4, True) # Hide Status column
table.setColumnHidden(0, True) table.setColumnHidden(0, True)
table.setAlternatingRowColors(True) table.setAlternatingRowColors(True)
table.setShowGrid(True) table.setShowGrid(True)
@ -322,6 +303,15 @@ class AnimeTracker(QMainWindow):
table.setFont(font) table.setFont(font)
table.horizontalHeader().setFont(header_font) table.horizontalHeader().setFont(header_font)
table.verticalHeader().setFont(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): for row, entry in enumerate(pre_entries):
col = 0 col = 0
# ID # ID
@ -349,7 +339,6 @@ class AnimeTracker(QMainWindow):
name_label.setOpenExternalLinks(True) name_label.setOpenExternalLinks(True)
else: else:
name_label.setText(html.escape(name)) name_label.setText(html.escape(name))
name_label.setStyleSheet("padding-left: 10px;")
table.setCellWidget(row, col, name_label) table.setCellWidget(row, col, name_label)
col += 1 col += 1
# Type # Type
@ -399,7 +388,7 @@ class AnimeTracker(QMainWindow):
item.setBackground(color) item.setBackground(color)
widget = table.cellWidget(r, c) widget = table.cellWidget(r, c)
if widget: if widget:
widget.setStyleSheet(f"background-color: {color.name()};") widget.setStyleSheet(f"padding-left: 2px;background-color: {color.name()};")
pre_layout.addWidget(table) pre_layout.addWidget(table)
pre_tab.setWidget(pre_content) pre_tab.setWidget(pre_content)
tab_text = "Pre-2010" tab_text = "Pre-2010"
@ -416,8 +405,6 @@ class AnimeTracker(QMainWindow):
self.tab_widget.tabBar().setTabTextColor(index, QColor('gray')) self.tab_widget.tabBar().setTabTextColor(index, QColor('gray'))
# Years >= 2010 # Years >= 2010
years = self.backend.get_years() 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: for year in years:
year_tab = QScrollArea() year_tab = QScrollArea()
year_tab.setWidgetResizable(True) year_tab.setWidgetResizable(True)
@ -426,25 +413,51 @@ class AnimeTracker(QMainWindow):
total_entries = 0 total_entries = 0
comp_entries = 0 comp_entries = 0
for season in ['winter', 'spring', 'summer', 'fall', '']: for season in ['winter', 'spring', 'summer', 'fall', '']:
show_section = True entries = self.backend.get_entries_for_season(year, season)
if self.season_filter is not None: # Database schema order:
if season != self.season_filter: # 0: id
show_section = False # 1: name
if show_section: # 2: year
entries = self.backend.get_entries_for_season(year, season, self.status_filter, self.type_filter, self.search_text) # 3: season
# 4: status
# 5: type
# 6: comment
# 7: url
if entries: if entries:
# Season title
s_name = season.capitalize() if season else 'Other' s_name = season.capitalize() if season else 'Other'
label = QLabel(s_name) label = QLabel()
season_font = QFont() season_font = QFont()
season_font.setPointSize(int(12 * self.table_scale)) season_font.setPointSize(int(12 * self.table_scale))
season_font.setBold(True) season_font.setBold(True)
label.setFont(season_font) label.setFont(season_font)
if season: # Only create links for actual seasons, not 'Other'
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) # This makes the link clickable and open in browser
else:
label.setText(s_name) # No link for 'Other' section
layout.addWidget(label) 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: <b>{completed_anime}/{total_anime} ({completion_percentage:.0f}%)</b>")
s_stat_font = QFont()
s_stat_font.setPointSize(int(10 * self.table_scale)) # Slightly smaller than season label
s_stat.setFont(s_stat_font)
layout.addWidget(s_stat)
table = CustomTableWidget(self, is_pre=False) table = CustomTableWidget(self, is_pre=False)
table.is_pre = False
self.tables.append(table)
table.setRowCount(len(entries)) table.setRowCount(len(entries))
table.setColumnCount(6) table.setColumnCount(6)
headers = ['ID', 'Name', 'Type', 'Status', 'Comment', 'Actions'] headers = ['ID', 'Name', 'Type', 'Status', 'Comment', 'Actions']
table.setHorizontalHeaderLabels(headers) table.setHorizontalHeaderLabels(headers)
table.setColumnHidden(3, True) # Hide Status column
table.setColumnHidden(0, True) table.setColumnHidden(0, True)
table.setAlternatingRowColors(True) table.setAlternatingRowColors(True)
table.setShowGrid(True) table.setShowGrid(True)
@ -468,6 +481,15 @@ class AnimeTracker(QMainWindow):
table.setFont(font) table.setFont(font)
table.horizontalHeader().setFont(header_font) table.horizontalHeader().setFont(header_font)
table.verticalHeader().setFont(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): for row, entry in enumerate(entries):
col = 0 col = 0
# ID # ID
@ -490,7 +512,6 @@ class AnimeTracker(QMainWindow):
name_label.setOpenExternalLinks(True) name_label.setOpenExternalLinks(True)
else: else:
name_label.setText(html.escape(name)) name_label.setText(html.escape(name))
name_label.setStyleSheet("padding-left: 10px;")
table.setCellWidget(row, col, name_label) table.setCellWidget(row, col, name_label)
col += 1 col += 1
# Type # Type
@ -540,18 +561,19 @@ class AnimeTracker(QMainWindow):
item.setBackground(color) item.setBackground(color)
widget = table.cellWidget(r, c) widget = table.cellWidget(r, c)
if widget: if widget:
widget.setStyleSheet(f"background-color: {color.name()};") widget.setStyleSheet(f"padding-left: 2px; background-color: {color.name()};")
layout.addWidget(table) layout.addWidget(table)
total_entries += len(entries) total_entries += len(entries)
comp_entries += sum(1 for e in entries if e[4] == 'completed') comp_entries += sum(1 for e in entries if e[4] == 'completed')
year_tab.setWidget(content) year_tab.setWidget(content)
if total_entries > 0 or (self.year_filter == year): if total_entries > 0:
perc = (comp_entries / total_entries * 100) if total_entries > 0 else 0 perc = (comp_entries / total_entries * 100) if total_entries > 0 else 0
tab_text = f"{year} ({perc:.0f}%)" tab_text = f"{year} ({perc:.0f}%)"
completed = (comp_entries == total_entries) completed = (comp_entries == total_entries)
index = self.tab_widget.addTab(year_tab, tab_text) index = self.tab_widget.addTab(year_tab, tab_text)
if completed: if completed:
self.tab_widget.tabBar().setTabTextColor(index, QColor('gray')) self.tab_widget.tabBar().setTabTextColor(index, QColor('gray'))
self.filter_tables(self.search_text) # Apply search filter after loading tabs
def create_actions_widget(self, anime_id, status): def create_actions_widget(self, anime_id, status):
widget = QWidget() widget = QWidget()
@ -606,10 +628,16 @@ class AnimeTracker(QMainWindow):
parts = tab_text.split(' (') parts = tab_text.split(' (')
default_year = int(parts[0]) default_year = int(parts[0])
default_season = '' default_season = ''
dialog = AnimeDialog(self, None, default_year, default_season) dialog = AnimeDialog(self, None, default_year, self.last_used_season)
if dialog.exec_() == QDialog.Accepted: if dialog.exec_() == QDialog.Accepted:
current_id = self.get_current_tab_identifier() current_id = self.get_current_tab_identifier()
data = dialog.get_data() 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
self.backend.add_anime(data) self.backend.add_anime(data)
self.load_tabs() self.load_tabs()
self.set_current_tab_by_identifier(current_id) self.set_current_tab_by_identifier(current_id)
@ -620,7 +648,11 @@ class AnimeTracker(QMainWindow):
current_id = self.get_current_tab_identifier() current_id = self.get_current_tab_identifier()
dialog = AnimeDialog(self, entry) dialog = AnimeDialog(self, entry)
if dialog.exec_() == QDialog.Accepted: if dialog.exec_() == QDialog.Accepted:
current_id = self.get_current_tab_identifier()
data = dialog.get_data() data = dialog.get_data()
if not data['name']:
QMessageBox.warning(self, "Error", "Anime name cannot be empty.")
return
self.backend.edit_anime(anime_id, data) self.backend.edit_anime(anime_id, data)
self.load_tabs() self.load_tabs()
self.set_current_tab_by_identifier(current_id) self.set_current_tab_by_identifier(current_id)
@ -684,6 +716,41 @@ class AnimeTracker(QMainWindow):
else: else:
QMessageBox.information(self, "Random Pick", "No unwatched anime in this tab.") 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" # Handle empty type as 'None'
layout.addWidget(QLabel(f" {display_type}: <b>{count}</b>"))
else:
layout.addWidget(QLabel(" No data available"))
db_path = 'anime_backlog.db'
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): def import_csv(self):
file_name, _ = QFileDialog.getOpenFileName(self, "Import CSV", "", "CSV Files (*.csv)") file_name, _ = QFileDialog.getOpenFileName(self, "Import CSV", "", "CSV Files (*.csv)")
if file_name: if file_name:
@ -711,10 +778,11 @@ class AnimeTracker(QMainWindow):
current = self.tab_widget.currentIndex() current = self.tab_widget.currentIndex()
if current > 0: if current > 0:
self.tab_widget.setCurrentIndex(current - 1) self.tab_widget.setCurrentIndex(current - 1)
elif key == Qt.Key_Q:
self.close()
elif key == Qt.Key_R: elif key == Qt.Key_R:
self.random_pick() self.random_pick()
elif key == Qt.Key_Q:
self.close()
elif modifiers == Qt.ControlModifier: elif modifiers == Qt.ControlModifier:
if key == Qt.Key_Plus or key == Qt.Key_Equal: if key == Qt.Key_Plus or key == Qt.Key_Equal:
current_id = self.get_current_tab_identifier() current_id = self.get_current_tab_identifier()
@ -726,6 +794,9 @@ class AnimeTracker(QMainWindow):
self.table_scale = max(self.table_scale - 0.1, 0.5) self.table_scale = max(self.table_scale - 0.1, 0.5)
self.load_tabs() self.load_tabs()
self.set_current_tab_by_identifier(current_id) self.set_current_tab_by_identifier(current_id)
elif key == Qt.Key_Q:
self.close()
super().keyPressEvent(event) super().keyPressEvent(event)
if __name__ == '__main__': if __name__ == '__main__':

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

BIN
icons/anime-app-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
icons/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
icons/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 924 B

BIN
icons/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
icons/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

8
readme.md Normal file
View File

@ -0,0 +1,8 @@
# How to build this app:
`python -m venv anime_env`
`source anime_env/bin/activate`
`pip install pyinstaller`
`pyinstaller build.spec --clean`
# How to run this app without building:
`python frontend.py`

1
requirements.txt Normal file
View File

@ -0,0 +1 @@
PyQt5==5.15.10