diff --git a/backend/app/crud/entries.py b/backend/app/crud/entries.py index eae2e1c..a9a56bd 100644 --- a/backend/app/crud/entries.py +++ b/backend/app/crud/entries.py @@ -1,5 +1,5 @@ from nanoid import generate -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, joinedload from app.core.logging import get_logger from app.models.entries import Entry @@ -8,6 +8,19 @@ logger = get_logger(__name__) +def get_all_entries(db: Session, skip: int = 0, limit: int = 100): + """Retrieve all entries from all newsletters, sorted by received date.""" + logger.debug(f"Querying all entries with skip={skip}, limit={limit}") + return ( + db.query(Entry) + .options(joinedload(Entry.newsletter)) + .order_by(Entry.received_at.desc()) + .offset(skip) + .limit(limit) + .all() + ) + + def get_entries_by_newsletter( db: Session, newsletter_id: str, skip: int = 0, limit: int = 100 ): diff --git a/backend/app/routers/feeds.py b/backend/app/routers/feeds.py index 9f9fbfd..0c4417d 100644 --- a/backend/app/routers/feeds.py +++ b/backend/app/routers/feeds.py @@ -4,12 +4,21 @@ from app.core.database import get_db from app.core.logging import get_logger -from app.services.feed_generator import generate_feed +from app.services.feed_generator import generate_feed, generate_master_feed logger = get_logger(__name__) router = APIRouter() +@router.get("/feeds/all") +def get_master_feed(db: Session = Depends(get_db)): + """Generate a master Atom feed for all newsletters.""" + logger.info("Generating master feed for all newsletters") + feed = generate_master_feed(db) + logger.info("Successfully generated master feed") + return Response(content=feed, media_type="application/atom+xml") + + @router.get("/feeds/{feed_identifier}") def get_newsletter_feed(feed_identifier: str, db: Session = Depends(get_db)): """Generate an Atom feed for a specific newsletter.""" diff --git a/backend/app/services/feed_generator.py b/backend/app/services/feed_generator.py index 97e5f32..e6fb0b8 100644 --- a/backend/app/services/feed_generator.py +++ b/backend/app/services/feed_generator.py @@ -3,7 +3,7 @@ from sqlalchemy.orm import Session from app.core.config import settings -from app.crud.entries import get_entries_by_newsletter +from app.crud.entries import get_all_entries, get_entries_by_newsletter from app.crud.newsletters import get_newsletter_by_identifier @@ -41,3 +41,34 @@ def generate_feed(db: Session, feed_identifier: str): fe.published(entry.received_at) return fg.atom_str(pretty=True) + + +def generate_master_feed(db: Session): + """Generate a master Atom feed for all newsletters.""" + entries = get_all_entries(db) + + feed_url = f"{settings.app_base_url}/feeds/all" + logo_url = f"{settings.app_base_url}/logo.png" + icon_url = f"{settings.app_base_url}/favicon.ico" + + fg = FeedGenerator() + fg.id("urn:letterfeed:master") + fg.title("LetterFeed: All Newsletters") + fg.logo(logo_url) + fg.icon(icon_url) + fg.link(href=feed_url, rel="self") + fg.link(href=f"{settings.app_base_url}/", rel="alternate") + fg.description("A master feed of all your newsletters.") + + for entry in entries: + fe = fg.add_entry() + fe.id(f"urn:letterfeed:entry:{entry.id}") + fe.title(f"[{entry.newsletter.name}] {entry.subject}") + fe.content(entry.body, type="html") + if entry.received_at.tzinfo is None: + timezone_aware_received_at = entry.received_at.replace(tzinfo=tz.tzutc()) + fe.published(timezone_aware_received_at) + else: + fe.published(entry.received_at) + + return fg.atom_str(pretty=True) diff --git a/backend/app/tests/test_crud.py b/backend/app/tests/test_crud.py index 3c1c9a1..7948ae0 100644 --- a/backend/app/tests/test_crud.py +++ b/backend/app/tests/test_crud.py @@ -1,9 +1,10 @@ +import time import uuid from unittest.mock import patch from sqlalchemy.orm import Session -from app.crud.entries import create_entry, get_entries_by_newsletter +from app.crud.entries import create_entry, get_all_entries, get_entries_by_newsletter from app.crud.newsletters import ( create_newsletter, get_newsletter_by_identifier, @@ -329,3 +330,59 @@ def test_create_multiple_entries_have_different_timestamps(db_session: Session): entry2 = create_entry(db_session, entry_data_2, newsletter.id) assert entry1.received_at != entry2.received_at + + +def test_get_all_entries(db_session: Session): + """Test getting all entries from all newsletters.""" + # Create two newsletters + newsletter1 = create_newsletter( + db_session, + NewsletterCreate( + name="Newsletter One", sender_emails=[f"one_{uuid.uuid4()}@test.com"] + ), + ) + time.sleep(0.1) # Ensure different timestamps + newsletter2 = create_newsletter( + db_session, + NewsletterCreate( + name="Newsletter Two", sender_emails=[f"two_{uuid.uuid4()}@test.com"] + ), + ) + + # Create entries for both + entry1 = create_entry( + db_session, + EntryCreate( + subject="Entry 1", body="Body 1", message_id=f"<{uuid.uuid4()}@test.com>" + ), + newsletter1.id, + ) + time.sleep(0.1) + entry2 = create_entry( + db_session, + EntryCreate( + subject="Entry 2", body="Body 2", message_id=f"<{uuid.uuid4()}@test.com>" + ), + newsletter2.id, + ) + time.sleep(0.1) + entry3 = create_entry( + db_session, + EntryCreate( + subject="Entry 3", body="Body 3", message_id=f"<{uuid.uuid4()}@test.com>" + ), + newsletter1.id, + ) + + # Get all entries + all_entries = get_all_entries(db_session, limit=10) + + # Assertions + assert len(all_entries) == 3 + # Check for descending order by received_at + assert all_entries[0].id == entry3.id + assert all_entries[1].id == entry2.id + assert all_entries[2].id == entry1.id + # Check that newsletter relationship is loaded + assert all_entries[0].newsletter.name == "Newsletter One" + assert all_entries[1].newsletter.name == "Newsletter Two" diff --git a/backend/app/tests/test_services.py b/backend/app/tests/test_services.py index 19e358d..823672c 100644 --- a/backend/app/tests/test_services.py +++ b/backend/app/tests/test_services.py @@ -7,7 +7,51 @@ from app.crud.newsletters import create_newsletter from app.schemas.entries import EntryCreate from app.schemas.newsletters import NewsletterCreate -from app.services.feed_generator import generate_feed +from app.services.feed_generator import generate_feed, generate_master_feed + + +def test_generate_master_feed(db_session: Session): + """Test the master feed generation for all newsletters.""" + # Create newsletters and entries + nl1 = create_newsletter( + db_session, + NewsletterCreate(name="Newsletter A", sender_emails=["a@example.com"]), + ) + create_entry( + db_session, + EntryCreate( + subject="Entry A1", body="

Body A1

", message_id=f"<{uuid.uuid4()}>" + ), + nl1.id, + ) + + nl2 = create_newsletter( + db_session, + NewsletterCreate(name="Newsletter B", sender_emails=["b@example.com"]), + ) + create_entry( + db_session, + EntryCreate( + subject="Entry B1", body="

Body B1

", message_id=f"<{uuid.uuid4()}>" + ), + nl2.id, + ) + + # Generate the master feed + feed_xml = generate_master_feed(db_session) + assert feed_xml is not None + + # Parse and verify + root = ET.fromstring(feed_xml) + ns = {"atom": "http://www.w3.org/2005/Atom"} + assert root.find("atom:title", ns).text == "LetterFeed: All Newsletters" + assert root.find("atom:id", ns).text == "urn:letterfeed:master" + + entry_titles = { + entry.find("atom:title", ns).text for entry in root.findall("atom:entry", ns) + } + assert "[Newsletter A] Entry A1" in entry_titles + assert "[Newsletter B] Entry B1" in entry_titles def test_generate_feed(db_session: Session): diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 39856ef..bf769c3 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -14,6 +14,7 @@ import { LoadingSpinner } from "@/components/letterfeed/LoadingSpinner" import { Header } from "@/components/letterfeed/Header" import { NewsletterList } from "@/components/letterfeed/NewsletterList" import { EmptyState } from "@/components/letterfeed/EmptyState" +import { MasterFeedCard } from "@/components/letterfeed/MasterFeedCard" import { NewsletterDialog } from "@/components/letterfeed/NewsletterDialog" import { SettingsDialog } from "@/components/letterfeed/SettingsDialog" @@ -68,6 +69,8 @@ function LetterFeedApp() { onOpenSettings={() => setIsSettingsOpen(true)} /> + {newsletters.length > 0 && } + {newsletters.length > 0 ? ( ) : ( diff --git a/frontend/src/components/letterfeed/MasterFeedCard.tsx b/frontend/src/components/letterfeed/MasterFeedCard.tsx new file mode 100644 index 0000000..5b598c2 --- /dev/null +++ b/frontend/src/components/letterfeed/MasterFeedCard.tsx @@ -0,0 +1,39 @@ +"use client" + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Rss, ExternalLink } from "lucide-react" +import { getMasterFeedUrl } from "@/lib/api" + +export function MasterFeedCard() { + const feedUrl = getMasterFeedUrl() + + return ( + + + + + Master Feed + + + +

+ This feed contains all entries from all your newsletters in one place. +

+
+

RSS Feed URL

+ +
+
+
+ ) +} diff --git a/frontend/src/components/letterfeed/__tests__/MasterFeedCard.test.tsx b/frontend/src/components/letterfeed/__tests__/MasterFeedCard.test.tsx new file mode 100644 index 0000000..a9aa334 --- /dev/null +++ b/frontend/src/components/letterfeed/__tests__/MasterFeedCard.test.tsx @@ -0,0 +1,45 @@ +import React from "react" +import { render, screen } from "@testing-library/react" +import "@testing-library/jest-dom" +import { MasterFeedCard } from "../MasterFeedCard" + +// Mock the getMasterFeedUrl function +jest.mock("@/lib/api", () => ({ + ...jest.requireActual("@/lib/api"), + getMasterFeedUrl: jest.fn(() => "http://mock-api/feeds/all"), +})) + +// Mock the toast +jest.mock("sonner", () => ({ + toast: { + success: jest.fn(), + }, +})) + +// Mock navigator.clipboard +Object.assign(navigator, { + clipboard: { + writeText: jest.fn(), + }, +}) + +describe("MasterFeedCard", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("renders the master feed card with the correct URL", () => { + render() + + expect(screen.getByText("Master Feed")).toBeInTheDocument() + expect( + screen.getByText( + "This feed contains all entries from all your newsletters in one place." + ) + ).toBeInTheDocument() + + const feedLink = screen.getByRole("link") + expect(feedLink).toHaveAttribute("href", "http://mock-api/feeds/all") + expect(feedLink).toHaveTextContent("http://mock-api/feeds/all") + }) +}) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a91d975..f2198e1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -208,3 +208,7 @@ export function getFeedUrl(newsletter: Newsletter): string { const feedIdentifier = newsletter.slug || newsletter.id; return `${API_BASE_URL}/feeds/${feedIdentifier}`; } + +export function getMasterFeedUrl(): string { + return `${API_BASE_URL}/feeds/all`; +}