first commit

This commit is contained in:
2025-08-01 06:05:06 +02:00
commit e2c546527f
44 changed files with 11845 additions and 0 deletions

60
.gitignore vendored Normal file
View 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
View 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
View 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
View File

635
backend/app/main.py Normal file
View 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")

View 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
View 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
View File

@@ -0,0 +1,10 @@
aiofiles
apscheduler
fastapi
feedparser
httpx
pydantic
uvicorn[standard]
python-multipart
psycopg2-binary
sqlalchemy

9
frontend/.editorconfig Normal file
View 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
View 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
View 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"
]
}

Binary file not shown.

1
frontend/.yarnrc.yml Normal file
View File

@@ -0,0 +1 @@
nodeLinker: node-modules

View 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',
},
})

View 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!')
})
})

View 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"
}

View 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 {}

View 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')

View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

41
frontend/eslint.config.ts Normal file
View 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

43
frontend/src/App.vue Normal file
View 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>

View 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;
}

View 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;
}
}

View 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>

View 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>

View 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>

View 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
View 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');

View 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();
}
}
});

View 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';
}
}
}
});

View 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; // 30min 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
View File

@@ -0,0 +1 @@
@import "tailwindcss";

View 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
View File

@@ -0,0 +1,17 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.vitest.json"
}
],
"compilerOptions": {
"module": "NodeNext"
}
}

View 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"]
}
}

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff