Update fetching and filtering capabilities by adding category support in GetArticles methods across multiple files. Update NewsStore to manage selected category state and integrate category filtering in article loading. Improve feed fetching with abort signal handling in fetchFeed function. Update UI components to reflect category selection and enhance user experience with new feed health management features.
Some checks failed
renovate / renovate (push) Failing after 13s
CI / build-frontend (push) Successful in 50s
CI / build-backend (push) Successful in 49s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m39s

This commit is contained in:
2025-12-27 21:26:28 -06:00
parent a4503563e3
commit 895fba9ded
9 changed files with 258 additions and 61 deletions

View File

@@ -29,14 +29,9 @@ Web News follows a "zero-knowledge" philosophy:
- [ ] Reading time
- [ ] UI/UX Cleanup
- [ ] Add feed fetching timeout and button to remove if failed 3 times
- [ ] Use Go Mobile, remove Java RSS plugin.
- [ ] Dont show loading screen if not initial load (eg. reloading tab)
- [ ] Fix feeds double image (Feed image and article image at top)
- [ ] Export article(s)
- [ ] Export/Import OPML on add feed modal
- [ ] Favicons for feeds and caching
- [ ]
- [ ] Favicon fetcher and caching
## Getting Started

View File

@@ -151,12 +151,12 @@ func (a *App) SaveArticles(articles string) error {
return a.db.SaveArticles(articles)
}
func (a *App) GetArticles(feedId string, offset, limit int) (string, error) {
a.logDebug("GetArticles feedId=%s offset=%d limit=%d", feedId, offset, limit)
func (a *App) GetArticles(feedId string, offset, limit int, categoryId string) (string, error) {
a.logDebug("GetArticles feedId=%s categoryId=%s offset=%d limit=%d", feedId, categoryId, offset, limit)
if a.db == nil {
return "[]", nil
}
return a.db.GetArticles(feedId, offset, limit)
return a.db.GetArticles(feedId, offset, limit, categoryId)
}
func (a *App) SearchArticles(query string, limit int) (string, error) {

View File

@@ -52,6 +52,7 @@ export default [
FileReader: 'readonly',
performance: 'readonly',
AbortController: 'readonly',
AbortSignal: 'readonly',
DOMParser: 'readonly',
Element: 'readonly',
Node: 'readonly',

View File

@@ -322,11 +322,18 @@ func (s *SQLiteDB) SaveArticles(articlesJSON string) error {
return tx.Commit()
}
func (s *SQLiteDB) GetArticles(feedId string, offset, limit int) (string, error) {
func (s *SQLiteDB) GetArticles(feedId string, offset, limit int, categoryId string) (string, error) {
var rows *sql.Rows
var err error
if feedId != "" {
rows, err = s.db.Query("SELECT id, feedId, title, link, description, content, author, pubDate, read, saved, imageUrl, readAt FROM articles WHERE feedId = ? ORDER BY pubDate DESC LIMIT ? OFFSET ?", feedId, limit, offset)
} else if categoryId != "" {
rows, err = s.db.Query(`
SELECT a.id, a.feedId, a.title, a.link, a.description, a.content, a.author, a.pubDate, a.read, a.saved, a.imageUrl, a.readAt
FROM articles a
JOIN feeds f ON a.feedId = f.id
WHERE f.categoryId = ?
ORDER BY a.pubDate DESC LIMIT ? OFFSET ?`, categoryId, limit, offset)
} else {
rows, err = s.db.Query("SELECT id, feedId, title, link, description, content, author, pubDate, read, saved, imageUrl, readAt FROM articles ORDER BY pubDate DESC LIMIT ? OFFSET ?", limit, offset)
}
@@ -488,7 +495,7 @@ func (s *SQLiteDB) ClearAll() error {
func (s *SQLiteDB) GetReadingHistory(days int) (string, error) {
cutoff := time.Now().AddDate(0, 0, -days).UnixMilli()
rows, err := s.db.Query(`
SELECT strftime('%Y-%m-%d', datetime(readAt/1000, 'unixepoch')) as date, COUNT(*) as count
SELECT strftime('%Y-%m-%d', datetime(readAt/1000, 'unixepoch', 'localtime')) as date, COUNT(*) as count
FROM articles
WHERE read = 1 AND readAt > ?
GROUP BY date
@@ -505,8 +512,11 @@ func (s *SQLiteDB) GetReadingHistory(days int) (string, error) {
if err := rows.Scan(&date, &count); err != nil {
continue
}
// Convert date string back to timestamp for frontend
t, _ := time.Parse("2006-01-02", date)
// Convert local date string back to local midnight timestamp for frontend
t, err := time.ParseInLocation("2006-01-02", date, time.Local)
if err != nil {
continue
}
history = append(history, map[string]any{
"date": t.UnixMilli(),
"count": count,

View File

@@ -353,8 +353,16 @@
</div>
{:else}
<button
class="flex-1 flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium text-text-secondary hover:bg-bg-secondary transition-colors text-left min-w-0"
onclick={() => toggleCategory(cat.id)}
class="flex-1 flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-colors text-left min-w-0 {newsStore.selectedCategoryId ===
cat.id
? 'bg-accent-blue/10 text-accent-blue font-semibold'
: 'text-text-secondary hover:bg-bg-secondary'}"
onclick={() => {
newsStore.selectCategory(cat.id);
toggleCategory(cat.id);
newsStore.readingArticle = null;
if (!isManageMode) newsStore.showSidebar = false;
}}
title={cat.name}
>
{#if expandedCategories[cat.id]}

View File

@@ -87,7 +87,12 @@ export interface IDB {
saveCategory(category: Category): Promise<void>;
saveCategories(categories: Category[]): Promise<void>;
deleteCategory(id: string): Promise<void>;
getArticles(feedId?: string, offset?: number, limit?: number): Promise<Article[]>;
getArticles(
feedId?: string,
offset?: number,
limit?: number,
categoryId?: string
): Promise<Article[]>;
saveArticles(articles: Article[]): Promise<void>;
searchArticles(query: string, limit?: number): Promise<Article[]>;
getReadingHistory(days?: number): Promise<{ date: number; count: number }[]>;
@@ -249,14 +254,42 @@ class IndexedDBImpl implements IDB {
});
}
async getArticles(feedId?: string, offset = 0, limit = 20): Promise<Article[]> {
async getArticles(
feedId?: string,
offset = 0,
limit = 20,
categoryId?: string
): Promise<Article[]> {
const db = await this.open();
return new Promise((resolve, reject) => {
const transaction = db.transaction('articles', 'readonly');
const transaction = db.transaction(['articles', 'feeds'], 'readonly');
const store = transaction.objectStore('articles');
let request: IDBRequest<any[]>;
if (feedId) request = store.index('feedId').getAll(IDBKeyRange.only(feedId));
else request = store.index('pubDate').getAll();
if (feedId) {
request = store.index('feedId').getAll(IDBKeyRange.only(feedId));
} else if (categoryId) {
// For IndexedDB, we need to get all feeds in category first
const feedStore = transaction.objectStore('feeds');
const feedsRequest = feedStore.getAll();
feedsRequest.onsuccess = () => {
const feeds = feedsRequest.result as Feed[];
const catFeedIds = new Set(
feeds.filter((f) => f.categoryId === categoryId).map((f) => f.id)
);
const allArticlesRequest = store.getAll();
allArticlesRequest.onsuccess = () => {
const articles = allArticlesRequest.result as Article[];
const filtered = articles.filter((a) => catFeedIds.has(a.feedId));
filtered.sort((a, b) => b.pubDate - a.pubDate);
resolve(filtered.slice(offset, offset + limit));
};
};
return;
} else {
request = store.index('pubDate').getAll();
}
request.onsuccess = () => {
const articles = request.result as Article[];
articles.sort((a, b) => b.pubDate - a.pubDate);
@@ -316,7 +349,8 @@ class IndexedDBImpl implements IDB {
if (cursor) {
const article = cursor.value as Article;
if (article.readAt) {
const date = new Date(article.readAt).toISOString().split('T')[0];
const d = new Date(article.readAt);
const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
history[date] = (history[date] || 0) + 1;
}
cursor.continue();
@@ -605,7 +639,12 @@ class CapacitorSQLiteDBImpl implements IDB {
await db.run('DELETE FROM categories WHERE id = ?', [id]);
}
async getArticles(feedId?: string, offset = 0, limit = 20): Promise<Article[]> {
async getArticles(
feedId?: string,
offset = 0,
limit = 20,
categoryId?: string
): Promise<Article[]> {
const db = await this.open();
let res;
if (feedId) {
@@ -613,6 +652,14 @@ class CapacitorSQLiteDBImpl implements IDB {
'SELECT * FROM articles WHERE feedId = ? ORDER BY pubDate DESC LIMIT ? OFFSET ?',
[feedId, limit, offset]
);
} else if (categoryId) {
res = await db.query(
`SELECT a.* FROM articles a
JOIN feeds f ON a.feedId = f.id
WHERE f.categoryId = ?
ORDER BY a.pubDate DESC LIMIT ? OFFSET ?`,
[categoryId, limit, offset]
);
} else {
res = await db.query('SELECT * FROM articles ORDER BY pubDate DESC LIMIT ? OFFSET ?', [
limit,
@@ -662,17 +709,21 @@ class CapacitorSQLiteDBImpl implements IDB {
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
const res = await db.query(
`
SELECT strftime('%Y-%m-%d', datetime(readAt/1000, 'unixepoch')) as date, COUNT(*) as count
SELECT strftime('%Y-%m-%d', datetime(readAt/1000, 'unixepoch', 'localtime')) as date, COUNT(*) as count
FROM articles
WHERE read = 1 AND readAt > ?
GROUP BY date
ORDER BY date DESC`,
[cutoff]
);
return (res.values || []).map((row) => ({
date: new Date(row.date).getTime(),
count: row.count,
}));
return (res.values || []).map((row) => {
const [y, m, d] = row.date.split('-').map(Number);
const date = new Date(y, m - 1, d).getTime();
return {
date,
count: row.count,
};
});
}
async markAsRead(id: string): Promise<void> {
@@ -839,8 +890,15 @@ class WailsDBImpl implements IDB {
JSON.stringify((await this.getCategories()).filter((c) => c.id !== id))
);
}
async getArticles(feedId?: string, offset = 0, limit = 20): Promise<Article[]> {
return JSON.parse(await this.call('GetArticles', feedId || '', offset, limit));
async getArticles(
feedId?: string,
offset = 0,
limit = 20,
categoryId?: string
): Promise<Article[]> {
return JSON.parse(
await this.call('GetArticles', feedId || '', offset, limit, categoryId || '')
);
}
async saveArticles(articles: Article[]): Promise<void> {
await this.call('SaveArticles', JSON.stringify(articles));
@@ -983,8 +1041,8 @@ class LazyDBWrapper implements IDB {
deleteCategory(id: string) {
return this.getImpl().deleteCategory(id);
}
getArticles(feedId?: string, offset?: number, limit?: number) {
return this.getImpl().getArticles(feedId, offset, limit);
getArticles(feedId?: string, offset?: number, limit?: number, categoryId?: string) {
return this.getImpl().getArticles(feedId, offset, limit, categoryId);
}
saveArticles(articles: Article[]) {
return this.getImpl().saveArticles(articles);

View File

@@ -5,13 +5,19 @@ import { registerPlugin } from '@capacitor/core';
const RSS = registerPlugin<any>('RSS');
export async function fetchFeed(
feedUrl: string
feedUrl: string,
signal?: AbortSignal
): Promise<{ feed: Partial<Feed>; articles: Article[] }> {
// Try native RSS fetch first if on mobile to bypass CORS/Bot protection
if (newsStore.isCapacitor) {
try {
// Capacitor plugin might not support signal directly, but we can check it
if (signal?.aborted) throw new Error('Aborted');
const data = await RSS.fetchFeed({ url: feedUrl });
if (signal?.aborted) throw new Error('Aborted');
const articles: Article[] = data.articles.map((item: any) => ({
...item,
description: stripHtml(item.description || '').substring(0, 200),
@@ -25,6 +31,7 @@ export async function fetchFeed(
articles,
};
} catch (e: any) {
if (e.message === 'Aborted') throw e;
console.warn('Native RSS fetch failed, falling back to API proxy:', e);
// Show actual error in toast if it's not a "not implemented" error
if (e.message && !e.message.includes('not implemented')) {
@@ -38,7 +45,10 @@ export async function fetchFeed(
if (newsStore.settings.authToken) {
headers['X-Account-Number'] = newsStore.settings.authToken;
}
const response = await fetch(`${apiBase}/feed?url=${encodeURIComponent(feedUrl)}`, { headers });
const response = await fetch(`${apiBase}/feed?url=${encodeURIComponent(feedUrl)}`, {
headers,
signal,
});
if (response.status === 401) {
newsStore.logout();
throw new Error('Unauthorized');
@@ -93,9 +103,10 @@ function stripHtml(html: string): string {
.trim();
}
export async function refreshAllFeeds() {
export async function refreshAllFeeds(signal?: AbortSignal) {
const feeds = await db.getFeeds();
for (const feed of feeds) {
if (signal?.aborted) throw new Error('Aborted');
if (!feed.enabled) continue;
const now = Date.now();
@@ -103,7 +114,7 @@ export async function refreshAllFeeds() {
if (shouldFetch) {
try {
const { feed: updatedFeed, articles } = await fetchFeed(feed.id);
const { feed: updatedFeed, articles } = await fetchFeed(feed.id, signal);
await db.saveFeed({
...feed,
...updatedFeed,
@@ -112,6 +123,7 @@ export async function refreshAllFeeds() {
});
await db.saveArticles(articles);
} catch (e: any) {
if (e.name === 'AbortError' || e.message === 'Aborted') throw e;
console.error(`Failed to refresh feed ${feed.id}:`, e);
const consecutiveErrors = (feed.consecutiveErrors || 0) + 1;
await db.saveFeed({
@@ -124,13 +136,13 @@ export async function refreshAllFeeds() {
}
}
export async function refreshFeed(feedId: string) {
export async function refreshFeed(feedId: string, signal?: AbortSignal) {
const feeds = await db.getFeeds();
const feed = feeds.find((f) => f.id === feedId);
if (!feed) return;
try {
const { feed: updatedFeed, articles } = await fetchFeed(feed.id);
const { feed: updatedFeed, articles } = await fetchFeed(feed.id, signal);
await db.saveFeed({
...feed,
...updatedFeed,
@@ -139,6 +151,7 @@ export async function refreshFeed(feedId: string) {
});
await db.saveArticles(articles);
} catch (e: any) {
if (e.name === 'AbortError' || e.message === 'Aborted') throw e;
console.error(`Failed to refresh feed ${feed.id}:`, e);
const consecutiveErrors = (feed.consecutiveErrors || 0) + 1;
await db.saveFeed({

View File

@@ -39,6 +39,7 @@ class NewsStore {
isInitialLoading = $state(false);
showSidebar = $state(false);
selectedFeedId = $state<string | null>(null);
selectedCategoryId = $state<string | null>(null);
currentView = $state<'all' | 'saved' | 'following' | 'settings'>('all');
readingArticle = $state<any | null>(null);
searchQuery = $state('');
@@ -46,6 +47,7 @@ class NewsStore {
isSelectMode = $state(false);
selectedArticleIds = $state(new Set<string>());
private limit = 20;
private refreshController: AbortController | null = null;
// Connection status
isOnline = $state(true);
@@ -308,7 +310,12 @@ class NewsStore {
} else if (this.currentView === 'following') {
articles = await db.getArticles(undefined, 0, this.limit);
} else {
articles = await db.getArticles(this.selectedFeedId || undefined, 0, this.limit * 2);
articles = await db.getArticles(
this.selectedFeedId || undefined,
0,
this.limit * 2,
this.selectedCategoryId || undefined
);
}
}
@@ -457,7 +464,12 @@ class NewsStore {
if (this.currentView === 'saved') {
this.hasMore = false;
} else {
more = await db.getArticles(this.selectedFeedId || undefined, offset, this.limit);
more = await db.getArticles(
this.selectedFeedId || undefined,
offset,
this.limit,
this.selectedCategoryId || undefined
);
}
if (this.settings.smartFeed && this.currentView !== 'saved' && !this.selectedFeedId) {
@@ -482,11 +494,20 @@ class NewsStore {
async selectView(view: 'all' | 'saved' | 'following') {
this.currentView = view;
this.selectedFeedId = null;
this.selectedCategoryId = null;
await this.loadArticles();
}
async selectFeed(feedId: string | null) {
this.selectedFeedId = feedId;
this.selectedCategoryId = null;
this.currentView = 'all';
await this.loadArticles();
}
async selectCategory(categoryId: string | null) {
this.selectedCategoryId = categoryId;
this.selectedFeedId = null;
this.currentView = 'all';
await this.loadArticles();
}
@@ -557,10 +578,20 @@ class NewsStore {
}
async refresh() {
if (this.loading && this.refreshController) {
this.refreshController.abort();
this.refreshController = null;
this.loading = false;
toast.info('Refresh cancelled');
return;
}
if (this.loading || !this.isAuthenticated) return;
this.loading = true;
this.refreshController = new AbortController();
try {
await refreshAllFeeds();
await refreshAllFeeds(this.refreshController.signal);
await this.loadArticles();
this.feeds = (await db.getFeeds()) || [];
this.categories = (await db.getCategories()) || [];
@@ -577,7 +608,11 @@ class NewsStore {
}
toast.success('Feeds refreshed');
} catch (e) {
} catch (e: any) {
if (e.name === 'AbortError' || e.message === 'Aborted') {
console.log('Refresh aborted');
return;
}
console.error('Refresh failed:', e);
if (e instanceof Error && e.message.includes('401')) {
this.logout();
@@ -586,22 +621,37 @@ class NewsStore {
}
} finally {
this.loading = false;
this.refreshController = null;
}
}
async refreshFeed(feedId: string) {
if (this.loading && this.refreshController) {
this.refreshController.abort();
this.refreshController = null;
this.loading = false;
toast.info('Refresh cancelled');
return;
}
this.loading = true;
this.refreshController = new AbortController();
try {
await refreshFeed(feedId);
await refreshFeed(feedId, this.refreshController.signal);
this.feeds = await db.getFeeds();
await this.loadArticles();
toast.success('Feed refreshed');
} catch (e) {
} catch (e: any) {
if (e.name === 'AbortError' || e.message === 'Aborted') {
console.log('Refresh aborted');
return;
}
console.error('Feed refresh failed:', e);
toast.error('Failed to refresh feed');
this.feeds = await db.getFeeds();
} finally {
this.loading = false;
this.refreshController = null;
}
}
@@ -674,6 +724,27 @@ class NewsStore {
toast.success('Feed removed');
}
async purgeProblematicFeeds(threshold = 5) {
const problematic = this.feeds.filter((f) => f.consecutiveErrors >= threshold);
if (problematic.length === 0) {
toast.info('No problematic feeds found');
return;
}
if (
!confirm(
`Are you sure you want to remove ${problematic.length} feeds that have failed ${threshold}+ times?`
)
)
return;
for (const feed of problematic) {
await db.deleteFeed(feed.id);
}
this.feeds = this.feeds.filter((f) => f.consecutiveErrors < threshold);
toast.success(`Removed ${problematic.length} problematic feeds`);
}
async updateFeed(feed: Feed, oldId?: string) {
const plainFeed = $state.snapshot(feed);
if (oldId && oldId !== feed.id) {

View File

@@ -39,6 +39,8 @@
let fontSize = $state(newsStore.settings.fontSize);
let lineHeight = $state(newsStore.settings.lineHeight);
let contentPurgeDays = $state(newsStore.settings.contentPurgeDays);
let muteFilters = $state<string[]>([]);
let errorThreshold = $state(5);
$effect(() => {
theme = newsStore.settings.theme;
@@ -51,6 +53,7 @@
fontSize = newsStore.settings.fontSize;
lineHeight = newsStore.settings.lineHeight;
contentPurgeDays = newsStore.settings.contentPurgeDays;
muteFilters = [...newsStore.settings.muteFilters];
});
async function handleSaveSettings() {
@@ -66,7 +69,8 @@
fontSize,
lineHeight,
contentPurgeDays,
// muteFilters and shortcuts are already updated in newsStore.settings via UI binding
muteFilters: [...muteFilters],
// shortcuts are already updated in newsStore.settings via UI binding
};
newsStore.settings = newSettings;
@@ -709,6 +713,43 @@
</div>
</section>
<!-- Feed Health -->
<section id="feed-health" class="space-y-4 w-full min-w-0">
<h3
class="text-sm font-semibold text-text-secondary uppercase tracking-wider flex items-center gap-2"
>
<Zap size={16} />
Feed Health
</h3>
<div class="card p-4 sm:p-6 space-y-6">
<div class="space-y-3">
<div class="flex justify-between text-sm">
<span class="text-text-secondary"
>Purge feeds with consecutive errors ≥</span
>
<span class="font-medium text-accent-blue">{errorThreshold} failures</span
>
</div>
<input
type="range"
min="1"
max="20"
step="1"
bind:value={errorThreshold}
class="w-full h-1.5 bg-bg-secondary rounded-lg appearance-none cursor-pointer accent-accent-blue"
aria-label="Error threshold"
/>
</div>
<button
class="w-full py-2.5 bg-red-500/10 hover:bg-red-500/20 text-red-500 rounded-xl text-xs font-semibold transition-colors flex items-center justify-center gap-2"
onclick={() => newsStore.purgeProblematicFeeds(errorThreshold)}
>
<Trash2 size={14} />
Purge All Problematic Feeds
</button>
</div>
</section>
<!-- Mute Filters -->
<section id="mute-filters" class="space-y-4 w-full min-w-0">
<h3
@@ -731,10 +772,7 @@
class="flex-1 bg-bg-secondary border border-border-color rounded-xl px-4 py-2 outline-none focus:ring-2 focus:ring-accent-blue/20"
onkeydown={(e) => {
if (e.key === 'Enter' && newFilter) {
newsStore.settings.muteFilters = [
...newsStore.settings.muteFilters,
newFilter,
];
muteFilters = [...muteFilters, newFilter];
newFilter = '';
}
}}
@@ -743,10 +781,7 @@
class="btn-primary px-4 py-2 rounded-xl whitespace-nowrap"
onclick={() => {
if (newFilter) {
newsStore.settings.muteFilters = [
...newsStore.settings.muteFilters,
newFilter,
];
muteFilters = [...muteFilters, newFilter];
newFilter = '';
}
}}
@@ -757,7 +792,7 @@
</div>
<div class="flex flex-wrap gap-2 pt-2">
{#each newsStore.settings.muteFilters as filter}
{#each muteFilters as filter}
<span
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-bg-secondary border border-border-color text-xs font-medium"
>
@@ -765,8 +800,7 @@
<button
class="text-text-secondary hover:text-red-500 transition-colors"
onclick={() =>
(newsStore.settings.muteFilters =
newsStore.settings.muteFilters.filter((f) => f !== filter))}
(muteFilters = muteFilters.filter((f) => f !== filter))}
>
<X size={12} />
</button>
@@ -835,12 +869,14 @@
{#each Array(30)
.fill(0)
.map((_, i) => {
const date = new Date();
date.setDate(date.getDate() - (29 - i));
const dStr = date.toISOString().split('T')[0];
const entry = readingHistory.find((h) => new Date(h.date)
.toISOString()
.split('T')[0] === dStr);
const d = new Date();
d.setDate(d.getDate() - (29 - i));
const dStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const entry = readingHistory.find((h) => {
const hd = new Date(h.date);
const hStr = `${hd.getFullYear()}-${String(hd.getMonth() + 1).padStart(2, '0')}-${String(hd.getDate()).padStart(2, '0')}`;
return hStr === dStr;
});
return { date: dStr, count: entry?.count || 0 };
}) as day}
<div
@@ -1117,6 +1153,11 @@
>{feed?.id}</span
>
</div>
{:else if newsStore.selectedCategoryId}
{@const cat = newsStore.categories.find(
(c) => c.id === newsStore.selectedCategoryId
)}
<span>{cat?.name}</span>
{:else if newsStore.currentView === 'saved'}
Saved stories
{:else if newsStore.currentView === 'following'}