diff --git a/backend/routes/admin_routes.py b/backend/routes/admin_routes.py index d34f68a..d8a0aa8 100644 --- a/backend/routes/admin_routes.py +++ b/backend/routes/admin_routes.py @@ -224,6 +224,43 @@ def send_newsletter(): }), 500 +@admin_bp.route('/api/admin/recent-articles', methods=['GET']) +def get_recent_articles(): + """Get recently summarized articles""" + try: + from database import articles_collection + + # Get last 10 articles with summaries, sorted by when they were summarized + articles = list(articles_collection.find( + {'summary': {'$exists': True, '$ne': None}}, + { + 'title': 1, + 'title_en': 1, + 'source': 1, + 'category': 1, + 'summarized_at': 1, + 'created_at': 1, + 'summary_word_count': 1, + '_id': 0 + } + ).sort('summarized_at', -1).limit(10)) + + # Convert datetime to ISO format + for article in articles: + if 'summarized_at' in article and article['summarized_at']: + article['summarized_at'] = article['summarized_at'].isoformat() + if 'created_at' in article and article['created_at']: + article['created_at'] = article['created_at'].isoformat() + + return jsonify({ + 'articles': articles, + 'total': len(articles) + }), 200 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + @admin_bp.route('/api/admin/stats', methods=['GET']) def get_stats(): """Get system statistics""" diff --git a/backend/routes/rss_routes.py b/backend/routes/rss_routes.py index 92ec9c7..c3dee89 100644 --- a/backend/routes/rss_routes.py +++ b/backend/routes/rss_routes.py @@ -1,8 +1,8 @@ +""" +RSS Feed management routes +""" from flask import Blueprint, request, jsonify from datetime import datetime -from pymongo.errors import DuplicateKeyError -from bson.objectid import ObjectId -import feedparser from database import rss_feeds_collection rss_bp = Blueprint('rss', __name__) @@ -10,145 +10,208 @@ rss_bp = Blueprint('rss', __name__) @rss_bp.route('/api/rss-feeds', methods=['GET']) def get_rss_feeds(): - """Get all RSS feeds, optionally filtered by category""" + """Get all RSS feeds""" try: - # Get optional category filter - category = request.args.get('category') + feeds = list(rss_feeds_collection.find( + {}, + {'_id': 0} + ).sort('name', 1)) - # Build query - query = {} - if category: - query['category'] = category + # Convert datetime to ISO format + for feed in feeds: + if 'created_at' in feed: + feed['created_at'] = feed['created_at'].isoformat() + + return jsonify({ + 'feeds': feeds, + 'total': len(feeds) + }), 200 - cursor = rss_feeds_collection.find(query).sort('created_at', -1) - feeds = [] - for feed in cursor: - feeds.append({ - 'id': str(feed['_id']), - 'name': feed.get('name', ''), - 'url': feed.get('url', ''), - 'category': feed.get('category', 'general'), - 'active': feed.get('active', True), - 'created_at': feed.get('created_at', '').isoformat() if feed.get('created_at') else '' - }) - return jsonify({'feeds': feeds}), 200 except Exception as e: return jsonify({'error': str(e)}), 500 @rss_bp.route('/api/rss-feeds', methods=['POST']) def add_rss_feed(): - """Add a new RSS feed with optional category""" - data = request.json - name = data.get('name', '').strip() - url = data.get('url', '').strip() - category = data.get('category', 'general').strip().lower() - - if not name or not url: - return jsonify({'error': 'Name and URL are required'}), 400 - - if not url.startswith('http://') and not url.startswith('https://'): - return jsonify({'error': 'URL must start with http:// or https://'}), 400 - - # Validate category (optional, but if provided should be reasonable) - valid_categories = ['general', 'local', 'politics', 'sports', 'culture', 'business', 'technology', 'entertainment'] - if category and category not in valid_categories: - # Allow custom categories but warn - pass - + """Add a new RSS feed""" try: - # Test if the RSS feed is valid - try: - feed = feedparser.parse(url) - if not feed.entries: - return jsonify({'error': 'Invalid RSS feed or no entries found'}), 400 - except Exception as e: - return jsonify({'error': f'Failed to parse RSS feed: {str(e)}'}), 400 + data = request.get_json() + # Validate required fields + if not data.get('name'): + return jsonify({'error': 'Feed name is required'}), 400 + if not data.get('url'): + return jsonify({'error': 'Feed URL is required'}), 400 + if not data.get('category'): + return jsonify({'error': 'Category is required'}), 400 + + # Category validation - accept any string, but recommend common ones + category = data['category'].strip().lower() + if not category: + return jsonify({'error': 'Category cannot be empty'}), 400 + + data['category'] = category # Normalize to lowercase + + # Check if feed already exists + existing = rss_feeds_collection.find_one({'url': data['url']}) + if existing: + return jsonify({'error': 'Feed URL already exists'}), 400 + + # Create feed document feed_doc = { - 'name': name, - 'url': url, - 'category': category, - 'active': True, + 'name': data['name'].strip(), + 'url': data['url'].strip(), + 'category': data['category'], + 'active': data.get('active', True), 'created_at': datetime.utcnow() } - try: - result = rss_feeds_collection.insert_one(feed_doc) - return jsonify({ - 'message': 'RSS feed added successfully', - 'id': str(result.inserted_id), - 'category': category - }), 201 - except DuplicateKeyError: - return jsonify({'error': 'RSS feed URL already exists'}), 409 - + rss_feeds_collection.insert_one(feed_doc) + + return jsonify({ + 'message': 'RSS feed added successfully', + 'feed': { + 'name': feed_doc['name'], + 'url': feed_doc['url'], + 'category': feed_doc['category'], + 'active': feed_doc['active'] + } + }), 201 + except Exception as e: return jsonify({'error': str(e)}), 500 -@rss_bp.route('/api/rss-feeds/', methods=['DELETE']) -def remove_rss_feed(feed_id): - """Remove an RSS feed""" +@rss_bp.route('/api/rss-feeds/', methods=['PUT']) +def update_rss_feed(feed_name): + """Update an RSS feed""" try: - # Validate ObjectId - try: - obj_id = ObjectId(feed_id) - except Exception: - return jsonify({'error': 'Invalid feed ID'}), 400 + data = request.get_json() - result = rss_feeds_collection.delete_one({'_id': obj_id}) + # Build update document + update_doc = {} + if 'url' in data: + update_doc['url'] = data['url'].strip() + if 'category' in data: + category = data['category'].strip().lower() + if not category: + return jsonify({'error': 'Category cannot be empty'}), 400 + update_doc['category'] = category + if 'active' in data: + update_doc['active'] = bool(data['active']) - if result.deleted_count > 0: - return jsonify({'message': 'RSS feed removed successfully'}), 200 - else: - return jsonify({'error': 'RSS feed not found'}), 404 - - except Exception as e: - return jsonify({'error': str(e)}), 500 - - -@rss_bp.route('/api/rss-feeds//toggle', methods=['PATCH']) -def toggle_rss_feed(feed_id): - """Toggle RSS feed active status""" - try: - # Validate ObjectId - try: - obj_id = ObjectId(feed_id) - except Exception: - return jsonify({'error': 'Invalid feed ID'}), 400 + if not update_doc: + return jsonify({'error': 'No fields to update'}), 400 - # Get current status - feed = rss_feeds_collection.find_one({'_id': obj_id}) - if not feed: - return jsonify({'error': 'RSS feed not found'}), 404 - - # Toggle status - new_status = not feed.get('active', True) result = rss_feeds_collection.update_one( - {'_id': obj_id}, - {'$set': {'active': new_status}} + {'name': feed_name}, + {'$set': update_doc} ) - if result.modified_count > 0: - return jsonify({ - 'message': f'RSS feed {"activated" if new_status else "deactivated"} successfully', - 'active': new_status - }), 200 + if result.matched_count > 0: + return jsonify({'message': 'RSS feed updated successfully'}), 200 else: - return jsonify({'error': 'Failed to update RSS feed'}), 500 + return jsonify({'error': 'Feed not found'}), 404 except Exception as e: return jsonify({'error': str(e)}), 500 -@rss_bp.route('/api/rss-feeds/categories', methods=['GET']) -def get_categories(): - """Get all unique categories from RSS feeds""" +@rss_bp.route('/api/rss-feeds/', methods=['DELETE']) +def delete_rss_feed(feed_name): + """Delete an RSS feed""" try: - categories = rss_feeds_collection.distinct('category') - # Filter out None/empty and sort - categories = sorted([c for c in categories if c]) - return jsonify({'categories': categories}), 200 + result = rss_feeds_collection.delete_one({'name': feed_name}) + + if result.deleted_count > 0: + return jsonify({'message': f'RSS feed "{feed_name}" deleted successfully'}), 200 + else: + return jsonify({'error': 'Feed not found'}), 404 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + + +@rss_bp.route('/api/rss-feeds/export', methods=['GET']) +def export_rss_feeds(): + """Export all RSS feeds as JSON""" + try: + feeds = list(rss_feeds_collection.find( + {}, + {'_id': 0} + ).sort('name', 1)) + + # Convert datetime to ISO format + for feed in feeds: + if 'created_at' in feed: + feed['created_at'] = feed['created_at'].isoformat() + + return jsonify({ + 'feeds': feeds, + 'total': len(feeds), + 'exported_at': datetime.utcnow().isoformat() + }), 200 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@rss_bp.route('/api/rss-feeds/import', methods=['POST']) +def import_rss_feeds(): + """Import RSS feeds from JSON""" + try: + data = request.get_json() + + if not data or 'feeds' not in data: + return jsonify({'error': 'Invalid import data. Expected {feeds: [...]}'}), 400 + + feeds = data['feeds'] + + if not isinstance(feeds, list): + return jsonify({'error': 'feeds must be an array'}), 400 + + imported = 0 + skipped = 0 + errors = [] + + for feed in feeds: + try: + # Validate required fields + if not feed.get('name') or not feed.get('url') or not feed.get('category'): + errors.append(f"Skipped invalid feed: {feed.get('name', 'unknown')}") + skipped += 1 + continue + + # Check if feed already exists + existing = rss_feeds_collection.find_one({'url': feed['url']}) + if existing: + errors.append(f"Skipped duplicate: {feed['name']}") + skipped += 1 + continue + + # Create feed document + feed_doc = { + 'name': feed['name'].strip(), + 'url': feed['url'].strip(), + 'category': feed['category'].strip().lower(), + 'active': feed.get('active', True), + 'created_at': datetime.utcnow() + } + + rss_feeds_collection.insert_one(feed_doc) + imported += 1 + + except Exception as e: + errors.append(f"Error importing {feed.get('name', 'unknown')}: {str(e)}") + skipped += 1 + + return jsonify({ + 'message': f'Import complete: {imported} imported, {skipped} skipped', + 'imported': imported, + 'skipped': skipped, + 'errors': errors if errors else None + }), 200 + except Exception as e: return jsonify({'error': str(e)}), 500 diff --git a/backend/routes/subscription_routes.py b/backend/routes/subscription_routes.py index c68714d..8b2f45b 100644 --- a/backend/routes/subscription_routes.py +++ b/backend/routes/subscription_routes.py @@ -8,36 +8,69 @@ subscription_bp = Blueprint('subscription', __name__) @subscription_bp.route('/api/subscribe', methods=['POST']) def subscribe(): - """Subscribe a user to the newsletter""" + """Subscribe a user to the newsletter with category preferences""" data = request.json email = data.get('email', '').strip().lower() + # Get valid categories from RSS feeds + from database import rss_feeds_collection + valid_categories = rss_feeds_collection.distinct('category') + + categories = data.get('categories', valid_categories) # Default: all categories if not email or '@' not in email: return jsonify({'error': 'Invalid email address'}), 400 + # Validate categories + if not isinstance(categories, list) or not categories: + categories = valid_categories # Default to all if invalid + else: + # Filter to only valid categories (categories that exist in RSS feeds) + categories = [c for c in categories if c in valid_categories] + if not categories: + categories = valid_categories # Default to all if none valid + try: subscriber_doc = { 'email': email, 'subscribed_at': datetime.utcnow(), - 'status': 'active' + 'status': 'active', + 'categories': categories } # Try to insert, if duplicate key error, subscriber already exists try: subscribers_collection.insert_one(subscriber_doc) - return jsonify({'message': 'Successfully subscribed!'}), 201 + return jsonify({ + 'message': 'Successfully subscribed!', + 'categories': categories + }), 201 except DuplicateKeyError: # Check if subscriber is active existing = subscribers_collection.find_one({'email': email}) if existing and existing.get('status') == 'active': - return jsonify({'message': 'Email already subscribed'}), 200 + # Update categories even if already subscribed + subscribers_collection.update_one( + {'email': email}, + {'$set': {'categories': categories}} + ) + return jsonify({ + 'message': 'Email already subscribed. Preferences updated!', + 'categories': categories + }), 200 else: # Reactivate if previously unsubscribed subscribers_collection.update_one( {'email': email}, - {'$set': {'status': 'active', 'subscribed_at': datetime.utcnow()}} + {'$set': { + 'status': 'active', + 'subscribed_at': datetime.utcnow(), + 'categories': categories + }} ) - return jsonify({'message': 'Successfully re-subscribed!'}), 200 + return jsonify({ + 'message': 'Successfully re-subscribed!', + 'categories': categories + }), 200 except Exception as e: return jsonify({'error': str(e)}), 500 @@ -95,6 +128,34 @@ def list_subscribers(): return jsonify({'error': str(e)}), 500 +@subscription_bp.route('/api/subscribers/', methods=['GET']) +def get_subscriber(email): + """Get a single subscriber's information""" + try: + email = email.strip().lower() + + subscriber = subscribers_collection.find_one( + {'email': email}, + {'_id': 0, 'email': 1, 'categories': 1, 'status': 1, 'subscribed_at': 1} + ) + + if subscriber: + # Convert datetime to ISO format + if 'subscribed_at' in subscriber: + subscriber['subscribed_at'] = subscriber['subscribed_at'].isoformat() + + # Ensure categories field exists + if 'categories' not in subscriber: + subscriber['categories'] = ['general', 'local', 'sports'] + + return jsonify(subscriber), 200 + else: + return jsonify({'error': 'Email not found in subscribers'}), 404 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + @subscription_bp.route('/api/subscribers/', methods=['DELETE']) def remove_subscriber(email): """Permanently remove a subscriber from the database""" @@ -110,3 +171,68 @@ def remove_subscriber(email): except Exception as e: return jsonify({'error': str(e)}), 500 + + +@subscription_bp.route('/api/admin/subscribers/delete-all', methods=['DELETE']) +def delete_all_subscribers(): + """Delete all subscribers (admin only)""" + try: + result = subscribers_collection.delete_many({}) + + return jsonify({ + 'message': f'Successfully deleted {result.deleted_count} subscribers', + 'deleted_count': result.deleted_count + }), 200 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@subscription_bp.route('/api/categories', methods=['GET']) +def get_categories(): + """Get available newsletter categories from RSS feeds""" + from database import rss_feeds_collection + + # Get unique categories from RSS feeds + categories_from_db = rss_feeds_collection.distinct('category') + + # Category metadata + category_info = { + 'general': { + 'name': 'Top Trending', + 'description': 'Top trending news and updates', + 'icon': '๐Ÿ”ฅ' + }, + 'local': { + 'name': 'Local Events', + 'description': 'Local events, culture, and community news', + 'icon': '๐Ÿ›๏ธ' + }, + 'sports': { + 'name': 'Sports', + 'description': 'Sports news and updates', + 'icon': 'โšฝ' + }, + 'science': { + 'name': 'Science & Tech', + 'description': 'Science and technology news', + 'icon': '๐Ÿ”ฌ' + } + } + + # Build category list + categories = [] + for cat_id in sorted(categories_from_db): + info = category_info.get(cat_id, { + 'name': cat_id.title(), + 'description': f'{cat_id.title()} news', + 'icon': '๐Ÿ“„' + }) + categories.append({ + 'id': cat_id, + 'name': info['name'], + 'description': info['description'], + 'icon': info['icon'] + }) + + return jsonify({'categories': categories}), 200 diff --git a/fix-categories.js b/fix-categories.js new file mode 100644 index 0000000..58ac0b9 --- /dev/null +++ b/fix-categories.js @@ -0,0 +1,56 @@ +// MongoDB script to fix article categories +// Run with: docker exec -i munich-news-mongodb mongosh -u admin -p changeme --authenticationDatabase admin < fix-categories.js + +use munich_news + +print("=== RSS Feeds and their categories ==="); +db.rss_feeds.find({}, {name: 1, category: 1, _id: 0}).forEach(feed => { + print(`${feed.name}: ${feed.category || 'NO CATEGORY'}`); +}); + +print("\n=== Current article category distribution ==="); +db.articles.aggregate([ + {$group: {_id: "$category", count: {$sum: 1}}}, + {$sort: {count: -1}} +]).forEach(result => { + print(`${result._id || 'null'}: ${result.count} articles`); +}); + +print("\n=== Fixing null categories ==="); + +// Update articles based on their RSS feed source +var feedsUpdated = 0; +db.rss_feeds.find().forEach(function(feed) { + if (feed.category) { + var result = db.articles.updateMany( + {source: feed.name, category: null}, + {$set: {category: feed.category}} + ); + if (result.modifiedCount > 0) { + print(`Updated ${result.modifiedCount} articles from ${feed.name} to category: ${feed.category}`); + feedsUpdated += result.modifiedCount; + } + } +}); + +// Set remaining null categories to 'general' +var remainingNull = db.articles.updateMany( + {category: null}, + {$set: {category: "general"}} +); + +if (remainingNull.modifiedCount > 0) { + print(`Set ${remainingNull.modifiedCount} remaining null articles to 'general'`); +} + +print(`\nTotal articles updated: ${feedsUpdated + remainingNull.modifiedCount}`); + +print("\n=== Updated article category distribution ==="); +db.articles.aggregate([ + {$group: {_id: "$category", count: {$sum: 1}}}, + {$sort: {count: -1}} +]).forEach(result => { + print(`${result._id || 'null'}: ${result.count} articles`); +}); + +print("\nโœ“ Done!"); diff --git a/frontend/public/admin.html b/frontend/public/admin.html index 33f8c84..114123c 100644 --- a/frontend/public/admin.html +++ b/frontend/public/admin.html @@ -226,6 +226,54 @@

