Compare commits

..

No commits in common. "540b5a3194d7fe3862654570821d53c627127006" and "b5314497da9fc4e63417d58c14d430eb568ad840" have entirely different histories.

15 changed files with 352 additions and 540 deletions

6
.gitignore vendored
View File

@ -4,9 +4,3 @@ __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/

Binary file not shown.

Binary file not shown.

View File

@ -1,7 +1,6 @@
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,
@ -9,8 +8,7 @@ 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', isolation_level=None) # Autocommit mode to prevent locks self.db = sqlite3.connect('anime_backlog.db')
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):
@ -32,10 +30,28 @@ 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): def get_pre_2010_entries(self, status_filter=None, type_filter=None, search=None, year_filter=None):
try: try:
cursor = self.db.cursor() cursor = self.db.cursor()
cursor.execute("SELECT * FROM anime WHERE year < 2010 ORDER BY year DESC, name") sql = "SELECT * FROM anime WHERE year < 2010"
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}")
@ -50,10 +66,25 @@ 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): def get_entries_for_season(self, year, season, status_filter=None, type_filter=None, search=None):
try: try:
cursor = self.db.cursor() cursor = self.db.cursor()
cursor.execute("SELECT * FROM anime WHERE year = ? AND season = ? ORDER BY name", (year, season)) sql = "SELECT * FROM anime WHERE year = ? AND 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}")
@ -70,64 +101,25 @@ 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:
@ -136,7 +128,6 @@ 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:
@ -145,7 +136,6 @@ 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:
@ -158,7 +148,6 @@ 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:
@ -178,13 +167,6 @@ 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)
@ -197,7 +179,6 @@ 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:
@ -205,7 +186,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, quoting=csv.QUOTE_MINIMAL) writer = csv.writer(f)
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:
@ -218,31 +199,3 @@ 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 []

View File

