first commit
This commit is contained in:
60
.gitignore
vendored
Normal file
60
.gitignore
vendored
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# IDE and editor files
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Environment and virtual environments
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!example.env
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# OS specific files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Compiled Python files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Local development files
|
||||||
|
.local
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Yarn
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/sdks
|
||||||
|
!.yarn/versions
|
199
README.md
Normal file
199
README.md
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
# Owly News Summariser
|
||||||
|
|
||||||
|
Owly News Summariser is a web application that fetches news articles from various RSS feeds, uses an LLM (Large Language Model) to summarize them, and presents them in a clean, user-friendly interface.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Fetches news from configurable RSS feeds
|
||||||
|
- Automatically summarizes articles using Ollama LLM
|
||||||
|
- Filters news by country
|
||||||
|
- Progressive Web App (PWA) support for offline access
|
||||||
|
- Scheduled background updates
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
The project consists of two main components:
|
||||||
|
|
||||||
|
- **Backend**: A FastAPI application that fetches and processes news feeds, summarizes articles, and provides API endpoints
|
||||||
|
- **Frontend**: A Vue.js application that displays the news and provides a user interface for managing feeds
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Python 3.8+ for the backend
|
||||||
|
- Node.js 16+ and Yarn for the frontend
|
||||||
|
- [Ollama](https://ollama.ai/) for article summarization
|
||||||
|
|
||||||
|
## Installing Yarn
|
||||||
|
|
||||||
|
Yarn is a package manager for JavaScript that's required for the frontend. Here's how to install it:
|
||||||
|
|
||||||
|
### Using npm (recommended)
|
||||||
|
|
||||||
|
If you already have Node.js installed, the easiest way to install Yarn is via npm:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g yarn
|
||||||
|
```
|
||||||
|
|
||||||
|
### Platform-specific installations
|
||||||
|
|
||||||
|
#### Windows
|
||||||
|
|
||||||
|
- **Using Chocolatey**: `choco install yarn`
|
||||||
|
- **Using Scoop**: `scoop install yarn`
|
||||||
|
- **Manual installation**: Download and run the [installer](https://classic.yarnpkg.com/latest.msi)
|
||||||
|
|
||||||
|
#### macOS
|
||||||
|
|
||||||
|
- **Using Homebrew**: `brew install yarn`
|
||||||
|
- **Using MacPorts**: `sudo port install yarn`
|
||||||
|
|
||||||
|
#### Linux
|
||||||
|
|
||||||
|
- **Debian/Ubuntu**:
|
||||||
|
```bash
|
||||||
|
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
|
||||||
|
echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
|
||||||
|
sudo apt update && sudo apt install yarn
|
||||||
|
```
|
||||||
|
|
||||||
|
- **CentOS/Fedora/RHEL**:
|
||||||
|
```bash
|
||||||
|
curl --silent --location https://dl.yarnpkg.com/rpm/yarn.repo | sudo tee /etc/yum.repos.d/yarn.repo
|
||||||
|
sudo yum install yarn
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Arch Linux**: `pacman -S yarn`
|
||||||
|
|
||||||
|
After installation, verify Yarn is installed correctly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn --version
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### Backend Setup
|
||||||
|
|
||||||
|
1. Navigate to the backend directory:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create a virtual environment:
|
||||||
|
```bash
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Install dependencies:
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Create a `.env` file based on the example:
|
||||||
|
```bash
|
||||||
|
cp example.env .env
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Customize the `.env` file as needed:
|
||||||
|
- `OLLAMA_HOST`: URL for the Ollama service (default: http://localhost:11434)
|
||||||
|
- `CRON_HOURS`: Interval for scheduled news fetching (default: 1)
|
||||||
|
- `DATABASE_URL`: SQLite database connection string
|
||||||
|
|
||||||
|
### Frontend Setup
|
||||||
|
|
||||||
|
1. Navigate to the frontend directory:
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install dependencies:
|
||||||
|
```bash
|
||||||
|
yarn
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running the Application
|
||||||
|
|
||||||
|
### Running the Backend
|
||||||
|
|
||||||
|
1. Navigate to the backend directory:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Activate the virtual environment:
|
||||||
|
```bash
|
||||||
|
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Start the backend server:
|
||||||
|
```bash
|
||||||
|
uvicorn app.main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend will be available at http://localhost:8000
|
||||||
|
|
||||||
|
### Running the Frontend
|
||||||
|
|
||||||
|
1. Navigate to the frontend directory:
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start the development server:
|
||||||
|
```bash
|
||||||
|
yarn dev:watch
|
||||||
|
```
|
||||||
|
|
||||||
|
The frontend will be available at http://localhost:5173
|
||||||
|
|
||||||
|
## Building for Production
|
||||||
|
|
||||||
|
### Building the Backend
|
||||||
|
|
||||||
|
The backend can be deployed as a standard FastAPI application. You can use tools like Gunicorn with Uvicorn workers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install gunicorn
|
||||||
|
gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building the Frontend
|
||||||
|
|
||||||
|
1. Navigate to the frontend directory:
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Build the frontend:
|
||||||
|
```bash
|
||||||
|
yarn build
|
||||||
|
```
|
||||||
|
|
||||||
|
The built files will be in the `dist` directory and can be served by any static file server.
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
The backend provides the following API endpoints:
|
||||||
|
|
||||||
|
- `GET /news`: Get news articles with optional filtering
|
||||||
|
- `GET /meta/last_sync`: Get the timestamp of the last feed synchronization
|
||||||
|
- `POST /meta/cron`: Set the schedule for automatic feed synchronization
|
||||||
|
- `GET /meta/feeds`: List all configured feeds
|
||||||
|
- `POST /meta/feeds`: Add a new feed
|
||||||
|
- `DELETE /meta/feeds`: Delete a feed
|
||||||
|
- `GET /meta/model`: Check the status of the LLM model
|
||||||
|
- `POST /meta/sync`: Manually trigger a feed synchronization
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- `OLLAMA_HOST`: URL for the Ollama service
|
||||||
|
- `CRON_HOURS`: Interval for scheduled news fetching in hours
|
||||||
|
- `DATABASE_URL`: SQLite database connection string
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT License](LICENSE)
|
56
backend/.gitignore
vendored
Normal file
56
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!example.env
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# Python bytecode
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# IDE specific files
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
635
backend/app/main.py
Normal file
635
backend/app/main.py
Normal file
@@ -0,0 +1,635 @@
|
|||||||
|
"""
|
||||||
|
Owly News Summariser Backend
|
||||||
|
|
||||||
|
This module provides a FastAPI application that serves as the backend for the Owly News Summariser.
|
||||||
|
It handles fetching news from RSS feeds, summarizing articles using Ollama/qwen, and providing
|
||||||
|
an API for the frontend to access the summarized news.
|
||||||
|
|
||||||
|
The application uses SQLite for data storage and APScheduler for scheduling periodic news harvesting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional, Any, Union, Iterator, Tuple, TypedDict, cast
|
||||||
|
|
||||||
|
import feedparser
|
||||||
|
import httpx
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from fastapi import FastAPI, Response, status, Depends
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
# Constants
|
||||||
|
DB_PATH = Path("owlynews.sqlite")
|
||||||
|
OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://localhost:11434")
|
||||||
|
MIN_CRON_HOURS = 0.5
|
||||||
|
DEFAULT_CRON_HOURS = float(os.getenv("CRON_HOURS", MIN_CRON_HOURS))
|
||||||
|
CRON_HOURS = max(MIN_CRON_HOURS, DEFAULT_CRON_HOURS)
|
||||||
|
SYNC_COOLDOWN_MINUTES = 30
|
||||||
|
LLM_MODEL = "qwen2:7b-instruct-q4_K_M"
|
||||||
|
LLM_TIMEOUT_SECONDS = 180
|
||||||
|
OLLAMA_API_TIMEOUT_SECONDS = 10
|
||||||
|
|
||||||
|
# FastAPI app initialization
|
||||||
|
app = FastAPI(
|
||||||
|
title="Owly News Summariser",
|
||||||
|
description="API for the Owly News Summariser application",
|
||||||
|
version="1.0.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Database schema definitions
|
||||||
|
SCHEMA_SQL = [
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS news (
|
||||||
|
id TEXT PRIMARY KEY, -- e.g. URL as unique identifier
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
summary_de TEXT,
|
||||||
|
summary_en TEXT,
|
||||||
|
published INTEGER, -- Unix epoch (UTC); use TEXT ISO-8601 if you prefer
|
||||||
|
source TEXT,
|
||||||
|
country TEXT,
|
||||||
|
source_feed TEXT
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_news_published ON news(published)",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS feeds (
|
||||||
|
id INTEGER PRIMARY KEY, -- auto-increment via rowid
|
||||||
|
country TEXT,
|
||||||
|
url TEXT UNIQUE NOT NULL
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
val TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
val TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseManager:
|
||||||
|
"""
|
||||||
|
Manages database connections and operations for the application.
|
||||||
|
Provides methods for initializing the database, executing queries,
|
||||||
|
and managing transactions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db_path: Path):
|
||||||
|
"""
|
||||||
|
Initialize the database manager with the given database path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: Path to the SQLite database file
|
||||||
|
"""
|
||||||
|
self.db_path = db_path
|
||||||
|
self._connection = None
|
||||||
|
self._initialize_db()
|
||||||
|
|
||||||
|
def _get_connection(self) -> sqlite3.Connection:
|
||||||
|
"""
|
||||||
|
Get or create a database connection.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An active SQLite connection
|
||||||
|
"""
|
||||||
|
if self._connection is None:
|
||||||
|
self._connection = sqlite3.connect(
|
||||||
|
self.db_path,
|
||||||
|
check_same_thread=False
|
||||||
|
)
|
||||||
|
self._connection.row_factory = sqlite3.Row
|
||||||
|
return self._connection
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_cursor(self) -> Iterator[sqlite3.Cursor]:
|
||||||
|
"""
|
||||||
|
Context manager that provides a database cursor and handles commits and rollbacks.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
A database cursor for executing SQL statements
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
with db_manager.get_cursor() as cursor:
|
||||||
|
cursor.execute("SELECT * FROM table")
|
||||||
|
results = cursor.fetchall()
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
conn = self._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
yield cursor
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _initialize_db(self) -> None:
|
||||||
|
"""
|
||||||
|
Initialize the database schema and default settings.
|
||||||
|
Creates tables if they don't exist and inserts default values.
|
||||||
|
"""
|
||||||
|
# Create schema
|
||||||
|
with self.get_cursor() as cursor:
|
||||||
|
for stmt in SCHEMA_SQL:
|
||||||
|
cursor.execute(stmt)
|
||||||
|
|
||||||
|
# Insert initial settings
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO settings VALUES (?, ?) ON CONFLICT (key) DO NOTHING",
|
||||||
|
("cron_hours", str(CRON_HOURS))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Insert initial metadata
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO meta VALUES (?, ?) ON CONFLICT (key) DO NOTHING",
|
||||||
|
("last_sync", "0")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Seed feeds if none exist
|
||||||
|
cursor.execute("SELECT COUNT(*) as count FROM feeds")
|
||||||
|
if cursor.fetchone()["count"] == 0:
|
||||||
|
self._seed_feeds()
|
||||||
|
|
||||||
|
def _seed_feeds(self) -> None:
|
||||||
|
"""
|
||||||
|
Seed the database with initial feeds from the seed_feeds.json file.
|
||||||
|
Only runs if the feeds table is empty.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
seed_path = Path(__file__).with_name("seed_feeds.json")
|
||||||
|
with open(seed_path, "r") as f:
|
||||||
|
seed_data = json.load(f)
|
||||||
|
|
||||||
|
with self.get_cursor() as cursor:
|
||||||
|
for country, urls in seed_data.items():
|
||||||
|
for url in urls:
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO feeds (country, url) VALUES (?, ?) "
|
||||||
|
"ON CONFLICT (url) DO NOTHING",
|
||||||
|
(country, url)
|
||||||
|
)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError) as e:
|
||||||
|
print(f"Error seeding feeds: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# Initialize database manager
|
||||||
|
db_manager = DatabaseManager(DB_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleSummary(TypedDict):
|
||||||
|
"""Type definition for article summary data returned from the LLM."""
|
||||||
|
title: str
|
||||||
|
summary_de: str
|
||||||
|
summary_en: str
|
||||||
|
|
||||||
|
|
||||||
|
class NewsFetcher:
|
||||||
|
"""
|
||||||
|
Handles fetching and summarizing news articles from RSS feeds.
|
||||||
|
Uses Ollama/qwen to generate summaries of articles.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def build_prompt(url: str) -> str:
|
||||||
|
"""
|
||||||
|
Generate a prompt for the LLM to summarize an article.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Public URL of the article to summarize
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A formatted prompt string that instructs the LLM to generate
|
||||||
|
a JSON response with title and summaries in German and English
|
||||||
|
|
||||||
|
Note:
|
||||||
|
LLMs like qwen2 don't have native web access; the model will
|
||||||
|
generate summaries based on its training data and the URL.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
"### Aufgabe\n"
|
||||||
|
f"Du bekommst eine öffentliche URL: {url}\n"
|
||||||
|
"### Regeln\n"
|
||||||
|
"1. **Entnimm den Inhalt nicht automatisch.** "
|
||||||
|
"Falls dir der Text nicht vorliegt, antworte mit leeren Strings.\n"
|
||||||
|
"2. Gib ausschließlich **gültiges minifiziertes JSON** zurück – "
|
||||||
|
"kein Markdown, keine Kommentare.\n"
|
||||||
|
"3. Struktur:\n"
|
||||||
|
"{\"title\":\"…\",\"summary_de\":\"…\",\"summary_en\":\"…\"}\n"
|
||||||
|
"4. summary_de ≤ 160 Wörter, summary_en ≤ 160 Wörter. Zähle selbst.\n"
|
||||||
|
"5. Kein Text vor oder nach dem JSON.\n"
|
||||||
|
"### Ausgabe\n"
|
||||||
|
"Jetzt antworte."
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def summarize_article(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
url: str
|
||||||
|
) -> Optional[ArticleSummary]:
|
||||||
|
"""
|
||||||
|
Generate a summary of an article using the LLM.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: An active httpx AsyncClient for making requests
|
||||||
|
url: URL of the article to summarize
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dictionary containing the article title and summaries in German and English,
|
||||||
|
or None if summarization failed
|
||||||
|
"""
|
||||||
|
prompt = NewsFetcher.build_prompt(url)
|
||||||
|
payload = {
|
||||||
|
"model": LLM_MODEL,
|
||||||
|
"prompt": prompt,
|
||||||
|
"stream": False,
|
||||||
|
"temperature": 0.2,
|
||||||
|
"format": "json"
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await client.post(
|
||||||
|
f"{OLLAMA_HOST}/api/generate",
|
||||||
|
json=payload,
|
||||||
|
timeout=LLM_TIMEOUT_SECONDS
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
result = response.json()
|
||||||
|
return cast(ArticleSummary, result["response"])
|
||||||
|
except (KeyError, ValueError, httpx.HTTPError, json.JSONDecodeError) as e:
|
||||||
|
print(f"Error summarizing article {url}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def harvest_feeds() -> None:
|
||||||
|
"""
|
||||||
|
Fetch articles from all feeds and store summaries in the database.
|
||||||
|
This is the main function that runs periodically to update the news database.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get all feeds from the database
|
||||||
|
with db_manager.get_cursor() as cursor:
|
||||||
|
cursor.execute("SELECT country, url FROM feeds")
|
||||||
|
feeds = cursor.fetchall()
|
||||||
|
|
||||||
|
# Process each feed
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
for feed_row in feeds:
|
||||||
|
await NewsFetcher._process_feed(client, feed_row)
|
||||||
|
|
||||||
|
# Update last sync timestamp
|
||||||
|
current_time = int(datetime.now(timezone.utc).timestamp())
|
||||||
|
with db_manager.get_cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE meta SET val=? WHERE key='last_sync'",
|
||||||
|
(str(current_time),)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error harvesting feeds: {e}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _process_feed(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
feed_row: sqlite3.Row
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Process a single feed, fetching and summarizing all articles.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: An active httpx AsyncClient for making requests
|
||||||
|
feed_row: A database row containing feed information
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
feed_data = feedparser.parse(feed_row["url"])
|
||||||
|
|
||||||
|
for entry in feed_data.entries:
|
||||||
|
# Skip entries without links or published dates
|
||||||
|
if not hasattr(entry, "link") or not hasattr(entry, "published_parsed"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
article_id = entry.link
|
||||||
|
|
||||||
|
# Parse the published date
|
||||||
|
try:
|
||||||
|
published = datetime(
|
||||||
|
*entry.published_parsed[:6],
|
||||||
|
tzinfo=timezone.utc
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
# Skip entries with invalid dates
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get article summary
|
||||||
|
summary = await NewsFetcher.summarize_article(client, entry.link)
|
||||||
|
if not summary:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Store in database
|
||||||
|
with db_manager.get_cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO news (
|
||||||
|
id, title, summary_de, summary_en, published,
|
||||||
|
source, country, source_feed
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
article_id,
|
||||||
|
summary["title"],
|
||||||
|
summary["summary_de"],
|
||||||
|
summary["summary_en"],
|
||||||
|
published.isoformat(),
|
||||||
|
entry.get("source", {}).get("title", feed_row["url"]),
|
||||||
|
feed_row["country"],
|
||||||
|
feed_row["url"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error processing feed {feed_row['url']}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# Initialize scheduler
|
||||||
|
scheduler = AsyncIOScheduler(timezone="UTC")
|
||||||
|
scheduler.add_job(
|
||||||
|
NewsFetcher.harvest_feeds,
|
||||||
|
"interval",
|
||||||
|
hours=CRON_HOURS,
|
||||||
|
id="harvest"
|
||||||
|
)
|
||||||
|
scheduler.start()
|
||||||
|
|
||||||
|
|
||||||
|
# Pydantic models for API requests and responses
|
||||||
|
class CronSettings(BaseModel):
|
||||||
|
"""Settings for the cron job that harvests news."""
|
||||||
|
hours: float
|
||||||
|
|
||||||
|
|
||||||
|
class FeedData(BaseModel):
|
||||||
|
"""Data for a news feed."""
|
||||||
|
country: str
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
class ModelStatus(BaseModel):
|
||||||
|
"""Status of the LLM model."""
|
||||||
|
name: str
|
||||||
|
status: str
|
||||||
|
available_models: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorResponse(BaseModel):
|
||||||
|
"""Standard error response."""
|
||||||
|
status: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class SuccessResponse(BaseModel):
|
||||||
|
"""Standard success response."""
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampResponse(BaseModel):
|
||||||
|
"""Response containing a timestamp."""
|
||||||
|
ts: int
|
||||||
|
|
||||||
|
|
||||||
|
class HoursResponse(BaseModel):
|
||||||
|
"""Response containing hours setting."""
|
||||||
|
hours: float
|
||||||
|
|
||||||
|
|
||||||
|
# Dependency for getting a database cursor
|
||||||
|
def get_db():
|
||||||
|
"""
|
||||||
|
Dependency that provides a database cursor.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
A database cursor for executing SQL statements
|
||||||
|
"""
|
||||||
|
with db_manager.get_cursor() as cursor:
|
||||||
|
yield cursor
|
||||||
|
|
||||||
|
|
||||||
|
# API endpoints
|
||||||
|
@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"),
|
||||||
|
db: sqlite3.Cursor = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get news articles filtered by country and date range.
|
||||||
|
|
||||||
|
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)
|
||||||
|
db: Database cursor dependency
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of news articles matching the criteria
|
||||||
|
"""
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM news
|
||||||
|
WHERE country=? AND published BETWEEN ? AND ?
|
||||||
|
ORDER BY published DESC
|
||||||
|
""",
|
||||||
|
(country, from_, to)
|
||||||
|
)
|
||||||
|
return [dict(row) for row in db.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
@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()
|
||||||
|
return {"ts": int(row["val"])}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/settings/cron", response_model=HoursResponse)
|
||||||
|
async def set_cron_schedule(
|
||||||
|
data: CronSettings,
|
||||||
|
db: sqlite3.Cursor = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update the cron schedule for harvesting news.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: New cron settings with hours interval
|
||||||
|
db: Database cursor dependency
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Object containing the updated hours setting
|
||||||
|
"""
|
||||||
|
# Ensure minimum interval
|
||||||
|
hours = max(MIN_CRON_HOURS, data.hours)
|
||||||
|
|
||||||
|
# Update scheduler
|
||||||
|
scheduler.get_job("harvest").modify(trigger="interval", hours=hours)
|
||||||
|
|
||||||
|
# Update database
|
||||||
|
db.execute(
|
||||||
|
"UPDATE settings SET val=? WHERE key='cron_hours'",
|
||||||
|
(str(hours),)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"hours": hours}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/feeds", response_model=List[Dict[str, Any]])
|
||||||
|
async def list_feeds(db: sqlite3.Cursor = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
List all registered news feeds.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database cursor dependency
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of feed objects with id, country, and url
|
||||||
|
"""
|
||||||
|
db.execute("SELECT * FROM feeds ORDER BY country")
|
||||||
|
return [dict(row) for row in db.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/feeds", response_model=SuccessResponse)
|
||||||
|
async def add_feed(
|
||||||
|
feed: FeedData,
|
||||||
|
db: sqlite3.Cursor = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Add a new news feed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feed: Feed data with country and URL
|
||||||
|
db: Database cursor dependency
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status
|
||||||
|
"""
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO feeds (country, url) VALUES (?, ?) "
|
||||||
|
"ON CONFLICT (url) DO NOTHING",
|
||||||
|
(feed.country, feed.url)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "added"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/feeds", response_model=SuccessResponse)
|
||||||
|
async def delete_feed(
|
||||||
|
url: str,
|
||||||
|
db: sqlite3.Cursor = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Delete a news feed by URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL of the feed to delete
|
||||||
|
db: Database cursor dependency
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status
|
||||||
|
"""
|
||||||
|
db.execute("DELETE FROM feeds WHERE url=?", (url,))
|
||||||
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/model/status", response_model=Union[ModelStatus, ErrorResponse])
|
||||||
|
async def get_model_status():
|
||||||
|
"""
|
||||||
|
Check the status of the LLM model.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Object containing model name, status, and available models,
|
||||||
|
or an error response if the model service is unavailable
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
# Get model information from Ollama
|
||||||
|
response = await client.get(
|
||||||
|
f"{OLLAMA_HOST}/api/tags",
|
||||||
|
timeout=OLLAMA_API_TIMEOUT_SECONDS
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
models_data = response.json()
|
||||||
|
models = models_data.get("models", [])
|
||||||
|
|
||||||
|
# Check if the current model is available
|
||||||
|
model_available = any(
|
||||||
|
model.get("name") == LLM_MODEL for model in models
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": LLM_MODEL,
|
||||||
|
"status": "ready" if model_available else "not available",
|
||||||
|
"available_models": [model.get("name") for model in models]
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "message": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/sync", response_model=None)
|
||||||
|
async def manual_sync(db: sqlite3.Cursor = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Manually trigger a feed synchronization.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database cursor dependency
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status or error response if sync was triggered too recently
|
||||||
|
"""
|
||||||
|
# Check when the last sync was performed
|
||||||
|
db.execute("SELECT val FROM meta WHERE key='last_sync'")
|
||||||
|
row = db.fetchone()
|
||||||
|
last_sync_ts = int(row["val"])
|
||||||
|
|
||||||
|
# Enforce cooldown period
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
last_sync_time = datetime.fromtimestamp(last_sync_ts, timezone.utc)
|
||||||
|
|
||||||
|
if now - last_sync_time < timedelta(minutes=SYNC_COOLDOWN_MINUTES):
|
||||||
|
return Response(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
content=f"Sync too soon – wait {SYNC_COOLDOWN_MINUTES} min."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Trigger sync in background
|
||||||
|
asyncio.create_task(NewsFetcher.harvest_feeds())
|
||||||
|
return {"status": "triggered"}
|
||||||
|
|
||||||
|
|
||||||
|
# Mount static frontend
|
||||||
|
frontend_path = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
|
||||||
|
"frontend",
|
||||||
|
"dist"
|
||||||
|
)
|
||||||
|
app.mount("/", StaticFiles(directory=frontend_path, html=True), name="static")
|
9
backend/app/seed_feeds.json
Normal file
9
backend/app/seed_feeds.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"DE": [
|
||||||
|
"https://www.tagesschau.de/xml/rss2",
|
||||||
|
"https://www.spiegel.de/schlagzeilen/tops/index.rss"
|
||||||
|
],
|
||||||
|
"EU": [
|
||||||
|
"https://www.euronews.com/rss?level=theme&name=news"
|
||||||
|
]
|
||||||
|
}
|
8
backend/example.env
Normal file
8
backend/example.env
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# URL for the Ollama service
|
||||||
|
OLLAMA_HOST=http://localhost:11434
|
||||||
|
|
||||||
|
# Interval for scheduled news fetching in hours (minimum: 0.5)
|
||||||
|
CRON_HOURS=1
|
||||||
|
|
||||||
|
# SQLite database connection string
|
||||||
|
DATABASE_URL=sqlite:///./newsdb.sqlite
|
10
backend/requirements.txt
Normal file
10
backend/requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
aiofiles
|
||||||
|
apscheduler
|
||||||
|
fastapi
|
||||||
|
feedparser
|
||||||
|
httpx
|
||||||
|
pydantic
|
||||||
|
uvicorn[standard]
|
||||||
|
python-multipart
|
||||||
|
psycopg2-binary
|
||||||
|
sqlalchemy
|
9
frontend/.editorconfig
Normal file
9
frontend/.editorconfig
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||||
|
charset = utf-8
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
end_of_line = lf
|
||||||
|
max_line_length = 100
|
35
frontend/.gitignore
vendored
Normal file
35
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
coverage
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
/cypress/videos/
|
||||||
|
/cypress/screenshots/
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
*.tsbuildinfo
|
16
frontend/.prettierrc.json
Normal file
16
frontend/.prettierrc.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/prettierrc",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"singleQuote": true,
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"arrowParens": "always",
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"vueIndentScriptAndStyle": true,
|
||||||
|
"plugins": [
|
||||||
|
"prettier-plugin-organize-imports",
|
||||||
|
"prettier-plugin-tailwindcss"
|
||||||
|
]
|
||||||
|
}
|
BIN
frontend/.yarn/install-state.gz
Normal file
BIN
frontend/.yarn/install-state.gz
Normal file
Binary file not shown.
1
frontend/.yarnrc.yml
Normal file
1
frontend/.yarnrc.yml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
nodeLinker: node-modules
|
8
frontend/cypress.config.ts
Normal file
8
frontend/cypress.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'cypress'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
e2e: {
|
||||||
|
specPattern: 'cypress/e2e/**/*.{cy,spec}.{js,jsx,ts,tsx}',
|
||||||
|
baseUrl: 'http://localhost:4173',
|
||||||
|
},
|
||||||
|
})
|
8
frontend/cypress/e2e/example.cy.ts
Normal file
8
frontend/cypress/e2e/example.cy.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// https://on.cypress.io/api
|
||||||
|
|
||||||
|
describe('My First Test', () => {
|
||||||
|
it('visits the app root url', () => {
|
||||||
|
cy.visit('/')
|
||||||
|
cy.contains('h1', 'You did it!')
|
||||||
|
})
|
||||||
|
})
|
5
frontend/cypress/fixtures/example.json
Normal file
5
frontend/cypress/fixtures/example.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "Using fixtures to represent data",
|
||||||
|
"email": "hello@cypress.io",
|
||||||
|
"body": "Fixtures are a great way to mock data for responses to routes"
|
||||||
|
}
|
39
frontend/cypress/support/commands.ts
Normal file
39
frontend/cypress/support/commands.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/// <reference types="cypress" />
|
||||||
|
// ***********************************************
|
||||||
|
// This example commands.ts shows you how to
|
||||||
|
// create various custom commands and overwrite
|
||||||
|
// existing commands.
|
||||||
|
//
|
||||||
|
// For more comprehensive examples of custom
|
||||||
|
// commands please read more here:
|
||||||
|
// https://on.cypress.io/custom-commands
|
||||||
|
// ***********************************************
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This is a parent command --
|
||||||
|
// Cypress.Commands.add('login', (email, password) => { ... })
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This is a child command --
|
||||||
|
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This is a dual command --
|
||||||
|
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This will overwrite an existing command --
|
||||||
|
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||||
|
//
|
||||||
|
// declare global {
|
||||||
|
// namespace Cypress {
|
||||||
|
// interface Chainable {
|
||||||
|
// login(email: string, password: string): Chainable<void>
|
||||||
|
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
|
||||||
|
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
|
||||||
|
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
export {}
|
20
frontend/cypress/support/e2e.ts
Normal file
20
frontend/cypress/support/e2e.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// ***********************************************************
|
||||||
|
// This example support/index.js is processed and
|
||||||
|
// loaded automatically before your test files.
|
||||||
|
//
|
||||||
|
// This is a great place to put global configuration and
|
||||||
|
// behavior that modifies Cypress.
|
||||||
|
//
|
||||||
|
// You can change the location of this file or turn off
|
||||||
|
// automatically serving support files with the
|
||||||
|
// 'supportFile' configuration option.
|
||||||
|
//
|
||||||
|
// You can read more here:
|
||||||
|
// https://on.cypress.io/configuration
|
||||||
|
// ***********************************************************
|
||||||
|
|
||||||
|
// Import commands.js using ES2015 syntax:
|
||||||
|
import './commands'
|
||||||
|
|
||||||
|
// Alternatively you can use CommonJS syntax:
|
||||||
|
// require('./commands')
|
9
frontend/cypress/tsconfig.json
Normal file
9
frontend/cypress/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": ["./e2e/**/*", "./support/**/*"],
|
||||||
|
"exclude": ["./support/component.*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"isolatedModules": false,
|
||||||
|
"types": ["cypress"]
|
||||||
|
}
|
||||||
|
}
|
1
frontend/env.d.ts
vendored
Normal file
1
frontend/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
41
frontend/eslint.config.ts
Normal file
41
frontend/eslint.config.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { globalIgnores } from 'eslint/config'
|
||||||
|
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||||
|
import pluginVue from 'eslint-plugin-vue'
|
||||||
|
import pluginVitest from '@vitest/eslint-plugin'
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
import pluginCypress from 'eslint-plugin-cypress'
|
||||||
|
import pluginOxlint from 'eslint-plugin-oxlint'
|
||||||
|
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
|
||||||
|
|
||||||
|
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||||
|
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||||
|
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
||||||
|
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
|
||||||
|
|
||||||
|
export default defineConfigWithVueTs(
|
||||||
|
{
|
||||||
|
name: 'app/files-to-lint',
|
||||||
|
files: ['**/*.{ts,mts,tsx,vue}'],
|
||||||
|
},
|
||||||
|
|
||||||
|
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||||
|
|
||||||
|
pluginVue.configs['flat/essential'],
|
||||||
|
vueTsConfigs.recommended,
|
||||||
|
|
||||||
|
{
|
||||||
|
...pluginVitest.configs.recommended,
|
||||||
|
files: ['src/**/__tests__/*'],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
...pluginCypress.configs.recommended,
|
||||||
|
files: [
|
||||||
|
'cypress/e2e/**/*.{cy,spec}.{js,ts,jsx,tsx}',
|
||||||
|
'cypress/support/**/*.{js,ts,jsx,tsx}'
|
||||||
|
],
|
||||||
|
},
|
||||||
|
...pluginOxlint.configs['flat/recommended'],
|
||||||
|
skipFormatting,
|
||||||
|
)
|
6
frontend/example.env
Normal file
6
frontend/example.env
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Backend API URL (used if you need to override the default proxy settings in vite.config.ts)
|
||||||
|
# VITE_API_BASE_URL=http://localhost:8000
|
||||||
|
|
||||||
|
# Note: By default, the frontend proxies API requests to http://localhost:8000
|
||||||
|
# as configured in vite.config.ts. You only need to set VITE_API_BASE_URL
|
||||||
|
# if you want to use a different backend URL.
|
14
frontend/index.html
Normal file
14
frontend/index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link href="/src/style.css" rel="stylesheet">
|
||||||
|
<title>Vite App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
62
frontend/package.json
Normal file
62
frontend/package.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"name": "owly-news-summariser",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"dev:watch": "vite build --watch",
|
||||||
|
"build": "run-p type-check \"build-only {@}\" --",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test:unit": "vitest",
|
||||||
|
"prepare": "cypress install",
|
||||||
|
"test:e2e": "start-server-and-test preview http://localhost:4173 'cypress run --e2e'",
|
||||||
|
"test:e2e:dev": "start-server-and-test 'vite dev --port 4173' http://localhost:4173 'cypress open --e2e'",
|
||||||
|
"build-only": "vite build",
|
||||||
|
"type-check": "vue-tsc --build",
|
||||||
|
"lint:oxlint": "oxlint . --fix -D correctness --ignore-path .gitignore",
|
||||||
|
"lint:eslint": "eslint . --fix",
|
||||||
|
"lint": "run-s lint:oxlint lint:eslint",
|
||||||
|
"format": "prettier --write src/"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"idb-keyval": "^6.2.2",
|
||||||
|
"pinia": "^3.0.3",
|
||||||
|
"vue": "^3.5.17"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
|
"@tsconfig/node22": "^22.0.2",
|
||||||
|
"@types/jsdom": "^21.1.7",
|
||||||
|
"@types/node": "^22.15.32",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.0",
|
||||||
|
"@vitejs/plugin-vue-jsx": "^5.0.0",
|
||||||
|
"@vitest/eslint-plugin": "^1.2.7",
|
||||||
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
|
"@vue/eslint-config-typescript": "^14.5.1",
|
||||||
|
"@vue/test-utils": "^2.4.6",
|
||||||
|
"@vue/tsconfig": "^0.7.0",
|
||||||
|
"cypress": "^14.5.0",
|
||||||
|
"eslint": "^9.29.0",
|
||||||
|
"eslint-plugin-cypress": "^5.1.0",
|
||||||
|
"eslint-plugin-oxlint": "~1.1.0",
|
||||||
|
"eslint-plugin-vue": "~10.2.0",
|
||||||
|
"jiti": "^2.4.2",
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
|
"npm-run-all2": "^8.0.4",
|
||||||
|
"oxlint": "~1.1.0",
|
||||||
|
"prettier": "3.5.3",
|
||||||
|
"prettier-plugin-organize-imports": "^4.2.0",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||||
|
"rolldown": "1.0.0-beta.29",
|
||||||
|
"start-server-and-test": "^2.0.12",
|
||||||
|
"tailwindcss": "^4.1.11",
|
||||||
|
"typescript": "~5.8.0",
|
||||||
|
"vite": "npm:rolldown-vite@latest",
|
||||||
|
"vite-plugin-pwa": "^0.18.0",
|
||||||
|
"vite-plugin-vue-devtools": "^7.7.7",
|
||||||
|
"vitest": "^3.2.4",
|
||||||
|
"vue-tsc": "^2.2.10"
|
||||||
|
},
|
||||||
|
"packageManager": "yarn@4.9.2"
|
||||||
|
}
|
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
43
frontend/src/App.vue
Normal file
43
frontend/src/App.vue
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {onMounted, ref} from 'vue';
|
||||||
|
import {useNews} from './stores/useNews';
|
||||||
|
import FeedManager from './components/FeedManager.vue';
|
||||||
|
import CronSlider from './components/CronSlider.vue';
|
||||||
|
import SyncButton from './components/SyncButton.vue';
|
||||||
|
import ModelStatus from './components/ModelStatus.vue';
|
||||||
|
|
||||||
|
const news = useNews();
|
||||||
|
const filters = ref({country: 'DE'});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await news.loadLastSync();
|
||||||
|
await news.sync(filters.value);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<main class="max-w-4xl mx-auto p-4 space-y-6">
|
||||||
|
<h1 class="text-2xl font-bold">📰 Local News Summariser</h1>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-3 gap-4">
|
||||||
|
<CronSlider/>
|
||||||
|
<SyncButton/>
|
||||||
|
<ModelStatus/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FeedManager/>
|
||||||
|
|
||||||
|
<section v-if="news.offline" class="p-2 bg-yellow-100 border-l-4 border-yellow-500">
|
||||||
|
Offline – Datenstand: {{ new Date(news.lastSync).toLocaleString() }}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<article v-for="a in news.articles" :key="a.id" class="bg-white rounded p-4 shadow">
|
||||||
|
<h2 class="font-semibold">{{ a.title }}</h2>
|
||||||
|
<p class="text-sm text-gray-600">{{ new Date(a.published).toLocaleString() }} – {{
|
||||||
|
a.source
|
||||||
|
}}</p>
|
||||||
|
<p class="mt-2">{{ a.summary_de }}</p>
|
||||||
|
<p class="italic mt-2 text-sm text-gray-700">{{ a.summary_en }}</p>
|
||||||
|
<a :href="a.id" target="_blank" class="text-blue-600 hover:underline">Original →</a>
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
</template>
|
86
frontend/src/assets/base.css
Normal file
86
frontend/src/assets/base.css
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/* color palette from <https://github.com/vuejs/theme> */
|
||||||
|
:root {
|
||||||
|
--vt-c-white: #ffffff;
|
||||||
|
--vt-c-white-soft: #f8f8f8;
|
||||||
|
--vt-c-white-mute: #f2f2f2;
|
||||||
|
|
||||||
|
--vt-c-black: #181818;
|
||||||
|
--vt-c-black-soft: #222222;
|
||||||
|
--vt-c-black-mute: #282828;
|
||||||
|
|
||||||
|
--vt-c-indigo: #2c3e50;
|
||||||
|
|
||||||
|
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
||||||
|
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
||||||
|
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
||||||
|
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
||||||
|
|
||||||
|
--vt-c-text-light-1: var(--vt-c-indigo);
|
||||||
|
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
||||||
|
--vt-c-text-dark-1: var(--vt-c-white);
|
||||||
|
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* semantic color variables for this project */
|
||||||
|
:root {
|
||||||
|
--color-background: var(--vt-c-white);
|
||||||
|
--color-background-soft: var(--vt-c-white-soft);
|
||||||
|
--color-background-mute: var(--vt-c-white-mute);
|
||||||
|
|
||||||
|
--color-border: var(--vt-c-divider-light-2);
|
||||||
|
--color-border-hover: var(--vt-c-divider-light-1);
|
||||||
|
|
||||||
|
--color-heading: var(--vt-c-text-light-1);
|
||||||
|
--color-text: var(--vt-c-text-light-1);
|
||||||
|
|
||||||
|
--section-gap: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--color-background: var(--vt-c-black);
|
||||||
|
--color-background-soft: var(--vt-c-black-soft);
|
||||||
|
--color-background-mute: var(--vt-c-black-mute);
|
||||||
|
|
||||||
|
--color-border: var(--vt-c-divider-dark-2);
|
||||||
|
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||||
|
|
||||||
|
--color-heading: var(--vt-c-text-dark-1);
|
||||||
|
--color-text: var(--vt-c-text-dark-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-background);
|
||||||
|
transition:
|
||||||
|
color 0.5s,
|
||||||
|
background-color 0.5s;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-family:
|
||||||
|
Inter,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
Oxygen,
|
||||||
|
Ubuntu,
|
||||||
|
Cantarell,
|
||||||
|
'Fira Sans',
|
||||||
|
'Droid Sans',
|
||||||
|
'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
35
frontend/src/assets/main.css
Normal file
35
frontend/src/assets/main.css
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
@import './base.css';
|
||||||
|
|
||||||
|
#app {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
a,
|
||||||
|
.green {
|
||||||
|
text-decoration: none;
|
||||||
|
color: hsla(160, 100%, 37%, 1);
|
||||||
|
transition: 0.4s;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
a: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;
|
||||||
|
}
|
||||||
|
}
|
29
frontend/src/components/CronSlider.vue
Normal file
29
frontend/src/components/CronSlider.vue
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {ref, onMounted} from 'vue';
|
||||||
|
|
||||||
|
const hours = ref(1);
|
||||||
|
const saving = ref(false);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const h = await fetch('/settings/cron').then(r => r.json());
|
||||||
|
hours.value = h.hours;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function update() {
|
||||||
|
saving.value = true;
|
||||||
|
await fetch('/settings/cron', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({hours: hours.value})
|
||||||
|
});
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="p-4 bg-white rounded shadow">
|
||||||
|
<label class="block font-semibold mb-2">Fetch Interval (Stunden)</label>
|
||||||
|
<input type="range" min="0.5" max="24" step="0.5" v-model="hours" @change="update"
|
||||||
|
class="w-full"/>
|
||||||
|
<p class="text-sm mt-2">{{ hours }} h <span v-if="saving" class="animate-pulse">…</span></p>
|
||||||
|
</div>
|
||||||
|
</template>
|
37
frontend/src/components/FeedManager.vue
Normal file
37
frontend/src/components/FeedManager.vue
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {ref, onMounted} from 'vue';
|
||||||
|
import {useFeeds} from '../stores/useFeeds';
|
||||||
|
|
||||||
|
const feeds = useFeeds();
|
||||||
|
const country = ref('DE');
|
||||||
|
const url = ref('');
|
||||||
|
|
||||||
|
onMounted(() => feeds.fetch());
|
||||||
|
|
||||||
|
async function add() {
|
||||||
|
if (!url.value.trim()) return;
|
||||||
|
await feeds.add(country.value, url.value.trim());
|
||||||
|
url.value = '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="bg-white rounded p-4 shadow space-y-2">
|
||||||
|
<h2 class="font-semibold text-lg">Feeds verwalten</h2>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<select v-model="country" class="border rounded p-1 flex-1">
|
||||||
|
<option>DE</option>
|
||||||
|
<option>EU</option>
|
||||||
|
</select>
|
||||||
|
<input v-model="url" placeholder="https://…" class="border rounded p-1 flex-[3]"/>
|
||||||
|
<button @click="add" class="bg-green-600 text-white px-3 rounded">+</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="list-disc pl-5">
|
||||||
|
<li v-for="(f, index) in feeds.list" :key="index" class="flex justify-between items-center">
|
||||||
|
<span>{{ f.country }} — {{ f.url }}</span>
|
||||||
|
<button @click="feeds.remove(f.url)" class="text-red-600">✕</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
43
frontend/src/components/ModelStatus.vue
Normal file
43
frontend/src/components/ModelStatus.vue
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue';
|
||||||
|
import { useModel } from '../stores/useModel';
|
||||||
|
|
||||||
|
const model = useModel();
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await model.loadModelStatus();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bg-white rounded p-4 shadow">
|
||||||
|
<h3 class="font-semibold mb-2">Model Status</h3>
|
||||||
|
|
||||||
|
<div v-if="model.status === 'loading'" class="text-gray-600">
|
||||||
|
Loading model information...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="model.status === 'error'" class="text-red-600">
|
||||||
|
Error: {{ model.error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<span class="font-medium mr-2">Name:</span>
|
||||||
|
<span>{{ model.name }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="font-medium mr-2">Status:</span>
|
||||||
|
<span
|
||||||
|
:class="{
|
||||||
|
'text-green-600': model.status === 'ready',
|
||||||
|
'text-red-600': model.status === 'not available'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ model.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
20
frontend/src/components/SyncButton.vue
Normal file
20
frontend/src/components/SyncButton.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {useNews} from '../stores/useNews';
|
||||||
|
|
||||||
|
const news = useNews();
|
||||||
|
|
||||||
|
async function click() {
|
||||||
|
if (!news.canManualSync()) return;
|
||||||
|
await fetch('/sync', {method: 'POST'});
|
||||||
|
await news.sync({country: 'DE'});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<button :disabled="!news.canManualSync()" @click="click"
|
||||||
|
class="px-4 py-2 rounded bg-blue-600 disabled:bg-gray-400 text-white">
|
||||||
|
Sync now
|
||||||
|
</button>
|
||||||
|
<p v-if="!news.canManualSync()" class="text-xs text-gray-500 mt-1">
|
||||||
|
Wait {{ Math.ceil((30 * 60 * 1000 - (Date.now() - news.lastSync)) / 60000) }} min
|
||||||
|
</p>
|
||||||
|
</template>
|
12
frontend/src/main.ts
Normal file
12
frontend/src/main.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import './assets/main.css';
|
||||||
|
|
||||||
|
import {createPinia} from 'pinia';
|
||||||
|
import {createApp} from 'vue';
|
||||||
|
import App from "@/App.vue";
|
||||||
|
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
|
||||||
|
app.use(createPinia());
|
||||||
|
|
||||||
|
app.mount('#app');
|
21
frontend/src/stores/useFeeds.ts
Normal file
21
frontend/src/stores/useFeeds.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import {defineStore} from 'pinia';
|
||||||
|
|
||||||
|
export const useFeeds = defineStore('feeds', {
|
||||||
|
state: () => ({list: [] as { country: string, url: string }[]}),
|
||||||
|
actions: {
|
||||||
|
async fetch() {
|
||||||
|
this.list = await fetch('/feeds').then(r => r.json());
|
||||||
|
},
|
||||||
|
async add(country: string, url: string) {
|
||||||
|
await fetch('/feeds', {
|
||||||
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({country, url})
|
||||||
|
});
|
||||||
|
await this.fetch();
|
||||||
|
},
|
||||||
|
async remove(url: string) {
|
||||||
|
await fetch(`/feeds?url=${encodeURIComponent(url)}`, {method: 'DELETE'});
|
||||||
|
await this.fetch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
28
frontend/src/stores/useModel.ts
Normal file
28
frontend/src/stores/useModel.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import {defineStore} from 'pinia';
|
||||||
|
|
||||||
|
export const useModel = defineStore('model', {
|
||||||
|
state: () => ({
|
||||||
|
name: '',
|
||||||
|
status: 'loading',
|
||||||
|
availableModels: [] as string[],
|
||||||
|
error: null as string | null
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
async loadModelStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/model/status');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch model status');
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
this.name = data.name;
|
||||||
|
this.status = data.status;
|
||||||
|
this.availableModels = data.available_models || [];
|
||||||
|
this.error = null;
|
||||||
|
} catch (e) {
|
||||||
|
this.status = 'error';
|
||||||
|
this.error = e instanceof Error ? e.message : 'Unknown error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
43
frontend/src/stores/useNews.ts
Normal file
43
frontend/src/stores/useNews.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import {defineStore} from 'pinia';
|
||||||
|
import {set, get} from 'idb-keyval';
|
||||||
|
|
||||||
|
export const useNews = defineStore('news', {
|
||||||
|
state: () => ({
|
||||||
|
articles: [] as {
|
||||||
|
id: string,
|
||||||
|
published: number,
|
||||||
|
title: string,
|
||||||
|
source: string,
|
||||||
|
summary_de: string,
|
||||||
|
summary_en: string
|
||||||
|
}[], lastSync: 0, offline: false
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
async loadLastSync() {
|
||||||
|
const {ts} = await fetch('/meta/last-sync').then(r => r.json());
|
||||||
|
this.lastSync = ts * 1000; // store ms
|
||||||
|
},
|
||||||
|
canManualSync() {
|
||||||
|
return Date.now() - this.lastSync > 30 * 60 * 1000; // 30‑min guard
|
||||||
|
},
|
||||||
|
async sync(filters: Record<string, string>) {
|
||||||
|
try {
|
||||||
|
if (!this.canManualSync()) throw new Error('Too soon');
|
||||||
|
const q = new URLSearchParams(filters).toString();
|
||||||
|
const res = await fetch(`/news?${q}`);
|
||||||
|
if (!res.ok) throw new Error('network');
|
||||||
|
const data = await res.json();
|
||||||
|
this.articles = data;
|
||||||
|
this.lastSync = Date.now();
|
||||||
|
await set(JSON.stringify(filters), data);
|
||||||
|
this.offline = false;
|
||||||
|
} catch (e) {
|
||||||
|
const cached = await get(JSON.stringify(filters));
|
||||||
|
if (cached) {
|
||||||
|
this.articles = cached;
|
||||||
|
this.offline = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
1
frontend/src/style.css
Normal file
1
frontend/src/style.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
12
frontend/tsconfig.app.json
Normal file
12
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||||
|
"exclude": ["src/**/__tests__/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
17
frontend/tsconfig.json
Normal file
17
frontend/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.vitest.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "NodeNext"
|
||||||
|
}
|
||||||
|
}
|
19
frontend/tsconfig.node.json
Normal file
19
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/node22/tsconfig.json",
|
||||||
|
"include": [
|
||||||
|
"vite.config.*",
|
||||||
|
"vitest.config.*",
|
||||||
|
"cypress.config.*",
|
||||||
|
"nightwatch.conf.*",
|
||||||
|
"playwright.config.*",
|
||||||
|
"eslint.config.*"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"types": ["node"]
|
||||||
|
}
|
||||||
|
}
|
11
frontend/tsconfig.vitest.json
Normal file
11
frontend/tsconfig.vitest.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.app.json",
|
||||||
|
"include": ["src/**/__tests__/*", "env.d.ts"],
|
||||||
|
"exclude": [],
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo",
|
||||||
|
|
||||||
|
"lib": [],
|
||||||
|
"types": ["node", "jsdom"]
|
||||||
|
}
|
||||||
|
}
|
45
frontend/vite.config.ts
Normal file
45
frontend/vite.config.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import {fileURLToPath, URL} from 'node:url';
|
||||||
|
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import vueJsx from '@vitejs/plugin-vue-jsx';
|
||||||
|
import {defineConfig} from 'vite';
|
||||||
|
import vueDevTools from 'vite-plugin-vue-devtools';
|
||||||
|
import {VitePWA} from "vite-plugin-pwa";
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
vueJsx(),
|
||||||
|
vueDevTools(),
|
||||||
|
tailwindcss(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
workbox: {
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
urlPattern: /\/news\?/,
|
||||||
|
handler: 'NetworkFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'news-api',
|
||||||
|
networkTimeoutSeconds: 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
build: {outDir: 'dist'},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/news': 'http://localhost:8000',
|
||||||
|
'/meta': 'http://localhost:8000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
14
frontend/vitest.config.ts
Normal file
14
frontend/vitest.config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
|
||||||
|
import viteConfig from './vite.config'
|
||||||
|
|
||||||
|
export default mergeConfig(
|
||||||
|
viteConfig,
|
||||||
|
defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
exclude: [...configDefaults.exclude, 'e2e/**'],
|
||||||
|
root: fileURLToPath(new URL('./', import.meta.url)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
10078
frontend/yarn.lock
Normal file
10078
frontend/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user