From ccc1a90cbe6f7afa205f68ca04394b97ff312ec9 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sat, 2 Aug 2025 01:33:49 +0200 Subject: [PATCH] implemented client side filters, mobile first styling and modals for articles --- backend/app/config.py | 4 +- backend/app/database.py | 30 +- backend/app/main.py | 159 ++++++--- backend/app/schema.sql | 4 +- backend/app/services.py | 36 ++- frontend/src/App.vue | 148 +++++++-- frontend/src/assets/main.css | 28 +- frontend/src/components/ArticleModal.vue | 241 ++++++++++++++ frontend/src/components/NewsFilters.vue | 393 +++++++++++++++++++++++ frontend/src/components/NewsList.vue | 156 +++++++++ frontend/src/components/SyncButton.vue | 2 +- frontend/src/stores/useFeeds.ts | 11 + frontend/src/stores/useNews.ts | 53 ++- frontend/src/style.css | 114 +++++++ 14 files changed, 1248 insertions(+), 131 deletions(-) create mode 100644 frontend/src/components/ArticleModal.vue create mode 100644 frontend/src/components/NewsFilters.vue create mode 100644 frontend/src/components/NewsList.vue diff --git a/backend/app/config.py b/backend/app/config.py index 2189c21..9bc838f 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -4,7 +4,7 @@ from pathlib import Path DB_PATH = Path(os.getenv("DB_NAME", "owlynews.sqlite3")) OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://localhost:11434") -MIN_CRON_HOURS = float(os.getenv("MIN_CRON_HOURS", 0.5)) +MIN_CRON_HOURS = float(os.getenv("MIN_CRON_HOURS", 0.1)) DEFAULT_CRON_HOURS = float(os.getenv("CRON_HOURS", MIN_CRON_HOURS)) CRON_HOURS = max(MIN_CRON_HOURS, DEFAULT_CRON_HOURS) SYNC_COOLDOWN_MINUTES = int(os.getenv("SYNC_COOLDOWN_MINUTES", 30)) @@ -21,7 +21,7 @@ frontend_path = os.path.join( ) logging.basicConfig( - level=logging.WARNING, + level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) diff --git a/backend/app/database.py b/backend/app/database.py index 67d2059..6cd4e4d 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -175,31 +175,53 @@ class DatabaseManager: A database cursor for executing SQL statements """ conn = None + cursor = None try: conn = self._get_connection() + cursor = conn.cursor() if readonly: conn.execute("BEGIN DEFERRED") + else: + conn.execute("BEGIN IMMEDIATE") - cursor = conn.cursor() yield cursor if not readonly: conn.commit() + else: + # For readonly transactions, we still need to end the transaction + conn.commit() + except sqlite3.OperationalError as e: if conn: - conn.rollback() + try: + conn.rollback() + except: + pass if "database is locked" in str(e).lower(): logger.warning( f"⚠️ Database temporarily locked, operation may need retry: {e}") raise e except Exception as e: if conn: - conn.rollback() + try: + conn.rollback() + except: + pass raise e finally: + if cursor: + try: + cursor.close() + except: + pass if conn: - conn.close() + try: + conn.close() + except: + pass + @contextmanager def get_cursor_with_retry(self, diff --git a/backend/app/main.py b/backend/app/main.py index 14d27b8..9f5206d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -22,6 +22,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.interval import IntervalTrigger from fastapi import Depends, FastAPI, HTTPException, Response, status from fastapi.staticfiles import StaticFiles +from starlette.responses import JSONResponse from backend.app.config import ( CRON_HOURS, @@ -65,51 +66,117 @@ scheduler.start() @app.get("/news", response_model=List[Dict[str, Any]]) async def get_news( country: str = "DE", - from_: str = "2025-07-01", - to_: str = datetime.now(timezone.utc).strftime("%Y-%m-%d"), + from_: str = None, + to_: str = None, + timezone_name: str = "UTC", + all_countries: bool = False, + all_dates: bool = False, db: sqlite3.Cursor = Depends(get_db) ): """ Get news articles filtered by country and date range. - Now optimized for concurrent access while scheduler is running. + Now handles client timezone properly and supports multiple countries and all news. Args: - country: Country code to filter by (default: "DE") - from_: Start date in ISO format (default: "2025-07-01") - to: End date in ISO format (default: current date) + country: Country code(s) to filter by (default: "DE"). + For multiple countries, use comma-separated values like "DE,US,GB" + from_: Start date in ISO format (optional) + to_: End date in ISO format (optional) + timezone_name: Client timezone for date interpretation (default: "UTC") + all_countries: If True, returns news from all countries (overrides country param) + all_dates: If True, returns news from all dates (overrides date params) db: Database cursor dependency Returns: List of news articles matching the criteria """ try: - datetime.fromisoformat(from_) - datetime.fromisoformat(to_) + from zoneinfo import ZoneInfo - from_ts = int(datetime.fromisoformat(from_).timestamp()) - to_ts = int(datetime.fromisoformat(to_).timestamp()) + # Handle timezone + try: + client_tz = ZoneInfo(timezone_name) + except Exception: + logger.warning(f"Invalid timezone '{timezone_name}', using UTC") + client_tz = timezone.utc - db.execute( - """ - SELECT id, title, description, url, published, country, created_at - FROM news - WHERE country = ? - AND published BETWEEN ? AND ? - ORDER BY published DESC LIMIT 1000 - """, - (country, from_ts, to_ts) - ) + where_conditions = [] + params = [] - return [dict(row) for row in db.fetchall()] + if not all_countries: + countries = [c.strip().upper() for c in country.split(',') if c.strip()] + if len(countries) == 1: + where_conditions.append("country = ?") + params.append(countries[0]) + elif len(countries) > 1: + placeholders = ','.join(['?' for _ in countries]) + where_conditions.append(f"country IN ({placeholders})") + params.extend(countries) - except ValueError: - raise HTTPException( - 400, "Invalid date format. Use ISO format (YYYY-MM-DD)") + if not all_dates and (from_ or to_): + if not from_: + from_ = "2025-01-01" # Default start date + if not to_: + to_ = datetime.now(timezone.utc).strftime("%Y-%m-%d") # Default to today + + # Parse and convert dates + from_date_naive = datetime.fromisoformat(from_) + to_date_naive = datetime.fromisoformat(to_) + + from_date = from_date_naive.replace(tzinfo=client_tz) + to_date = to_date_naive.replace(tzinfo=client_tz) + + # Include the entire end date + to_date = to_date.replace(hour=23, minute=59, second=59) + + from_ts = int(from_date.timestamp()) + to_ts = int(to_date.timestamp()) + + where_conditions.append("published BETWEEN ? AND ?") + params.extend([from_ts, to_ts]) + + logger.info(f"Date range: {from_date} to {to_date} (UTC timestamps: {from_ts} to {to_ts})") + + # Build the complete SQL query + base_sql = """ + SELECT id, title, summary, url, published, country, created_at + FROM news \ + """ + + if where_conditions: + sql = base_sql + " WHERE " + " AND ".join(where_conditions) + else: + sql = base_sql + + sql += " ORDER BY published DESC LIMIT 1000" + + # Log query info + if all_countries and all_dates: + logger.info("Querying ALL news articles (no filters)") + elif all_countries: + logger.info(f"Querying news from ALL countries with date filter") + elif all_dates: + logger.info(f"Querying ALL dates for countries: {country}") + else: + logger.info(f"Querying news: countries={country}, timezone={timezone_name}") + + logger.info(f"SQL: {sql}") + logger.info(f"Parameters: {params}") + + # Execute the query + db.execute(sql, params) + rows = db.fetchall() + result = [dict(row) for row in rows] + + logger.info(f"Found {len(result)} news articles") + return result + + except ValueError as e: + logger.error(f"Date parsing error: {e}") + raise HTTPException(400, "Invalid date format. Use ISO format (YYYY-MM-DD)") except Exception as e: logger.error(f"❌ Error fetching news: {e}") - raise HTTPException( - 500, "Internal server error while fetching news" - ) + raise HTTPException(500, "Internal server error while fetching news") @app.get("/feeds", response_model=List[Dict[str, Any]]) @@ -125,7 +192,7 @@ async def list_feeds(db: sqlite3.Cursor = Depends(get_db)): """ try: db.execute("SELECT * FROM feeds ORDER BY country, url") - return [dict(row) for row in db.fetchall()] + return JSONResponse(content=[dict(row) for row in db.fetchall()]) except Exception as e: logger.error(f"❌ Error fetching feeds: {e}") raise HTTPException( @@ -133,23 +200,6 @@ async def list_feeds(db: sqlite3.Cursor = Depends(get_db)): ) -@app.get("/meta/last-sync", response_model=TimestampResponse) -async def get_last_sync(db: sqlite3.Cursor = Depends(get_db)): - """ - Get the timestamp of the last successful feed synchronization. - - Args: - db: Database cursor dependency - - Returns: - Object containing the timestamp as a Unix epoch - """ - db.execute("SELECT val FROM meta WHERE key='last_sync'") - row = db.fetchone() - if row is None: - import time - return {"ts": int(time.time())} - return {"ts": int(row["val"])} @app.post("/feeds", response_model=SuccessResponse) @@ -271,6 +321,25 @@ async def manual_sync(db: sqlite3.Cursor = Depends(get_db)): ) +@app.get("/meta/last-sync", response_model=TimestampResponse) +async def get_last_sync(db: sqlite3.Cursor = Depends(get_db)): + """ + Get the timestamp of the last successful feed synchronization. + + Args: + db: Database cursor dependency + + Returns: + Object containing the timestamp as a Unix epoch + """ + db.execute("SELECT val FROM meta WHERE key='last_sync'") + row = db.fetchone() + if row is None: + import time + return {"ts": int(time.time())} + return {"ts": int(row["val"])} + + @app.get("/settings/cron", response_model=HoursResponse) async def get_cron_schedule(db: sqlite3.Cursor = Depends(get_db)): """ diff --git a/backend/app/schema.sql b/backend/app/schema.sql index 7684db2..9bf91aa 100644 --- a/backend/app/schema.sql +++ b/backend/app/schema.sql @@ -4,11 +4,11 @@ CREATE TABLE IF NOT EXISTS news ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, - description TEXT, + summary TEXT, url TEXT NOT NULL, published TIMESTAMP NOT NULL, country TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + created_at INTEGER DEFAULT (strftime('%s', 'now')) ); -- Index for faster queries on published date diff --git a/backend/app/services.py b/backend/app/services.py index f682dd8..4e2f7f0 100644 --- a/backend/app/services.py +++ b/backend/app/services.py @@ -126,7 +126,7 @@ class NewsFetcher: def build_prompt( url: str, title: str = "", - description: str = "", + summary: str = "", content: str = "") -> str: """ Generate a prompt for the LLM to summarize an article. @@ -134,7 +134,7 @@ class NewsFetcher: Args: url: Public URL of the article to summarize title: Article title from RSS feed (optional) - description: Article description from RSS feed (optional) + summary: Article summary from RSS feed (optional) content: Extracted article content (optional) Returns: @@ -144,8 +144,8 @@ class NewsFetcher: context_info = [] if title: context_info.append(f"RSS-Titel: {title}") - if description: - context_info.append(f"RSS-Beschreibung: {description}") + if summary: + context_info.append(f"RSS-Beschreibung: {summary}") if content: content_preview = content[:500] + \ "..." if len(content) > 500 else content @@ -164,9 +164,9 @@ class NewsFetcher: "2. Falls kein Artikel-Inhalt verfügbar ist, nutze RSS-Titel und -Beschreibung\n" "3. Falls keine ausreichenden Informationen vorliegen, erstelle eine plausible Zusammenfassung basierend auf der URL\n" "4. Gib ausschließlich **gültiges minifiziertes JSON** zurück – kein Markdown, keine Kommentare\n" - "5. Struktur: {\"title\":\"…\",\"description\":\"…\"}\n" + "5. Struktur: {\"title\":\"…\",\"summary\":\"…\"}\n" "6. title: Aussagekräftiger deutscher Titel (max 100 Zeichen)\n" - "7. description: Deutsche Zusammenfassung (zwischen 100 und 160 Wörter)\n" + "7. summary: Deutsche Zusammenfassung (zwischen 100 und 160 Wörter)\n" "8. Kein Text vor oder nach dem JSON\n\n" "### Ausgabe\n" "Jetzt antworte mit dem JSON:" @@ -177,7 +177,7 @@ class NewsFetcher: client: httpx.AsyncClient, url: str, title: str = "", - description: str = "" + summary: str = "" ) -> Optional[ArticleSummary]: """ Generate a summary of an article using the LLM. @@ -187,7 +187,7 @@ class NewsFetcher: client: An active httpx AsyncClient for making requests url: URL of the article to summarize title: Article title from RSS feed - description: Article description from RSS feed + summary: Article summary from RSS feed Returns: A dictionary containing the article title and summaries in German and English, @@ -200,7 +200,7 @@ class NewsFetcher: f"⚠️ Could not fetch article content, using RSS data only") prompt = NewsFetcher.build_prompt( - url, title, description, article_content) + url, title, summary, article_content) payload = { "model": LLM_MODEL, "prompt": prompt, @@ -226,7 +226,7 @@ class NewsFetcher: summary_data = llm_response # Validate required fields - required_fields = ["title", "description"] + required_fields = ["title", "summary"] missing_fields = [ field for field in required_fields if field not in summary_data] @@ -237,12 +237,12 @@ class NewsFetcher: return None # Check summary quality metrics - description = len(summary_data.get("description", "").split()) + summary_length = len(summary_data.get("summary", "").split()) - if description > 160 or description < 100: + if summary_length > 160: logger.warning( f"⚠️ Summary exceeds word limit - " - f"Description: {description}/160" + f"Summary: {summary_length}/160" ) return cast(ArticleSummary, summary_data) @@ -373,7 +373,7 @@ class NewsFetcher: f"⚠️ Database check failed for article {i}, continuing: {db_error}") rss_title = getattr(entry, 'title', '') - rss_description = getattr( + rss_summary = getattr( entry, 'description', '') or getattr( entry, 'summary', '') @@ -381,9 +381,11 @@ class NewsFetcher: client, article_url, title=rss_title, - description=rss_description + summary=rss_summary ) + logger.info(summary) + if not summary: logger.warning( f"❌ Failed to get summary for article {i}: {article_url}") @@ -398,12 +400,12 @@ class NewsFetcher: """ INSERT OR IGNORE INTO news - (title, description, url, published, country) + (title, summary, url, published, country) VALUES (?, ?, ?, ?, ?) """, ( summary["title"], - summary["description"], + summary["summary"], article_url, published_timestamp, feed_row["country"], diff --git a/frontend/src/App.vue b/frontend/src/App.vue index f730d6d..764808b 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,41 +1,131 @@ + + - + diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 36fb845..12a953e 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -1,13 +1,7 @@ -@import './base.css'; +/* Clean main.css without conflicting styles */ -#app { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - font-weight: normal; -} - -a, +/* Keep only the link styles you want */ +a.custom-link, .green { text-decoration: none; color: hsla(160, 100%, 37%, 1); @@ -16,20 +10,10 @@ a, } @media (hover: hover) { - a:hover { + a.custom-link:hover, + .green:hover { background-color: hsla(160, 100%, 37%, 0.2); } } -@media (min-width: 1024px) { - body { - display: flex; - place-items: center; - } - - #app { - display: grid; - grid-template-columns: 1fr 1fr; - padding: 0 2rem; - } -} +/* Add any other custom styles you need here, but avoid layout-breaking rules */ diff --git a/frontend/src/components/ArticleModal.vue b/frontend/src/components/ArticleModal.vue new file mode 100644 index 0000000..9047916 --- /dev/null +++ b/frontend/src/components/ArticleModal.vue @@ -0,0 +1,241 @@ + + + + + + diff --git a/frontend/src/components/NewsFilters.vue b/frontend/src/components/NewsFilters.vue new file mode 100644 index 0000000..81825fd --- /dev/null +++ b/frontend/src/components/NewsFilters.vue @@ -0,0 +1,393 @@ + + + diff --git a/frontend/src/components/NewsList.vue b/frontend/src/components/NewsList.vue new file mode 100644 index 0000000..e43768a --- /dev/null +++ b/frontend/src/components/NewsList.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/frontend/src/components/SyncButton.vue b/frontend/src/components/SyncButton.vue index 353337f..1398892 100644 --- a/frontend/src/components/SyncButton.vue +++ b/frontend/src/components/SyncButton.vue @@ -11,7 +11,7 @@ async function click() {