๐Ÿ”— AI Clustering & Aggregation

Loading...
+ + +
+

๐Ÿ‘ฅ Subscriber Management

+
+ + +
+
+
+
+ + +
+

๐Ÿ“ก RSS Feed Management

+
+ + + +
+
+
+
+ + +
+

๐Ÿค– Recent AI Summarization Activity

+
+ + +
+
Loading...
+
diff --git a/frontend/public/admin.js b/frontend/public/admin.js index 2dd88ce..ca44752 100644 --- a/frontend/public/admin.js +++ b/frontend/public/admin.js @@ -313,3 +313,262 @@ async function loadClusteringStats() { document.getElementById('clusteringStats').innerHTML = `
Error: ${error.message}
`; } } + + +// Subscriber Management +async function viewSubscribers() { + const listDiv = document.getElementById('subscriberList'); + const messageDiv = document.getElementById('subscriberMessage'); + + listDiv.innerHTML = '

Loading subscribers...

'; + messageDiv.innerHTML = ''; + + try { + const response = await fetch('/api/subscribers'); + const data = await response.json(); + + if (data.subscribers && data.subscribers.length > 0) { + let html = '
'; + html += ''; + html += ''; + html += ''; + + data.subscribers.forEach(sub => { + const categories = sub.categories ? sub.categories.join(', ') : 'All'; + html += ` + + + + `; + }); + + html += '
EmailCategoriesStatus
${sub.email}${categories}${sub.status}
'; + html += `

