mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
Paginate briefing cards and add video previews
This commit is contained in:
@@ -6,6 +6,8 @@ import type { ArticleContent, NewsItem } from '../types'
|
|||||||
|
|
||||||
type Category = 'all' | 'official' | 'news' | 'video'
|
type Category = 'all' | 'official' | 'news' | 'video'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 16
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<Category, string> = {
|
const CATEGORY_LABELS: Record<Category, string> = {
|
||||||
all: 'All',
|
all: 'All',
|
||||||
official: 'Official',
|
official: 'Official',
|
||||||
@@ -34,6 +36,12 @@ function categoryOf(item: NewsItem): Category {
|
|||||||
return 'news'
|
return 'news'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getYouTubeVideoId(url: string): string | null {
|
||||||
|
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/
|
||||||
|
const match = url.match(regExp)
|
||||||
|
return match && match[2].length === 11 ? match[2] : null
|
||||||
|
}
|
||||||
|
|
||||||
function CategoryTabs({
|
function CategoryTabs({
|
||||||
active,
|
active,
|
||||||
counts,
|
counts,
|
||||||
@@ -116,7 +124,7 @@ function BriefingCard({
|
|||||||
<span className="bp-card-age">{timeAgo(item.published_at ?? item.fetched_at)}</span>
|
<span className="bp-card-age">{timeAgo(item.published_at ?? item.fetched_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="bp-card-title">{item.title}</h3>
|
<h3 className="bp-card-title">{item.title}</h3>
|
||||||
{!isVideo && (item.og_description || item.summary) && (
|
{(item.og_description || item.summary) && (
|
||||||
<p className="bp-card-desc">
|
<p className="bp-card-desc">
|
||||||
{stripHtml(item.og_description || item.summary || '')}
|
{stripHtml(item.og_description || item.summary || '')}
|
||||||
</p>
|
</p>
|
||||||
@@ -153,13 +161,19 @@ function ReaderPanel({
|
|||||||
setArticle(null)
|
setArticle(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (categoryOf(item) === 'video') {
|
||||||
|
setArticle(null)
|
||||||
|
setLoading(false)
|
||||||
|
setError(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
setArticle(null)
|
setArticle(null)
|
||||||
fetchNewsArticle(item.url)
|
fetchNewsArticle(item.url)
|
||||||
.then((data) => { setArticle(data); setLoading(false) })
|
.then((data) => { setArticle(data); setLoading(false) })
|
||||||
.catch((err) => { setError(String(err)); setLoading(false) })
|
.catch((err) => { setError(String(err)); setLoading(false) })
|
||||||
}, [item?.url])
|
}, [item?.url, item ? categoryOf(item) : ''])
|
||||||
|
|
||||||
// Scroll panel to top when item changes
|
// Scroll panel to top when item changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -207,14 +221,27 @@ function ReaderPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(article?.image_url || item.og_image_url) && (
|
{categoryOf(item) === 'video' ? (
|
||||||
<div className="bp-reader-hero">
|
<div className="bp-reader-video-container">
|
||||||
<img
|
<iframe
|
||||||
src={article?.image_url ?? item.og_image_url}
|
className="bp-reader-video-iframe"
|
||||||
alt=""
|
src={`https://www.youtube.com/embed/${getYouTubeVideoId(item.url)}?autoplay=1&rel=0`}
|
||||||
loading="lazy"
|
title={item.title}
|
||||||
/>
|
frameBorder="0"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
|
allowFullScreen
|
||||||
|
></iframe>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
(article?.image_url || item.og_image_url) && (
|
||||||
|
<div className="bp-reader-hero">
|
||||||
|
<img
|
||||||
|
src={article?.image_url ?? item.og_image_url}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="bp-reader-content">
|
<div className="bp-reader-content">
|
||||||
@@ -225,6 +252,25 @@ function ReaderPanel({
|
|||||||
<div className="bp-reader-byline mono">{article.byline}</div>
|
<div className="bp-reader-byline mono">{article.byline}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{categoryOf(item) === 'video' && (
|
||||||
|
<div className="bp-reader-video-desc">
|
||||||
|
{(item.og_description || item.summary) && (
|
||||||
|
<p className="bp-reader-fallback" style={{ marginBottom: '16px' }}>
|
||||||
|
{stripHtml(item.og_description || item.summary || '')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={item.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="bp-reader-ext-link"
|
||||||
|
style={{ fontSize: '13px', fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
Open in YouTube ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="bp-reader-loading">Loading article…</div>
|
<div className="bp-reader-loading">Loading article…</div>
|
||||||
)}
|
)}
|
||||||
@@ -286,6 +332,7 @@ function ReaderPanel({
|
|||||||
export function BriefingPage() {
|
export function BriefingPage() {
|
||||||
const [activeCategory, setActiveCategory] = useState<Category>('all')
|
const [activeCategory, setActiveCategory] = useState<Category>('all')
|
||||||
const [selectedItem, setSelectedItem] = useState<NewsItem | null>(null)
|
const [selectedItem, setSelectedItem] = useState<NewsItem | null>(null)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const { data: allNews = [], isLoading, isError } = useQuery({
|
const { data: allNews = [], isLoading, isError } = useQuery({
|
||||||
@@ -297,6 +344,10 @@ export function BriefingPage() {
|
|||||||
const filtered = allNews.filter(
|
const filtered = allNews.filter(
|
||||||
(item) => activeCategory === 'all' || categoryOf(item) === activeCategory,
|
(item) => activeCategory === 'all' || categoryOf(item) === activeCategory,
|
||||||
)
|
)
|
||||||
|
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
|
||||||
|
const safePage = Math.min(page, pageCount)
|
||||||
|
const pageStart = (safePage - 1) * PAGE_SIZE
|
||||||
|
const pagedItems = filtered.slice(pageStart, pageStart + PAGE_SIZE)
|
||||||
|
|
||||||
const counts: Record<Category, number> = {
|
const counts: Record<Category, number> = {
|
||||||
all: allNews.length,
|
all: allNews.length,
|
||||||
@@ -323,7 +374,22 @@ export function BriefingPage() {
|
|||||||
|
|
||||||
const handleClose = useCallback(() => setSelectedItem(null), [])
|
const handleClose = useCallback(() => setSelectedItem(null), [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage((current) => Math.min(current, pageCount))
|
||||||
|
}, [pageCount])
|
||||||
|
|
||||||
|
const handlePageChange = useCallback(
|
||||||
|
(nextPage: number) => {
|
||||||
|
setPage(Math.min(Math.max(nextPage, 1), pageCount))
|
||||||
|
setSelectedItem(null)
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
},
|
||||||
|
[pageCount],
|
||||||
|
)
|
||||||
|
|
||||||
const unreadCount = allNews.filter((i) => !i.read_at).length
|
const unreadCount = allNews.filter((i) => !i.read_at).length
|
||||||
|
const pageEnd = Math.min(pageStart + pagedItems.length, filtered.length)
|
||||||
|
const showPagination = filtered.length > PAGE_SIZE
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bp-page" data-testid="briefing-page">
|
<div className="bp-page" data-testid="briefing-page">
|
||||||
@@ -342,7 +408,7 @@ export function BriefingPage() {
|
|||||||
<CategoryTabs
|
<CategoryTabs
|
||||||
active={activeCategory}
|
active={activeCategory}
|
||||||
counts={counts}
|
counts={counts}
|
||||||
onChange={(c) => { setActiveCategory(c); setSelectedItem(null) }}
|
onChange={(c) => { setActiveCategory(c); setSelectedItem(null); setPage(1) }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
@@ -351,16 +417,67 @@ export function BriefingPage() {
|
|||||||
Run <code>box-box --ingest-news</code> to refresh feeds.
|
Run <code>box-box --ingest-news</code> to refresh feeds.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
|
<>
|
||||||
{filtered.map((item) => (
|
{showPagination && (
|
||||||
<BriefingCard
|
<div className="bp-pagination bp-pagination-top">
|
||||||
key={item.url}
|
<span className="bp-pagination-meta mono">
|
||||||
item={item}
|
{pageStart + 1}-{pageEnd} of {filtered.length}
|
||||||
isActive={selectedItem?.url === item.url}
|
</span>
|
||||||
onSelect={handleSelect}
|
<div className="bp-pagination-actions">
|
||||||
/>
|
<button
|
||||||
))}
|
className="bp-page-btn"
|
||||||
</div>
|
onClick={() => handlePageChange(safePage - 1)}
|
||||||
|
disabled={safePage === 1}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="bp-page-current mono">
|
||||||
|
Page {safePage} / {pageCount}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="bp-page-btn"
|
||||||
|
onClick={() => handlePageChange(safePage + 1)}
|
||||||
|
disabled={safePage === pageCount}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
|
||||||
|
{pagedItems.map((item) => (
|
||||||
|
<BriefingCard
|
||||||
|
key={item.url}
|
||||||
|
item={item}
|
||||||
|
isActive={selectedItem?.url === item.url}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPagination && (
|
||||||
|
<div className="bp-pagination bp-pagination-bottom">
|
||||||
|
<button
|
||||||
|
className="bp-page-btn"
|
||||||
|
onClick={() => handlePageChange(safePage - 1)}
|
||||||
|
disabled={safePage === 1}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="bp-page-current mono">
|
||||||
|
Page {safePage} / {pageCount}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="bp-page-btn"
|
||||||
|
onClick={() => handlePageChange(safePage + 1)}
|
||||||
|
disabled={safePage === pageCount}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2455,22 +2455,76 @@ a { color: inherit; text-decoration: none; }
|
|||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bp-pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s4);
|
||||||
|
}
|
||||||
|
.bp-pagination-top {
|
||||||
|
margin-bottom: var(--s4);
|
||||||
|
padding-bottom: var(--s3);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.bp-pagination-bottom {
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: var(--s5);
|
||||||
|
}
|
||||||
|
.bp-pagination-meta,
|
||||||
|
.bp-page-current {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.bp-pagination-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
.bp-page-btn {
|
||||||
|
min-width: 76px;
|
||||||
|
padding: var(--s2) var(--s4);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-radius: 2px;
|
||||||
|
color: var(--text-2);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.bp-page-btn:hover:not(:disabled) {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface-h);
|
||||||
|
}
|
||||||
|
.bp-page-btn:disabled {
|
||||||
|
color: var(--text-3);
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
/* Individual card */
|
/* Individual card */
|
||||||
.bp-card {
|
.bp-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 2px;
|
border-radius: 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.1s, background 0.1s;
|
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||||
position: relative;
|
position: relative;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
.bp-card:hover { border-color: var(--border-2); background: var(--surface-h); }
|
.bp-card:hover {
|
||||||
.bp-card.bp-card-active { border-color: var(--red); }
|
border-color: var(--border-2);
|
||||||
.bp-card.bp-card-read { opacity: 0.6; }
|
background: var(--surface-h);
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.bp-card.bp-card-active {
|
||||||
|
border-color: var(--red);
|
||||||
|
box-shadow: 0 0 0 1px var(--red), 0 4px 20px rgba(225, 6, 0, 0.15);
|
||||||
|
}
|
||||||
|
.bp-card.bp-card-read { opacity: 0.5; }
|
||||||
|
|
||||||
.bp-card-img-wrap {
|
.bp-card-img-wrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -2483,9 +2537,9 @@ a { color: inherit; text-decoration: none; }
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
display: block;
|
display: block;
|
||||||
transition: transform 0.2s;
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
.bp-card:hover .bp-card-img { transform: scale(1.02); }
|
.bp-card:hover .bp-card-img { transform: scale(1.04); }
|
||||||
|
|
||||||
.bp-card-img.bp-card-img-fallback,
|
.bp-card-img.bp-card-img-fallback,
|
||||||
.bp-card-img-fallback {
|
.bp-card-img-fallback {
|
||||||
@@ -2638,11 +2692,34 @@ a { color: inherit; text-decoration: none; }
|
|||||||
}
|
}
|
||||||
.bp-reader-close:hover { color: var(--text); }
|
.bp-reader-close:hover { color: var(--text); }
|
||||||
|
|
||||||
|
.bp-reader-video-container {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
position: relative;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.bp-reader-video-iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.bp-reader-video-desc {
|
||||||
|
margin-bottom: var(--s5);
|
||||||
|
padding: var(--s4);
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-left: 3px solid var(--red);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.bp-reader-hero {
|
.bp-reader-hero {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.bp-reader-hero img {
|
.bp-reader-hero img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -2733,6 +2810,20 @@ a { color: inherit; text-decoration: none; }
|
|||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.bp-grid { grid-template-columns: 1fr; }
|
.bp-grid { grid-template-columns: 1fr; }
|
||||||
|
.bp-pagination {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.bp-pagination-actions,
|
||||||
|
.bp-pagination-bottom {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.bp-page-current {
|
||||||
|
align-self: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
.bp-reader { width: 100vw; }
|
.bp-reader { width: 100vw; }
|
||||||
.bp-reader-content { padding: var(--s5) var(--s5) var(--s7); }
|
.bp-reader-content { padding: var(--s5) var(--s5) var(--s7); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
"golang.org/x/net/html"
|
"golang.org/x/net/html"
|
||||||
)
|
)
|
||||||
|
|
||||||
const UserAgent = "box-box/phase-19b-rss-spike"
|
const UserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||||
|
|
||||||
// Source describes a feed that can be fetched and normalized.
|
// Source describes a feed that can be fetched and normalized.
|
||||||
type Source struct {
|
type Source struct {
|
||||||
@@ -207,6 +207,12 @@ type rssItem struct {
|
|||||||
PubDate string `xml:"pubDate"`
|
PubDate string `xml:"pubDate"`
|
||||||
Description string `xml:"description"`
|
Description string `xml:"description"`
|
||||||
Categories []string `xml:"category"`
|
Categories []string `xml:"category"`
|
||||||
|
Thumbnail struct {
|
||||||
|
URL string `xml:"url,attr"`
|
||||||
|
} `xml:"http://search.yahoo.com/mrss/ thumbnail"`
|
||||||
|
Content struct {
|
||||||
|
URL string `xml:"url,attr"`
|
||||||
|
} `xml:"http://search.yahoo.com/mrss/ content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type atomFeed struct {
|
type atomFeed struct {
|
||||||
@@ -225,6 +231,12 @@ type atomEntry struct {
|
|||||||
Term string `xml:"term,attr"`
|
Term string `xml:"term,attr"`
|
||||||
Label string `xml:"label,attr"`
|
Label string `xml:"label,attr"`
|
||||||
} `xml:"category"`
|
} `xml:"category"`
|
||||||
|
MediaGroup struct {
|
||||||
|
Thumbnail struct {
|
||||||
|
URL string `xml:"url,attr"`
|
||||||
|
} `xml:"http://search.yahoo.com/mrss/ thumbnail"`
|
||||||
|
Description string `xml:"http://search.yahoo.com/mrss/ description"`
|
||||||
|
} `xml:"http://search.yahoo.com/mrss/ group"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type atomLink struct {
|
type atomLink struct {
|
||||||
@@ -243,6 +255,10 @@ func normalizeRSS(source Source, raw []rssItem, fetchedAt time.Time) []Item {
|
|||||||
if !source.SkipSummary {
|
if !source.SkipSummary {
|
||||||
summary = stripHTML(entry.Description)
|
summary = stripHTML(entry.Description)
|
||||||
}
|
}
|
||||||
|
imgURL := entry.Thumbnail.URL
|
||||||
|
if imgURL == "" {
|
||||||
|
imgURL = entry.Content.URL
|
||||||
|
}
|
||||||
items = append(items, Item{
|
items = append(items, Item{
|
||||||
Source: source.ID,
|
Source: source.ID,
|
||||||
Title: cleanText(entry.Title),
|
Title: cleanText(entry.Title),
|
||||||
@@ -251,6 +267,7 @@ func normalizeRSS(source Source, raw []rssItem, fetchedAt time.Time) []Item {
|
|||||||
Summary: summary,
|
Summary: summary,
|
||||||
Category: firstNonEmpty(entry.Categories, source.Category),
|
Category: firstNonEmpty(entry.Categories, source.Category),
|
||||||
FetchedAt: fetchedAt,
|
FetchedAt: fetchedAt,
|
||||||
|
OGImageURL: imgURL,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return DeduplicateByURL(items)
|
return DeduplicateByURL(items)
|
||||||
@@ -265,17 +282,19 @@ func normalizeAtom(source Source, raw []atomEntry, fetchedAt time.Time) []Item {
|
|||||||
}
|
}
|
||||||
summary := ""
|
summary := ""
|
||||||
if !source.SkipSummary {
|
if !source.SkipSummary {
|
||||||
raw := firstNonEmpty([]string{entry.Summary, entry.Content}, "")
|
raw := firstNonEmpty([]string{entry.Summary, entry.Content, entry.MediaGroup.Description}, "")
|
||||||
summary = stripHTML(raw)
|
summary = stripHTML(raw)
|
||||||
}
|
}
|
||||||
items = append(items, Item{
|
items = append(items, Item{
|
||||||
Source: source.ID,
|
Source: source.ID,
|
||||||
Title: cleanText(entry.Title),
|
Title: cleanText(entry.Title),
|
||||||
URL: atomEntryURL(entry),
|
URL: atomEntryURL(entry),
|
||||||
PublishedAt: parseFeedTime(firstNonEmpty([]string{entry.Published, entry.Updated}, "")),
|
PublishedAt: parseFeedTime(firstNonEmpty([]string{entry.Published, entry.Updated}, "")),
|
||||||
Summary: summary,
|
Summary: summary,
|
||||||
Category: category,
|
Category: category,
|
||||||
FetchedAt: fetchedAt,
|
FetchedAt: fetchedAt,
|
||||||
|
OGImageURL: entry.MediaGroup.Thumbnail.URL,
|
||||||
|
OGDescription: stripHTML(entry.MediaGroup.Description),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return DeduplicateByURL(items)
|
return DeduplicateByURL(items)
|
||||||
|
|||||||
@@ -166,6 +166,9 @@ func enrichOG(ctx context.Context, client *http.Client, items []Item, parallel i
|
|||||||
}
|
}
|
||||||
|
|
||||||
for i := range items {
|
for i := range items {
|
||||||
|
if items[i].OGImageURL != "" {
|
||||||
|
continue // Already has preview from feed, skip OG head fetch
|
||||||
|
}
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(idx int) {
|
go func(idx int) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ import (
|
|||||||
func TestCoverageCRUD(t *testing.T) {
|
func TestCoverageCRUD(t *testing.T) {
|
||||||
s := openTestStore(t)
|
s := openTestStore(t)
|
||||||
|
|
||||||
// Verify schema migration version is 4 (since we added 004_coverage.sql)
|
// Verify schema migration version is 5 (since we added 005_news_enriched.sql)
|
||||||
version, err := s.SchemaVersion()
|
version, err := s.SchemaVersion()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SchemaVersion() error = %v", err)
|
t.Fatalf("SchemaVersion() error = %v", err)
|
||||||
}
|
}
|
||||||
if version != 4 {
|
if version != 5 {
|
||||||
t.Fatalf("SchemaVersion() = %d, want 4", version)
|
t.Fatalf("SchemaVersion() = %d, want 5", version)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify session_coverage table exists
|
// Verify session_coverage table exists
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ func TestOpenAppliesMigrations(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SchemaVersion() error = %v", err)
|
t.Fatalf("SchemaVersion() error = %v", err)
|
||||||
}
|
}
|
||||||
if version != 4 {
|
if version != 5 {
|
||||||
t.Fatalf("SchemaVersion() = %d, want 4", version)
|
t.Fatalf("SchemaVersion() = %d, want 5", version)
|
||||||
}
|
}
|
||||||
|
|
||||||
tables := []string{
|
tables := []string{
|
||||||
@@ -99,6 +99,12 @@ func TestMigrationsAreIdempotent(t *testing.T) {
|
|||||||
if count != 1 {
|
if count != 1 {
|
||||||
t.Fatalf("schema_migrations v4 count = %d, want 1", count)
|
t.Fatalf("schema_migrations v4 count = %d, want 1", count)
|
||||||
}
|
}
|
||||||
|
if err := s.db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version = 5`).Scan(&count); err != nil {
|
||||||
|
t.Fatalf("count schema_migrations v5: %v", err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("schema_migrations v5 count = %d, want 1", count)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRawPayloadInsertAndRead(t *testing.T) {
|
func TestRawPayloadInsertAndRead(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user