@ -1,55 +0,0 @@
# -*- 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 QInputDialog, QApplication, QAbstractItemView, QSizePolicy, QHeaderView, QPushButton
) )
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,7 +25,6 @@ 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)
@ -40,23 +39,20 @@ 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:
# Unescape for display in input fields self.name_edit.setText(entry[1])
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.setPlainText(html.unescape(entry[6] or '')) self.comment_edit.setText(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:
@ -74,15 +70,14 @@ 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': html.escape(self.name_edit.text().strip()), 'name': self.name_edit.text(),
'year': self.year_spin.value(), 'year': self.year_spin.value(),
'season': html.escape(self.season_combo.currentText().strip()), 'season': self.season_combo.currentText(),
'status': html.escape(self.status_combo.currentText().strip()), 'status': self.status_combo.currentText(),
'type': html.escape(self.type_combo.currentText().strip()), 'type': self.type_combo.currentText(),
'comment': html.escape(self.comment_edit.toPlainText().strip()), 'comment': self.comment_edit.toPlainText(),
'url': html.escape(self.url_edit.text().strip()) 'url': self.url_edit.text()
} }
class ShortcutsDialog(QDialog): class ShortcutsDialog(QDialog):
@ -97,7 +92,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
- Ctrl + Q: Quit the program - 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
@ -150,12 +145,7 @@ 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()
@ -163,18 +153,45 @@ 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") self.search_edit.setPlaceholderText("Search name or comment")
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.search_text = '' self.year_filter = None
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()))
@ -183,14 +200,18 @@ 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 filter_tables(self, text): def apply_filters(self):
self.search_text = text.strip().lower() year_val = self.year_spin.value()
for table in self.tables: self.year_filter = year_val if year_val > 0 else None
for row in range(table.rowCount()): season_val = self.season_combo.currentText()
name_col = 2 if table.is_pre else 1 self.season_filter = None if season_val == 'All' else ('' if season_val == 'Other' else season_val)
name_text = table.cellWidget(row, name_col).text() status_val = self.status_combo.currentText()
clean_name = re.sub(r'<[^>]+>', '', name_text).lower() self.status_filter = None if status_val == 'All' else status_val
table.setRowHidden(row, self.search_text not in clean_name if self.search_text else False) 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): def closeEvent(self, event):
self.settings.setValue("geometry", self.saveGeometry()) self.settings.setValue("geometry", self.saveGeometry())
@ -221,9 +242,6 @@ 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)
@ -263,148 +281,143 @@ class AnimeTracker(QMainWindow):
def load_tabs(self): def load_tabs(self):
self.tab_widget.clear() self.tab_widget.clear()
self.tables = [] show_pre = True
# Pre-2010 tab if self.season_filter is not None and self.season_filter != '':
pre_entries = self.backend.get_pre_2010_entries() show_pre = False
pre_tab = QScrollArea() if self.year_filter is not None and self.year_filter >= 2010:
pre_tab.setWidgetResizable(True) show_pre = False
pre_content = QWidget() if show_pre:
pre_layout = QVBoxLayout(pre_content) 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)
if pre_entries: pre_tab = QScrollArea()
table = CustomTableWidget(self, is_pre=True) pre_tab.setWidgetResizable(True)
table.is_pre = True pre_content = QWidget()
self.tables.append(table) pre_layout = QVBoxLayout(pre_content)
table.setRowCount(len(pre_entries)) if pre_entries:
table.setColumnCount(7) table = CustomTableWidget(self, is_pre=True)
headers = ['ID', 'Year', 'Name', 'Type', 'Status', 'Comment', 'Actions'] table.setRowCount(len(pre_entries))
table.setHorizontalHeaderLabels(headers) table.setColumnCount(7)
table.setColumnHidden(4, True) # Hide Status column headers = ['ID', 'Year', 'Name', 'Type', 'Status', 'Comment', 'Actions']
table.setColumnHidden(0, True) table.setHorizontalHeaderLabels(headers)
table.setAlternatingRowColors(True) table.setColumnHidden(0, True)
table.setShowGrid(True) table.setAlternatingRowColors(True)
header = table.horizontalHeader() table.setShowGrid(True)
header.setStretchLastSection(False) header = table.horizontalHeader()
table.setSelectionBehavior(QAbstractItemView.SelectRows) header.setStretchLastSection(False)
table.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) table.setSelectionBehavior(QAbstractItemView.SelectRows)
table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) table.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
header.setSectionResizeMode(0, QHeaderView.Fixed) # ID hidden table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
header.setSectionResizeMode(1, QHeaderView.ResizeToContents) # Year header.setSectionResizeMode(0, QHeaderView.Fixed) # ID hidden
header.setSectionResizeMode(2, QHeaderView.Stretch) # Name header.setSectionResizeMode(1, QHeaderView.ResizeToContents) # Year
header.setSectionResizeMode(3, QHeaderView.ResizeToContents) # Type header.setSectionResizeMode(2, QHeaderView.Stretch) # Name
header.setSectionResizeMode(4, QHeaderView.ResizeToContents) # Status header.setSectionResizeMode(3, QHeaderView.ResizeToContents) # Type
header.setSectionResizeMode(5, QHeaderView.Stretch) # Comment header.setSectionResizeMode(4, QHeaderView.ResizeToContents) # Status
header.setSectionResizeMode(6, QHeaderView.ResizeToContents) # Actions header.setSectionResizeMode(5, QHeaderView.Stretch) # Comment
table.setColumnWidth(0, 0) # Set ID column width to 0 to ensure it's hidden header.setSectionResizeMode(6, QHeaderView.ResizeToContents) # Actions
font = QFont() table.setColumnWidth(0, 0) # Set ID column width to 0 to ensure it's hidden
font.setPointSize(int(10 * self.table_scale)) font = QFont()
header_font = QFont() font.setPointSize(int(10 * self.table_scale))
header_font.setPointSize(int(10 * self.table_scale)) header_font = QFont()
header_font.setBold(True) header_font.setPointSize(int(10 * self.table_scale))
table.setFont(font) header_font.setBold(True)
table.horizontalHeader().setFont(header_font) table.setFont(font)
table.verticalHeader().setFont(font) table.horizontalHeader().setFont(header_font)
# Add vertical header styling for index numbers table.verticalHeader().setFont(font)
vheader = table.verticalHeader() for row, entry in enumerate(pre_entries):
vheader.setDefaultAlignment(Qt.AlignCenter) col = 0
vheader.setStyleSheet(""" # ID
QHeaderView::section { id_item = QTableWidgetItem(str(entry[0]))
padding-left: 5px; table.setItem(row, col, id_item)
padding-right: 5px; col += 1
} # Year
""") year_item = QTableWidgetItem(str(entry[2]))
for row, entry in enumerate(pre_entries): year_item.setFont(font)
col = 0 table.setItem(row, col, year_item)
# ID col += 1
id_item = QTableWidgetItem(str(entry[0])) # Name
table.setItem(row, col, id_item) name = entry[1]
col += 1 url = entry[7]
# Year name_label = QLabel()
year_item = QTableWidgetItem(str(entry[2])) name_font = QFont(font)
year_item.setFont(font) if entry[4] == 'watching':
table.setItem(row, col, year_item) name_font.setItalic(True)
col += 1 elif entry[4] == 'completed':
# Name name_font.setStrikeOut(True)
name = entry[1] name_label.setFont(name_font)
url = entry[7] if url:
name_label = QLabel() name_escaped = html.escape(name)
name_font = QFont(font) name_label.setText(f'<a href="{url}" style="color: #0000FF; text-decoration: none;">{name_escaped}</a>')
if entry[4] == 'watching': name_label.setOpenExternalLinks(True)
name_font.setItalic(True) else:
elif entry[4] == 'completed': name_label.setText(html.escape(name))
name_font.setStrikeOut(True) name_label.setStyleSheet("padding-left: 10px;")
name_label.setFont(name_font) table.setCellWidget(row, col, name_label)
if url: col += 1
name_escaped = html.escape(name) # Type
name_label.setText(f' <a href="{url}" style="color: #0000FF; text-decoration: none;">{name_escaped}</a>') type_item = QTableWidgetItem(entry[5] or '')
name_label.setOpenExternalLinks(True) type_item.setFont(font)
else: table.setItem(row, col, type_item)
name_label.setText(html.escape(name)) col += 1
table.setCellWidget(row, col, name_label) # Status
col += 1 status_item = QTableWidgetItem(entry[4])
# Type status_item.setFont(font)
type_item = QTableWidgetItem(entry[5] or '') table.setItem(row, col, status_item)
type_item.setFont(font) col += 1
table.setItem(row, col, type_item) # Comment
col += 1 comment_item = QTableWidgetItem(entry[6] or '')
# Status comment_item.setFont(font)
status_item = QTableWidgetItem(entry[4]) table.setItem(row, col, comment_item)
status_item.setFont(font) col += 1
table.setItem(row, col, status_item) # Actions
col += 1 actions_widget = self.create_actions_widget(entry[0], entry[4])
# Comment for child in actions_widget.findChildren(QToolButton):
comment_item = QTableWidgetItem(entry[6] or '') child.setFont(font)
comment_item.setFont(font) table.setCellWidget(row, col, actions_widget)
table.setItem(row, col, comment_item) table.resizeColumnsToContents()
col += 1 table.resizeRowsToContents()
# Actions # Calculate fixed height
actions_widget = self.create_actions_widget(entry[0], entry[4]) height = table.horizontalHeader().height() + 2 # small margin
for child in actions_widget.findChildren(QToolButton): for i in range(table.rowCount()):
child.setFont(font) height += table.rowHeight(i)
table.setCellWidget(row, col, actions_widget) table.setFixedHeight(height)
table.resizeColumnsToContents() # Apply status colors
table.resizeRowsToContents() status_col = 4
# Calculate fixed height for r in range(table.rowCount()):
height = table.horizontalHeader().height() + 2 # small margin status = table.item(r, status_col).text()
for i in range(table.rowCount()): if status == 'unwatched':
height += table.rowHeight(i) color = QColor(255, 255, 255)
table.setFixedHeight(height) elif status == 'watching':
# Apply status colors color = QColor(255, 255, 0)
status_col = 4 elif status == 'completed':
for r in range(table.rowCount()): color = QColor(0, 255, 0)
status = table.item(r, status_col).text() else:
if status == 'unwatched': color = QColor(255, 255, 255)
color = QColor(255, 255, 255) for c in range(table.columnCount()):
elif status == 'watching': if c == 0:
color = QColor(255, 255, 0) continue # skip hidden id
elif status == 'completed': item = table.item(r, c)
color = QColor(0, 255, 0) if item:
else: item.setBackground(color)
color = QColor(255, 255, 255) widget = table.cellWidget(r, c)
for c in range(table.columnCount()): if widget:
if c == 0: widget.setStyleSheet(f"background-color: {color.name()};")
continue # skip hidden id pre_layout.addWidget(table)
item = table.item(r, c) pre_tab.setWidget(pre_content)
if item: tab_text = "Pre-2010"
item.setBackground(color) completed = False
widget = table.cellWidget(r, c) total = len(pre_entries)
if widget: if total > 0:
widget.setStyleSheet(f"padding-left: 2px;background-color: {color.name()};") comp = sum(1 for e in pre_entries if e[4] == 'completed')
pre_layout.addWidget(table) perc = (comp / total * 100)
pre_tab.setWidget(pre_content) tab_text += f" ({perc:.0f}%)"
tab_text = "Pre-2010" if comp == total:
completed = False completed = True
total = len(pre_entries) index = self.tab_widget.addTab(pre_tab, tab_text)
if total > 0: if completed:
comp = sum(1 for e in pre_entries if e[4] == 'completed') self.tab_widget.tabBar().setTabTextColor(index, QColor('gray'))
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 >= 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)
@ -413,167 +426,132 @@ 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', '']:
entries = self.backend.get_entries_for_season(year, season) show_section = True
# Database schema order: if self.season_filter is not None:
# 0: id if season != self.season_filter:
# 1: name show_section = False
# 2: year if show_section:
# 3: season entries = self.backend.get_entries_for_season(year, season, self.status_filter, self.type_filter, self.search_text)
# 4: status if entries:
# 5: type s_name = season.capitalize() if season else 'Other'
# 6: comment label = QLabel(s_name)
# 7: url season_font = QFont()
if entries: season_font.setPointSize(int(12 * self.table_scale))
# Season title season_font.setBold(True)
s_name = season.capitalize() if season else 'Other' label.setFont(season_font)
label = QLabel() layout.addWidget(label)
season_font = QFont() table = CustomTableWidget(self, is_pre=False)
season_font.setPointSize(int(12 * self.table_scale)) table.setRowCount(len(entries))
season_font.setBold(True) table.setColumnCount(6)
label.setFont(season_font) headers = ['ID', 'Name', 'Type', 'Status', 'Comment', 'Actions']
if season: # Only create links for actual seasons, not 'Other' table.setHorizontalHeaderLabels(headers)
mal_url = f"https://myanimelist.net/anime/season/{year}/{season}" table.setColumnHidden(0, True)
label.setText(f'<a href="{mal_url}" style="color: #0000FF; text-decoration: none; font-weight: bold;">{s_name}</a>') table.setAlternatingRowColors(True)
label.setTextFormat(Qt.RichText) table.setShowGrid(True)
label.setOpenExternalLinks(True) # This makes the link clickable and open in browser header = table.horizontalHeader()
else: header.setStretchLastSection(False)
label.setText(s_name) # No link for 'Other' section table.setSelectionBehavior(QAbstractItemView.SelectRows)
table.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
layout.addWidget(label) table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
header.setSectionResizeMode(0, QHeaderView.Fixed) # ID hidden
# Season's stats header.setSectionResizeMode(1, QHeaderView.Stretch) # Name
total_anime = len(entries) header.setSectionResizeMode(2, QHeaderView.ResizeToContents) # Type
completed_anime = sum(1 for entry in entries if entry[4] == 'completed') header.setSectionResizeMode(3, QHeaderView.ResizeToContents) # Status
completion_percentage = (completed_anime / total_anime * 100) if total_anime > 0 else 0 header.setSectionResizeMode(4, QHeaderView.Stretch) # Comment
s_stat = QLabel(f"Completed: <b>{completed_anime}/{total_anime} ({completion_percentage:.0f}%)</b>") header.setSectionResizeMode(5, QHeaderView.ResizeToContents) # Actions
s_stat_font = QFont() table.setColumnWidth(0, 0) # Set ID column width to 0 to ensure it's hidden
s_stat_font.setPointSize(int(10 * self.table_scale)) # Slightly smaller than season label font = QFont()
s_stat.setFont(s_stat_font) font.setPointSize(int(10 * self.table_scale))
layout.addWidget(s_stat) header_font = QFont()
table = CustomTableWidget(self, is_pre=False) header_font.setPointSize(int(10 * self.table_scale))
table.is_pre = False header_font.setBold(True)
self.tables.append(table) table.setFont(font)
table.setRowCount(len(entries)) table.horizontalHeader().setFont(header_font)
table.setColumnCount(6) table.verticalHeader().setFont(font)
headers = ['ID', 'Name', 'Type', 'Status', 'Comment', 'Actions'] for row, entry in enumerate(entries):
table.setHorizontalHeaderLabels(headers) col = 0
table.setColumnHidden(3, True) # Hide Status column # ID
table.setColumnHidden(0, True) id_item = QTableWidgetItem(str(entry[0]))
table.setAlternatingRowColors(True) table.setItem(row, col, id_item)
table.setShowGrid(True) col += 1
header = table.horizontalHeader() # Name
header.setStretchLastSection(False) name = entry[1]
table.setSelectionBehavior(QAbstractItemView.SelectRows) url = entry[7]
table.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) name_label = QLabel()
table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) name_font = QFont(font)
header.setSectionResizeMode(0, QHeaderView.Fixed) # ID hidden if entry[4] == 'watching':
header.setSectionResizeMode(1, QHeaderView.Stretch) # Name name_font.setItalic(True)
header.setSectionResizeMode(2, QHeaderView.ResizeToContents) # Type elif entry[4] == 'completed':
header.setSectionResizeMode(3, QHeaderView.ResizeToContents) # Status name_font.setStrikeOut(True)
header.setSectionResizeMode(4, QHeaderView.Stretch) # Comment name_label.setFont(name_font)
header.setSectionResizeMode(5, QHeaderView.ResizeToContents) # Actions if url:
table.setColumnWidth(0, 0) # Set ID column width to 0 to ensure it's hidden name_escaped = html.escape(name)
font = QFont() name_label.setText(f'<a href="{url}" style="color: #0000FF; text-decoration: none;">{name_escaped}</a>')
font.setPointSize(int(10 * self.table_scale)) name_label.setOpenExternalLinks(True)
header_font = QFont() else:
header_font.setPointSize(int(10 * self.table_scale)) name_label.setText(html.escape(name))
header_font.setBold(True) name_label.setStyleSheet("padding-left: 10px;")
table.setFont(font) table.setCellWidget(row, col, name_label)
table.horizontalHeader().setFont(header_font) col += 1
table.verticalHeader().setFont(font) # Type
# Add vertical header styling for index numbers type_item = QTableWidgetItem(entry[5] or '')
vheader = table.verticalHeader() type_item.setFont(font)
vheader.setDefaultAlignment(Qt.AlignCenter) table.setItem(row, col, type_item)
vheader.setStyleSheet(""" col += 1
QHeaderView::section { # Status
padding-left: 5px; status_item = QTableWidgetItem(entry[4])
padding-right: 5px; status_item.setFont(font)
} table.setItem(row, col, status_item)
""") col += 1
for row, entry in enumerate(entries): # Comment
col = 0 comment_item = QTableWidgetItem(entry[6] or '')
# ID comment_item.setFont(font)
id_item = QTableWidgetItem(str(entry[0])) table.setItem(row, col, comment_item)
table.setItem(row, col, id_item) col += 1
col += 1 # Actions
# Name actions_widget = self.create_actions_widget(entry[0], entry[4])
name = entry[1] for child in actions_widget.findChildren(QToolButton):
url = entry[7] child.setFont(font)
name_label = QLabel() table.setCellWidget(row, col, actions_widget)
name_font = QFont(font) table.resizeColumnsToContents()
if entry[4] == 'watching': table.resizeRowsToContents()
name_font.setItalic(True) # Calculate fixed height
elif entry[4] == 'completed': height = table.horizontalHeader().height() + 2 # small margin
name_font.setStrikeOut(True) for i in range(table.rowCount()):
name_label.setFont(name_font) height += table.rowHeight(i)
if url: table.setFixedHeight(height)
name_escaped = html.escape(name) # Apply status colors
name_label.setText(f' <a href="{url}" style="color: #0000FF; text-decoration: none;">{name_escaped}</a>') status_col = 3
name_label.setOpenExternalLinks(True) for r in range(table.rowCount()):
else: status = table.item(r, status_col).text()
name_label.setText(html.escape(name)) if status == 'unwatched':
table.setCellWidget(row, col, name_label) color = QColor(255, 255, 255)
col += 1 elif status == 'watching':
# Type color = QColor(255, 255, 0)
type_item = QTableWidgetItem(entry[5] or '') elif status == 'completed':
type_item.setFont(font) color = QColor(0, 255, 0)
table.setItem(row, col, type_item) else:
col += 1 color = QColor(255, 255, 255)
# Status for c in range(table.columnCount()):
status_item = QTableWidgetItem(entry[4]) if c == 0:
status_item.setFont(font) continue # skip hidden id
table.setItem(row, col, status_item) item = table.item(r, c)
col += 1 if item:
# Comment item.setBackground(color)
comment_item = QTableWidgetItem(entry[6] or '') widget = table.cellWidget(r, c)
comment_item.setFont(font) if widget:
table.setItem(row, col, comment_item) widget.setStyleSheet(f"background-color: {color.name()};")
col += 1 layout.addWidget(table)
# Actions total_entries += len(entries)
actions_widget = self.create_actions_widget(entry[0], entry[4]) comp_entries += sum(1 for e in entries if e[4] == 'completed')
for child in actions_widget.findChildren(QToolButton):
child.setFont(font)
table.setCellWidget(row, col, actions_widget)
table.resizeColumnsToContents()
table.resizeRowsToContents()
# Calculate fixed height
height = table.horizontalHeader().height() + 2 # small margin
for i in range(table.rowCount()):
height += table.rowHeight(i)
table.setFixedHeight(height)
# Apply status colors
status_col = 3
for r in range(table.rowCount()):
status = table.item(r, status_col).text()
if status == 'unwatched':
color = QColor(255, 255, 255)
elif status == 'watching':
color = QColor(255, 255, 0)
elif status == 'completed':
color = QColor(0, 255, 0)
else:
color = QColor(255, 255, 255)
for c in range(table.columnCount()):
if c == 0:
continue # skip hidden id
item = table.item(r, c)
if item:
item.setBackground(color)
widget = table.cellWidget(r, c)
if widget:
widget.setStyleSheet(f"padding-left: 2px; background-color: {color.name()};")
layout.addWidget(table)
total_entries += len(entries)
comp_entries += sum(1 for e in entries if e[4] == 'completed')
year_tab.setWidget(content) year_tab.setWidget(content)
if total_entries > 0: if total_entries > 0 or (self.year_filter == year):
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()
@ -628,16 +606,10 @@ 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, self.last_used_season) dialog = AnimeDialog(self, None, default_year, default_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)
@ -648,11 +620,7 @@ 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)
@ -716,41 +684,6 @@ 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:
@ -778,11 +711,10 @@ 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_R:
self.random_pick()
elif key == Qt.Key_Q: elif key == Qt.Key_Q:
self.close() self.close()
elif key == Qt.Key_R:
self.random_pick()
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()
@ -794,9 +726,6 @@ 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.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 924 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@ -1,8 +0,0 @@
# 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`

View File

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