Total: ${data.total} subscribers

`; + listDiv.innerHTML = html; + } else { + listDiv.innerHTML = '

No subscribers found.

'; + } + } catch (error) { + listDiv.innerHTML = '

Failed to load subscribers

'; + } +} + +async function deleteAllSubscribers() { + const messageDiv = document.getElementById('subscriberMessage'); + + if (!confirm('โš ๏ธ Are you sure you want to delete ALL subscribers? This cannot be undone!')) { + return; + } + + if (!confirm('โš ๏ธ FINAL WARNING: This will permanently delete all subscriber data. Continue?')) { + return; + } + + messageDiv.innerHTML = '

Deleting all subscribers...

'; + + try { + const response = await fetch('/api/admin/subscribers/delete-all', { + method: 'DELETE' + }); + + const data = await response.json(); + + if (response.ok) { + messageDiv.innerHTML = `

โœ“ ${data.message}

`; + document.getElementById('subscriberList').innerHTML = ''; + // Refresh stats + loadStats(); + } else { + messageDiv.innerHTML = `

โœ— ${data.error || 'Failed to delete subscribers'}

`; + } + } catch (error) { + messageDiv.innerHTML = '

โœ— Network error

'; + } +} + + +// RSS Feed Management +async function exportRSSFeeds() { + const messageDiv = document.getElementById('rssFeedMessage'); + messageDiv.innerHTML = '

Exporting RSS feeds...

'; + + try { + const response = await fetch('/api/rss-feeds/export'); + const data = await response.json(); + + if (response.ok) { + // Create download + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `rss-feeds-${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + messageDiv.innerHTML = `

โœ“ Exported ${data.total} RSS feeds

`; + } else { + messageDiv.innerHTML = `

โœ— ${data.error || 'Export failed'}

`; + } + } catch (error) { + messageDiv.innerHTML = '

โœ— Network error

'; + } +} + +async function importRSSFeeds(event) { + const messageDiv = document.getElementById('rssFeedMessage'); + const file = event.target.files[0]; + + if (!file) return; + + messageDiv.innerHTML = '

Importing RSS feeds...

'; + + try { + const text = await file.text(); + const data = JSON.parse(text); + + const response = await fetch('/api/rss-feeds/import', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }); + + const result = await response.json(); + + if (response.ok) { + let html = `

โœ“ ${result.message}

`; + if (result.errors && result.errors.length > 0) { + html += '
Show details
    '; + result.errors.forEach(err => { + html += `
  • ${err}
  • `; + }); + html += '
'; + } + messageDiv.innerHTML = html; + + // Refresh feed list if visible + if (document.getElementById('rssFeedList').innerHTML) { + viewRSSFeeds(); + } + } else { + messageDiv.innerHTML = `

โœ— ${result.error || 'Import failed'}

`; + } + } catch (error) { + messageDiv.innerHTML = `

โœ— Error: ${error.message}

`; + } + + // Reset file input + event.target.value = ''; +} + +async function viewRSSFeeds() { + const listDiv = document.getElementById('rssFeedList'); + const messageDiv = document.getElementById('rssFeedMessage'); + + listDiv.innerHTML = '

Loading RSS feeds...

'; + messageDiv.innerHTML = ''; + + try { + const response = await fetch('/api/rss-feeds'); + const data = await response.json(); + + if (data.feeds && data.feeds.length > 0) { + let html = '
'; + html += ''; + html += ''; + html += ''; + + data.feeds.forEach(feed => { + const statusColor = feed.active ? 'green' : 'gray'; + const statusText = feed.active ? 'Active' : 'Inactive'; + html += ` + + + + + `; + }); + + html += '
NameCategoryURLStatus
${feed.name}${feed.category}${feed.url}${statusText}
'; + html += `

Total: ${data.total} feeds

`; + listDiv.innerHTML = html; + } else { + listDiv.innerHTML = '

No RSS feeds found.

'; + } + } catch (error) { + listDiv.innerHTML = '

Failed to load RSS feeds

'; + } +} + + +// Recent Summarization Activity +let autoRefreshInterval = null; + +async function loadRecentArticles() { + const container = document.getElementById('recentArticles'); + + try { + const response = await fetch('/api/admin/recent-articles'); + const data = await response.json(); + + if (data.articles && data.articles.length > 0) { + let html = '
'; + html += ''; + html += ''; + html += ''; + + data.articles.forEach(article => { + const time = article.summarized_at ? new Date(article.summarized_at).toLocaleTimeString() : 'N/A'; + const title = article.title_en || article.title; + const categoryColors = { + 'general': '#667eea', + 'local': '#f59e0b', + 'sports': '#10b981', + 'science': '#8b5cf6' + }; + const categoryColor = categoryColors[article.category] || '#6b7280'; + + html += ` + + + + + + `; + }); + + html += '
TimeTitleSourceCategoryWords
${time}${title}${article.source}${article.category || 'N/A'}${article.summary_word_count || 'N/A'}
'; + html += `

Last updated: ${new Date().toLocaleTimeString()}

`; + container.innerHTML = html; + } else { + container.innerHTML = '

No summarized articles found.

'; + } + } catch (error) { + container.innerHTML = '

Failed to load recent articles

'; + } +} + +function toggleAutoRefresh() { + const checkbox = document.getElementById('autoRefresh'); + + if (checkbox.checked) { + // Start auto-refresh every 10 seconds + loadRecentArticles(); + autoRefreshInterval = setInterval(loadRecentArticles, 10000); + } else { + // Stop auto-refresh + if (autoRefreshInterval) { + clearInterval(autoRefreshInterval); + autoRefreshInterval = null; + } + } +} + +// Load recent articles on page load +document.addEventListener('DOMContentLoaded', () => { + loadRecentArticles(); +}); diff --git a/frontend/public/app.js b/frontend/public/app.js index ee57df2..8e4f82b 100644 --- a/frontend/public/app.js +++ b/frontend/public/app.js @@ -11,8 +11,32 @@ document.addEventListener('DOMContentLoaded', () => { loadNews(); loadStats(); setupInfiniteScroll(); + loadCategories(); }); +async function loadCategories() { + try { + const response = await fetch('/api/categories'); + const data = await response.json(); + const categories = data.categories || []; + + const container = document.getElementById('categoryCheckboxes'); + container.innerHTML = ''; + + categories.forEach(category => { + const label = document.createElement('label'); + label.className = 'flex items-center space-x-3 cursor-pointer'; + label.innerHTML = ` + + ${category.icon} ${category.name} + `; + container.appendChild(label); + }); + } catch (error) { + console.error('Failed to load categories:', error); + } +} + async function loadNews() { const newsGrid = document.getElementById('newsGrid'); newsGrid.innerHTML = '
Loading news...
'; @@ -291,6 +315,16 @@ async function subscribe() { return; } + // Get selected categories + const checkboxes = document.querySelectorAll('#categoryCheckboxes input[type="checkbox"]:checked'); + const categories = Array.from(checkboxes).map(cb => cb.value); + + if (categories.length === 0) { + formMessage.textContent = 'Please select at least one category'; + formMessage.className = 'text-red-200 font-medium'; + return; + } + subscribeBtn.disabled = true; subscribeBtn.textContent = 'Subscribing...'; subscribeBtn.classList.add('opacity-75', 'cursor-not-allowed'); @@ -302,7 +336,10 @@ async function subscribe() { headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: email }) + body: JSON.stringify({ + email: email, + categories: categories + }) }); const data = await response.json(); diff --git a/frontend/public/index.html b/frontend/public/index.html index aa1a61d..153b708 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -41,6 +41,15 @@ class="w-full px-6 py-4 rounded-lg text-gray-800 text-lg focus:outline-none focus:ring-4 focus:ring-white/30 shadow-lg" required > + + +
+

Choose your interests:

+
+ +
+
+ + + + + + + + + + + + + + diff --git a/frontend/server.js b/frontend/server.js index 5c846d8..1e99847 100644 --- a/frontend/server.js +++ b/frontend/server.js @@ -17,6 +17,15 @@ app.get('/admin', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'admin.html')); }); +// Serve preferences page +app.get('/preferences.html', (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'preferences.html')); +}); + +app.get('/preferences', (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'preferences.html')); +}); + // Serve static files app.use(express.static('public')); @@ -61,6 +70,83 @@ app.post('/api/unsubscribe', async (req, res) => { } }); +app.get('/api/subscribers/:email', async (req, res) => { + try { + const response = await axios.get(`${API_URL}/api/subscribers/${req.params.email}`); + res.json(response.data); + } catch (error) { + res.status(error.response?.status || 500).json( + error.response?.data || { error: 'Failed to get subscriber' } + ); + } +}); + +app.get('/api/categories', async (req, res) => { + try { + const response = await axios.get(`${API_URL}/api/categories`); + res.json(response.data); + } catch (error) { + res.status(error.response?.status || 500).json( + error.response?.data || { error: 'Failed to get categories' } + ); + } +}); + +app.delete('/api/admin/subscribers/delete-all', async (req, res) => { + try { + const response = await axios.delete(`${API_URL}/api/admin/subscribers/delete-all`); + res.json(response.data); + } catch (error) { + res.status(error.response?.status || 500).json( + error.response?.data || { error: 'Failed to delete subscribers' } + ); + } +}); + +app.get('/api/rss-feeds', async (req, res) => { + try { + const response = await axios.get(`${API_URL}/api/rss-feeds`); + res.json(response.data); + } catch (error) { + res.status(error.response?.status || 500).json( + error.response?.data || { error: 'Failed to get RSS feeds' } + ); + } +}); + +app.get('/api/rss-feeds/export', async (req, res) => { + try { + const response = await axios.get(`${API_URL}/api/rss-feeds/export`); + res.json(response.data); + } catch (error) { + res.status(error.response?.status || 500).json( + error.response?.data || { error: 'Failed to export RSS feeds' } + ); + } +}); + +app.post('/api/rss-feeds/import', async (req, res) => { + try { + const response = await axios.post(`${API_URL}/api/rss-feeds/import`, req.body); + res.json(response.data); + } catch (error) { + res.status(error.response?.status || 500).json( + error.response?.data || { error: 'Failed to import RSS feeds' } + ); + } +}); + +app.get('/api/admin/recent-articles', async (req, res) => { + try { + const response = await axios.get(`${API_URL}/api/admin/recent-articles`); + res.json(response.data); + } catch (error) { + res.status(error.response?.status || 500).json( + error.response?.data || { error: 'Failed to get recent articles' } + ); + } +}); + // Ollama API proxy endpoints for admin dashboard app.get('/api/ollama/ping', async (req, res) => { try { diff --git a/news_sender/newsletter_template.html b/news_sender/newsletter_template.html index e8a72b6..d2b22e0 100644 --- a/news_sender/newsletter_template.html +++ b/news_sender/newsletter_template.html @@ -83,18 +83,18 @@ - - {% if trending_articles %} + + {% for section in category_sections %} @@ -102,8 +102,8 @@ - - {% for article in trending_articles %} + + {% for article in section.articles %} {% endif %} {% endfor %} - {% endif %} - - {% if other_articles %} - + + {% if not loop.last %} - - - - - - - {% for article in other_articles %} - - - - - - {% if not loop.last %} - - - {% endif %} {% endfor %} - {% endif %} @@ -299,8 +205,8 @@ Here's everything in one sentence each:

- {% set all_articles = (trending_articles or []) + (other_articles or []) %} - {% for article in all_articles %} + {% for section in category_sections %} + {% for article in section.articles %}
{{ loop.index }}.

@@ -309,6 +215,7 @@

{% endfor %} + {% endfor %} @@ -355,6 +262,8 @@

Visit Website โ€ข + Manage Preferences + โ€ข Unsubscribe

diff --git a/news_sender/sender_service.py b/news_sender/sender_service.py index 46b99c4..9bffc4a 100644 --- a/news_sender/sender_service.py +++ b/news_sender/sender_service.py @@ -162,6 +162,7 @@ def get_latest_articles(max_articles=10, hours=24): 'link': doc.get('link', ''), 'summary': cluster.get('neutral_summary', doc.get('summary', '')), 'source': doc.get('source', ''), + 'category': doc.get('category', 'general'), 'published_at': doc.get('published_at', ''), 'is_clustered': True, 'sources': sources, @@ -177,6 +178,7 @@ def get_latest_articles(max_articles=10, hours=24): 'link': doc.get('link', ''), 'summary': doc.get('summary', ''), 'source': doc.get('source', ''), + 'category': doc.get('category', 'general'), 'published_at': doc.get('published_at', ''), 'is_clustered': False }) @@ -190,6 +192,7 @@ def get_latest_articles(max_articles=10, hours=24): 'link': doc.get('link', ''), 'summary': doc.get('summary', ''), 'source': doc.get('source', ''), + 'category': doc.get('category', 'general'), 'published_at': doc.get('published_at', ''), 'is_clustered': False }) @@ -206,22 +209,29 @@ def get_latest_articles(max_articles=10, hours=24): def get_active_subscribers(): """ - Get all active subscribers from database + Get all active subscribers from database with their category preferences Returns: - list: Email addresses of active subscribers + list: Subscriber dictionaries with email and categories """ cursor = subscribers_collection.find({'status': 'active'}) - return [doc['email'] for doc in cursor] + subscribers = [] + for doc in cursor: + subscribers.append({ + 'email': doc['email'], + 'categories': doc.get('categories', None) # None means all categories + }) + return subscribers -def render_newsletter_html(articles, tracking_enabled=False, pixel_tracking_id=None, - link_tracking_map=None, api_url=None): +def render_newsletter_html(articles, subscriber_categories=None, tracking_enabled=False, + pixel_tracking_id=None, link_tracking_map=None, api_url=None): """ Render newsletter HTML from template with optional tracking integration Args: articles: List of article dictionaries + subscriber_categories: List of categories the subscriber wants (None = all) tracking_enabled: Whether to inject tracking pixel and replace links pixel_tracking_id: Tracking ID for the email open pixel link_tracking_map: Dictionary mapping original URLs to tracking IDs @@ -237,10 +247,39 @@ def render_newsletter_html(articles, tracking_enabled=False, pixel_tracking_id=N template = Template(template_content) - # Split articles into sections - # Top 3 are "trending", rest are "other articles" - trending_articles = articles[:3] if len(articles) >= 3 else articles - other_articles = articles[3:] if len(articles) > 3 else [] + # Filter articles by subscriber's category preferences + if subscriber_categories: + filtered_articles = [a for a in articles if a.get('category', 'general') in subscriber_categories] + else: + filtered_articles = articles + + # Group articles by category (max 3 per category) + from collections import defaultdict + articles_by_category = defaultdict(list) + + for article in filtered_articles: + category = article.get('category', 'general') + if len(articles_by_category[category]) < 3: + articles_by_category[category].append(article) + + # Convert to list of category sections + category_sections = [] + category_names = { + 'general': {'name': 'Top Trending', 'icon': '๐Ÿ”ฅ'}, + 'local': {'name': 'Local Events', 'icon': '๐Ÿ›๏ธ'}, + 'sports': {'name': 'Sports', 'icon': 'โšฝ'}, + 'science': {'name': 'Science & Tech', 'icon': '๐Ÿ”ฌ'} + } + + for category, category_articles in sorted(articles_by_category.items()): + if category_articles: + cat_info = category_names.get(category, {'name': category.title(), 'icon': '๐Ÿ“„'}) + category_sections.append({ + 'id': category, + 'name': cat_info['name'], + 'icon': cat_info['icon'], + 'articles': category_articles + }) # Get weather data from weather_service import get_munich_weather @@ -248,13 +287,14 @@ def render_newsletter_html(articles, tracking_enabled=False, pixel_tracking_id=N # Prepare template data now = datetime.now() + total_articles = sum(len(section['articles']) for section in category_sections) template_data = { 'date': now.strftime('%A, %B %d, %Y'), 'year': now.year, - 'article_count': len(articles), - 'trending_articles': trending_articles, - 'other_articles': other_articles, + 'article_count': total_articles, + 'category_sections': category_sections, 'unsubscribe_link': f'{Config.WEBSITE_URL}/unsubscribe', + 'preferences_link': f'{Config.WEBSITE_URL}/preferences.html', 'website_link': Config.WEBSITE_URL, 'tracking_enabled': tracking_enabled, 'weather': weather @@ -358,7 +398,8 @@ def send_newsletter(max_articles=None, test_email=None): # Get subscribers if test_email: - subscribers = [test_email] + # For test mode, send with all categories + subscribers = [{'email': test_email, 'categories': None}] print(f"\n๐Ÿงช Test mode: Sending to {test_email} only") else: print("\nFetching active subscribers...") @@ -386,7 +427,10 @@ def send_newsletter(max_articles=None, test_email=None): failed_count = 0 errors = [] - for i, email in enumerate(subscribers, 1): + for i, subscriber in enumerate(subscribers, 1): + email = subscriber['email'] + categories = subscriber['categories'] + print(f"[{i}/{len(subscribers)}] Sending to {email}...", end=' ') # Generate tracking data for this subscriber if tracking is enabled @@ -399,9 +443,10 @@ def send_newsletter(max_articles=None, test_email=None): tracking_service=tracking_service ) - # Render newsletter with tracking + # Render newsletter with tracking and subscriber's category preferences html_content = render_newsletter_html( articles=articles, + subscriber_categories=categories, tracking_enabled=True, pixel_tracking_id=tracking_data['pixel_tracking_id'], link_tracking_map=tracking_data['link_tracking_map'], @@ -410,10 +455,10 @@ def send_newsletter(max_articles=None, test_email=None): except Exception as e: print(f"โš  Tracking error: {e}, sending without tracking...", end=' ') # Fallback: send without tracking - html_content = render_newsletter_html(articles) + html_content = render_newsletter_html(articles, subscriber_categories=categories) else: - # Render newsletter without tracking - html_content = render_newsletter_html(articles) + # Render newsletter without tracking but with subscriber's preferences + html_content = render_newsletter_html(articles, subscriber_categories=categories) # Send email success, error = send_email(email, subject, html_content)
-

- ๐Ÿ”ฅ Top Trending in Munich +

+ {{ section.icon }} {{ section.name }}

- The most talked-about stories today + Top stories in {{ section.name.lower() }}

@@ -177,110 +177,16 @@
- - - - -
-

- ๐Ÿ“ฐ More Stories -

-

- Additional news from around Munich -

-
-
- - - - - -
- - {{ loop.index + trending_articles|length }} - -
- - -

- {{ article.title_en if article.title_en else article.title }} -

- - - {% if article.title_en and article.title_en != article.title %} -

- Original: {{ article.title }} -

- {% endif %} - - -

- {% if article.is_clustered %} - Multiple sources - {% else %} - {{ article.source }} - {% if article.author %} - โ€ข {{ article.author }} - {% endif %} - {% endif %} -

- - -

- {{ article.summary }} -

- - - {% if article.is_clustered and article.sources %} - -

- ๐Ÿ“ฐ Covered by {{ article.article_count }} sources: -

-
- {% for source in article.sources %} - - {{ source.name }} โ†’ - - {% endfor %} -
- {% else %} - - - Read more โ†’ - - {% endif %} -
-
-