Compare commits

..

7 Commits
v1.0.1 ... main

Author SHA1 Message Date
Bernd
a9d7e3783f code refactoring 2025-08-08 16:31:49 +05:00
Bernd
9c7fe0c32d fixing building instructions 2025-08-06 13:40:43 +05:00
Bernd
4c3265242d changing key shortcuts for tab navigation 2025-08-06 13:33:29 +05:00
Bernd
c0a37c4b45 updating readme 2025-08-05 16:51:58 +05:00
Bernd
86e7b72323 fixed season default value when editing anime 2025-08-05 16:36:43 +05:00
Bernd
8900b7b11d preserving sroll position fix 2025-08-05 16:22:23 +05:00
Bernd
8be501dd46 fixing season combo box logic 2025-07-31 20:16:10 +05:00
3 changed files with 688 additions and 448 deletions

View File

@ -1,17 +1,26 @@
# backend.py
import sqlite3 import sqlite3
import csv import csv
import logging import logging
import html import html
from typing import Dict, Any, Optional
# Set up logging # Set up logging
logging.basicConfig(filename='anime_tracker.log', level=logging.ERROR, logging.basicConfig(filename='anime_tracker.log', level=logging.ERROR,
format='%(asctime)s - %(levelname)s - %(message)s') format='%(asctime)s - %(levelname)s - %(message)s')
ALLOWED_STATUSES = {'unwatched', 'watching', 'completed'}
class AnimeBackend: class AnimeBackend:
def __init__(self): def __init__(self, db_path: str = 'anime_backlog.db'):
self.db = sqlite3.connect('anime_backlog.db', isolation_level=None) # Autocommit mode to prevent locks # Use autocommit mode (isolation_level=None). Keep commits explicit where needed.
self.db.execute('PRAGMA journal_mode=WAL') # Use WAL mode for better concurrency self.db_path = db_path
self.db = sqlite3.connect(self.db_path, isolation_level=None, check_same_thread=False)
self.db.execute('PRAGMA journal_mode=WAL') # better concurrency
# Enable returning rows as tuples (default). Create table and useful indexes.
self.create_table() self.create_table()
self.create_indexes()
def create_table(self): def create_table(self):
try: try:
@ -28,10 +37,51 @@ class AnimeBackend:
url TEXT url TEXT
) )
""") """)
# No explicit commit required in autocommit mode, but keep for clarity
self.db.commit() self.db.commit()
except Exception as e: except Exception as e:
logging.error(f"Error creating table: {e}") logging.error(f"Error creating table: {e}")
def create_indexes(self):
try:
cursor = self.db.cursor()
cursor.execute("CREATE INDEX IF NOT EXISTS idx_year_season ON anime(year, season)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_name_year_season ON anime(name, year, season)")
self.db.commit()
except Exception as e:
logging.error(f"Error creating indexes: {e}")
def sanitize_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""
Prepare and validate incoming data (from frontend or CSV) before writing to DB.
Backend is responsible for HTML-escaping text fields to avoid double-escaping problems.
"""
name = data.get('name') or ''
name = name.strip()
year = data.get('year') or 0
try:
year = int(year)
except Exception:
year = 0
season = (data.get('season') or '').strip()
status = (data.get('status') or 'unwatched').strip()
if status not in ALLOWED_STATUSES:
status = 'unwatched'
type_ = (data.get('type') or '').strip()
comment = (data.get('comment') or '').strip()
url = (data.get('url') or '').strip()
# HTML-escape user visible fields to prevent HTML injection in UI.
return {
'name': html.escape(name),
'year': year,
'season': season,
'status': status,
'type': html.escape(type_),
'comment': html.escape(comment),
'url': url
}
def get_pre_2010_entries(self): def get_pre_2010_entries(self):
try: try:
cursor = self.db.cursor() cursor = self.db.cursor()
@ -50,7 +100,7 @@ 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: int, season: str):
try: try:
cursor = self.db.cursor() cursor = self.db.cursor()
cursor.execute("SELECT * FROM anime WHERE year = ? AND season = ? ORDER BY name", (year, season)) cursor.execute("SELECT * FROM anime WHERE year = ? AND season = ? ORDER BY name", (year, season))
@ -59,7 +109,7 @@ class AnimeBackend:
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}")
return [] return []
def get_anime_by_id(self, anime_id): def get_anime_by_id(self, anime_id: int) -> Optional[tuple]:
try: try:
cursor = self.db.cursor() cursor = self.db.cursor()
cursor.execute("SELECT * FROM anime WHERE id = ?", (anime_id,)) cursor.execute("SELECT * FROM anime WHERE id = ?", (anime_id,))
@ -68,86 +118,52 @@ class AnimeBackend:
logging.error(f"Error getting anime by id {anime_id}: {e}") logging.error(f"Error getting anime by id {anime_id}: {e}")
return None return None
def add_anime(self, data): def add_anime(self, data: Dict[str, Any]):
try: try:
# Sanitize string inputs d = self.sanitize_data(data)
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 (?, ?, ?, ?, ?, ?, ?)",
( (d['name'], d['year'], d['season'], d['status'], d['type'], d['comment'], d['url'])
sanitized_data['name'],
sanitized_data['year'],
sanitized_data['season'],
sanitized_data['status'],
sanitized_data['type'],
sanitized_data['comment'],
sanitized_data['url']
)
) )
# autocommit mode ensures immediate write, but call commit for clarity
self.db.commit() self.db.commit()
return cursor.lastrowid
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: int, data: Dict[str, Any]):
try: try:
# Sanitize string inputs d = self.sanitize_data(data)
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=?",
( (d['name'], d['year'], d['season'], d['status'], d['type'], d['comment'], d['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: int):
try: try:
cursor = self.db.cursor() cursor = self.db.cursor()
cursor.execute("DELETE FROM anime WHERE id = ?", (anime_id,)) cursor.execute("DELETE FROM anime WHERE id = ?", (anime_id,))
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: int, new_status: str):
try: try:
if new_status not in ALLOWED_STATUSES:
logging.error(f"Attempt to set invalid status: {new_status}")
return
cursor = self.db.cursor() cursor = self.db.cursor()
cursor.execute("UPDATE anime SET status = ? WHERE id = ?", (new_status, anime_id)) cursor.execute("UPDATE anime SET status = ? WHERE id = ?", (new_status, anime_id))
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: int):
try: try:
cursor = self.db.cursor() cursor = self.db.cursor()
for season in ['winter', 'spring', 'summer', 'fall', '']: for season in ['winter', 'spring', 'summer', 'fall', '']:
@ -158,69 +174,80 @@ 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: str):
skipped = 0
inserted = 0
try: try:
with open(file_name, 'r', newline='') as f: with open(file_name, 'r', newline='', encoding='utf-8') as f:
reader = csv.reader(f) reader = csv.reader(f)
header = next(reader, None) header = next(reader, None)
if header:
cursor = self.db.cursor() cursor = self.db.cursor()
for row in reader: for row in reader:
# Accept either 7 columns (no id) or 8 columns (with id)
if len(row) == 7: if len(row) == 7:
name, year_str, season, status, type_, comment, url = row name, year_str, season, status, type_, comment, url = row
elif len(row) == 8: elif len(row) == 8:
_, name, year_str, season, status, type_, comment, url = row _, name, year_str, season, status, type_, comment, url = row
else: else:
skipped += 1
logging.error(f"Skipping CSV row with unexpected length {len(row)}: {row}")
continue continue
try: try:
year = int(year_str) year = int(year_str)
except ValueError: except ValueError:
skipped += 1
logging.error(f"Skipping CSV row with invalid year: {row}")
continue continue
# Sanitize CSV inputs # Prepare and sanitize
name = html.escape(name.strip()) if name else '' data = {
season = season.strip() if season else '' 'name': name,
status = status.strip() if status else 'unwatched' 'year': year,
type_ = type_.strip() if type_ else '' 'season': season,
comment = html.escape(comment.strip()) if comment else '' 'status': status,
url = url.strip() if url else '' 'type': type_,
'comment': comment,
'url': url
}
d = self.sanitize_data(data)
# Avoid duplicates by name/year/season
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) (d['name'], d['year'], d['season'])
) )
if not cursor.fetchone(): if not cursor.fetchone():
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 (?, ?, ?, ?, ?, ?, ?)",
(name, year, season, status, type_, comment, url) (d['name'], d['year'], d['season'], d['status'], d['type'], d['comment'], d['url'])
) )
inserted += 1
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() finally:
logging.info(f"CSV import finished: inserted={inserted}, skipped={skipped}")
def export_to_csv(self, file_name): def export_to_csv(self, file_name: str):
try: try:
cursor = self.db.cursor() cursor = self.db.cursor()
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='', encoding='utf-8') as f:
writer = csv.writer(f, quoting=csv.QUOTE_MINIMAL) 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:
logging.error(f"Error exporting to CSV {file_name}: {e}") logging.error(f"Error exporting to CSV {file_name}: {e}")
def delete_year(self, year): def delete_year(self, year: int):
try: try:
cursor = self.db.cursor() cursor = self.db.cursor()
cursor.execute("DELETE FROM anime WHERE year = ?", (year,)) cursor.execute("DELETE FROM anime WHERE year = ?", (year,))
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): def get_total_entries(self) -> int:
try: try:
cursor = self.db.cursor() cursor = self.db.cursor()
cursor.execute("SELECT COUNT(*) FROM anime") cursor.execute("SELECT COUNT(*) FROM anime")
@ -229,7 +256,7 @@ class AnimeBackend:
logging.error(f"Error getting total entries: {e}") logging.error(f"Error getting total entries: {e}")
return 0 return 0
def get_completed_entries(self): def get_completed_entries(self) -> int:
try: try:
cursor = self.db.cursor() cursor = self.db.cursor()
cursor.execute("SELECT COUNT(*) FROM anime WHERE status = 'completed'") cursor.execute("SELECT COUNT(*) FROM anime WHERE status = 'completed'")
@ -242,7 +269,23 @@ class AnimeBackend:
try: try:
cursor = self.db.cursor() cursor = self.db.cursor()
cursor.execute("SELECT type, COUNT(*) FROM anime GROUP BY type ORDER BY 2 DESC") cursor.execute("SELECT type, COUNT(*) FROM anime GROUP BY type ORDER BY 2 DESC")
return cursor.fetchall() # Returns list of tuples: (type, count) return cursor.fetchall() # list of (type, count)
except Exception as e: except Exception as e:
logging.error(f"Error getting entries by type: {e}") logging.error(f"Error getting entries by type: {e}")
return [] return []
def close(self):
try:
if self.db:
self.db.close()
self.db = None
except Exception as e:
logging.error(f"Error closing DB: {e}")
def __del__(self):
try:
if getattr(self, 'db', None):
self.db.close()
except Exception:
pass

File diff suppressed because it is too large Load Diff

View File

@ -10,5 +10,8 @@
Then run a binary: Then run a binary:
`./dist/AnimeTracker/AnimeTracker` `./dist/AnimeTracker/AnimeTracker`
## How to update the binary file
`rm -rf ~/Applications/AnimeTracker/_internal && cp -r ~/Documents/programs/python/anime-tracker/dist/AnimeTracker ~/Applications/`
## How to run this app without building: ## How to run this app without building:
`python frontend.py` `python frontend.py`