Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion backend/app/crud/entries.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
):
Expand Down
11 changes: 10 additions & 1 deletion backend/app/routers/feeds.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
33 changes: 32 additions & 1 deletion backend/app/services/feed_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)
59 changes: 58 additions & 1 deletion backend/app/tests/test_crud.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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"
46 changes: 45 additions & 1 deletion backend/app/tests/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="<p>Body A1</p>", 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="<p>Body B1</p>", 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):
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -68,6 +69,8 @@ function LetterFeedApp() {
onOpenSettings={() => setIsSettingsOpen(true)}
/>

{newsletters.length > 0 && <MasterFeedCard />}

{newsletters.length > 0 ? (
<NewsletterList newsletters={newsletters} onEditNewsletter={openEditDialog} />
) : (
Expand Down
39 changes: 39 additions & 0 deletions frontend/src/components/letterfeed/MasterFeedCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Card className="mb-8">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Rss className="w-5 h-5 text-orange-500" />
Master Feed
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
This feed contains all entries from all your newsletters in one place.
</p>
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">RSS Feed URL</h4>
<div className="flex items-center gap-2">
<a
href={feedUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 hover:underline"
>
<ExternalLink className="w-3 h-3" />
{feedUrl}
</a>
</div>
</div>
</CardContent>
</Card>
)
}
Original file line number Diff line number Diff line change
@@ -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(<MasterFeedCard />)

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")
})
})
4 changes: 4 additions & 0 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
}