Refactor code for improved readability and consistency
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 11s
CI / build-frontend (push) Successful in 1m29s
CI / build-backend (push) Successful in 26s

- Cleaned up formatting in app.css and db.ts by removing unnecessary blank lines and ensuring consistent indentation.
- Enhanced readability in various files by adding missing semicolons and adjusting line breaks for better clarity.
- Updated function signatures and object definitions for improved type consistency in TypeScript files.
This commit is contained in:
2025-12-27 12:26:57 -06:00
parent c5176f9ed4
commit 3f62b36de5
10 changed files with 1566 additions and 974 deletions

View File

@@ -53,7 +53,7 @@
.card {
@apply bg-bg-primary border border-border-color rounded-lg overflow-hidden transition-all hover:shadow-md;
}
.btn-primary {
@apply bg-accent-blue text-white px-4 py-2 rounded-md font-medium hover:bg-accent-blue-dark transition-colors;
}

View File

@@ -1,5 +1,9 @@
import { Capacitor } from '@capacitor/core';
import { SQLiteConnection, type SQLiteDBConnection, CapacitorSQLite } from '@capacitor-community/sqlite';
import {
SQLiteConnection,
type SQLiteDBConnection,
CapacitorSQLite,
} from '@capacitor-community/sqlite';
export interface Category {
id: string;
@@ -86,7 +90,7 @@ export interface IDB {
getArticles(feedId?: string, offset?: number, limit?: number): Promise<Article[]>;
saveArticles(articles: Article[]): Promise<void>;
searchArticles(query: string, limit?: number): Promise<Article[]>;
getReadingHistory(days?: number): Promise<{ date: number, count: number }[]>;
getReadingHistory(days?: number): Promise<{ date: number; count: number }[]>;
markAsRead(id: string): Promise<void>;
bulkMarkRead(ids: string[]): Promise<void>;
bulkDelete(ids: string[]): Promise<void>;
@@ -122,14 +126,23 @@ class IndexedDBImpl implements IDB {
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
const transaction = (event.target as IDBOpenDBRequest).transaction!;
if (!db.objectStoreNames.contains('feeds')) db.createObjectStore('feeds', { keyPath: 'id' });
if (!db.objectStoreNames.contains('categories')) db.createObjectStore('categories', { keyPath: 'id' });
if (!db.objectStoreNames.contains('settings')) db.createObjectStore('settings', { keyPath: 'id' });
let articleStore: IDBObjectStore = !db.objectStoreNames.contains('articles') ? db.createObjectStore('articles', { keyPath: 'id' }) : transaction.objectStore('articles');
if (!articleStore.indexNames.contains('feedId')) articleStore.createIndex('feedId', 'feedId', { unique: false });
if (!articleStore.indexNames.contains('pubDate')) articleStore.createIndex('pubDate', 'pubDate', { unique: false });
if (!articleStore.indexNames.contains('saved')) articleStore.createIndex('saved', 'saved', { unique: false });
if (!articleStore.indexNames.contains('readAt')) articleStore.createIndex('readAt', 'readAt', { unique: false });
if (!db.objectStoreNames.contains('feeds'))
db.createObjectStore('feeds', { keyPath: 'id' });
if (!db.objectStoreNames.contains('categories'))
db.createObjectStore('categories', { keyPath: 'id' });
if (!db.objectStoreNames.contains('settings'))
db.createObjectStore('settings', { keyPath: 'id' });
let articleStore: IDBObjectStore = !db.objectStoreNames.contains('articles')
? db.createObjectStore('articles', { keyPath: 'id' })
: transaction.objectStore('articles');
if (!articleStore.indexNames.contains('feedId'))
articleStore.createIndex('feedId', 'feedId', { unique: false });
if (!articleStore.indexNames.contains('pubDate'))
articleStore.createIndex('pubDate', 'pubDate', { unique: false });
if (!articleStore.indexNames.contains('saved'))
articleStore.createIndex('saved', 'saved', { unique: false });
if (!articleStore.indexNames.contains('readAt'))
articleStore.createIndex('readAt', 'readAt', { unique: false });
};
});
}
@@ -159,7 +172,7 @@ class IndexedDBImpl implements IDB {
return new Promise((resolve, reject) => {
const transaction = db.transaction('feeds', 'readwrite');
const store = transaction.objectStore('feeds');
feeds.forEach(f => store.put(f));
feeds.forEach((f) => store.put(f));
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
@@ -174,7 +187,10 @@ class IndexedDBImpl implements IDB {
const request = articleStore.index('feedId').openKeyCursor(IDBKeyRange.only(id));
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursor>).result;
if (cursor) { articleStore.delete(cursor.primaryKey); cursor.continue(); }
if (cursor) {
articleStore.delete(cursor.primaryKey);
cursor.continue();
}
};
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
@@ -206,7 +222,7 @@ class IndexedDBImpl implements IDB {
return new Promise((resolve, reject) => {
const transaction = db.transaction('categories', 'readwrite');
const store = transaction.objectStore('categories');
categories.forEach(c => store.put(c));
categories.forEach((c) => store.put(c));
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
@@ -221,7 +237,12 @@ class IndexedDBImpl implements IDB {
const request = feedStore.getAll();
request.onsuccess = () => {
const feeds = request.result as Feed[];
feeds.forEach(f => { if (f.categoryId === id) { f.categoryId = 'uncategorized'; feedStore.put(f); } });
feeds.forEach((f) => {
if (f.categoryId === id) {
f.categoryId = 'uncategorized';
feedStore.put(f);
}
});
};
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
@@ -250,7 +271,7 @@ class IndexedDBImpl implements IDB {
return new Promise((resolve, reject) => {
const transaction = db.transaction('articles', 'readwrite');
const store = transaction.objectStore('articles');
articles.forEach(article => store.put(article));
articles.forEach((article) => store.put(article));
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
@@ -268,7 +289,11 @@ class IndexedDBImpl implements IDB {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
if (cursor && results.length < limit) {
const article = cursor.value as Article;
if (article.title.toLowerCase().includes(lowQuery) || article.description.toLowerCase().includes(lowQuery) || (article.content && article.content.toLowerCase().includes(lowQuery))) {
if (
article.title.toLowerCase().includes(lowQuery) ||
article.description.toLowerCase().includes(lowQuery) ||
(article.content && article.content.toLowerCase().includes(lowQuery))
) {
results.push(article);
}
cursor.continue();
@@ -278,12 +303,12 @@ class IndexedDBImpl implements IDB {
});
}
async getReadingHistory(days = 30): Promise<{ date: number, count: number }[]> {
async getReadingHistory(days = 30): Promise<{ date: number; count: number }[]> {
const db = await this.open();
return new Promise((resolve, reject) => {
const transaction = db.transaction('articles', 'readonly');
const index = transaction.objectStore('articles').index('readAt');
const startTime = Date.now() - (days * 24 * 60 * 60 * 1000);
const startTime = Date.now() - days * 24 * 60 * 60 * 1000;
const request = index.openCursor(IDBKeyRange.lowerBound(startTime));
const history: Record<string, number> = {};
request.onsuccess = (event) => {
@@ -296,7 +321,12 @@ class IndexedDBImpl implements IDB {
}
cursor.continue();
} else {
resolve(Object.entries(history).map(([date, count]) => ({ date: new Date(date).getTime(), count })));
resolve(
Object.entries(history).map(([date, count]) => ({
date: new Date(date).getTime(),
count,
}))
);
}
};
request.onerror = () => reject(request.error);
@@ -327,7 +357,11 @@ class IndexedDBImpl implements IDB {
const request = store.get(id);
request.onsuccess = () => {
const article = request.result as Article;
if (article && !article.read) { article.read = true; article.readAt = now; store.put(article); }
if (article && !article.read) {
article.read = true;
article.readAt = now;
store.put(article);
}
};
}
}
@@ -347,7 +381,10 @@ class IndexedDBImpl implements IDB {
const request = store.get(id);
request.onsuccess = () => {
const article = request.result as Article;
if (article) { article.saved = !article.saved; store.put({ ...article, saved: article.saved ? 1 : 0 }); }
if (article) {
article.saved = !article.saved;
store.put({ ...article, saved: article.saved ? 1 : 0 });
}
};
}
}
@@ -357,14 +394,18 @@ class IndexedDBImpl implements IDB {
return new Promise((resolve, reject) => {
const transaction = db.transaction('articles', 'readwrite');
const store = transaction.objectStore('articles');
const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000);
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
const request = store.openCursor();
let count = 0;
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
if (cursor) {
const article = cursor.value as Article;
if (article.content && !article.saved && article.pubDate < cutoff) { delete article.content; cursor.update(article); count++; }
if (article.content && !article.saved && article.pubDate < cutoff) {
delete article.content;
cursor.update(article);
count++;
}
cursor.continue();
} else resolve(count);
};
@@ -379,7 +420,10 @@ class IndexedDBImpl implements IDB {
const request = store.get(id);
request.onsuccess = () => {
const article = request.result as Article;
if (article) { article.content = content; store.put(article); }
if (article) {
article.content = content;
store.put(article);
}
};
}
@@ -391,8 +435,11 @@ class IndexedDBImpl implements IDB {
const request = store.get(id);
request.onsuccess = () => {
const article = request.result as Article;
if (article) { article.saved = !article.saved; store.put({ ...article, saved: article.saved ? 1 : 0 }); resolve(article.saved); }
else resolve(false);
if (article) {
article.saved = !article.saved;
store.put({ ...article, saved: article.saved ? 1 : 0 });
resolve(article.saved);
} else resolve(false);
};
request.onerror = () => reject(request.error);
});
@@ -402,7 +449,10 @@ class IndexedDBImpl implements IDB {
const db = await this.open();
return new Promise((resolve, reject) => {
const transaction = db.transaction('articles', 'readonly');
const request = transaction.objectStore('articles').index('saved').getAll(IDBKeyRange.only(1));
const request = transaction
.objectStore('articles')
.index('saved')
.getAll(IDBKeyRange.only(1));
request.onsuccess = () => {
const articles = request.result as Article[];
articles.sort((a, b) => b.pubDate - a.pubDate);
@@ -419,9 +469,21 @@ class IndexedDBImpl implements IDB {
const request = transaction.objectStore('settings').get('main');
request.onsuccess = () => {
const defaults: Settings = {
theme: 'system', globalFetchInterval: 30, autoFetch: true, apiBaseUrl: '/api', smartFeed: false, readingMode: 'inline', paneWidth: 40, fontFamily: 'sans', fontSize: 18, lineHeight: 1.6, contentPurgeDays: 30, authToken: null, muteFilters: [],
theme: 'system',
globalFetchInterval: 30,
autoFetch: true,
apiBaseUrl: '/api',
smartFeed: false,
readingMode: 'inline',
paneWidth: 40,
fontFamily: 'sans',
fontSize: 18,
lineHeight: 1.6,
contentPurgeDays: 30,
authToken: null,
muteFilters: [],
shortcuts: { next: 'j', prev: 'k', save: 's', read: 'r', open: 'o', toggleSelect: 'x' },
relevanceProfile: { categoryScores: {}, feedScores: {}, totalInteractions: 0 }
relevanceProfile: { categoryScores: {}, feedScores: {}, totalInteractions: 0 },
};
resolve({ ...defaults, ...(request.result || {}) });
};
@@ -441,8 +503,14 @@ class IndexedDBImpl implements IDB {
async clearAll(): Promise<void> {
const db = await this.open();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['feeds', 'categories', 'articles', 'settings'], 'readwrite');
transaction.objectStore('feeds').clear(); transaction.objectStore('categories').clear(); transaction.objectStore('articles').clear(); transaction.objectStore('settings').clear();
const transaction = db.transaction(
['feeds', 'categories', 'articles', 'settings'],
'readwrite'
);
transaction.objectStore('feeds').clear();
transaction.objectStore('categories').clear();
transaction.objectStore('articles').clear();
transaction.objectStore('settings').clear();
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
@@ -456,16 +524,16 @@ class CapacitorSQLiteDBImpl implements IDB {
async open(): Promise<SQLiteDBConnection> {
if (this.db) return this.db;
if (!this.sqlite) this.sqlite = new SQLiteConnection(CapacitorSQLite);
const ret = await this.sqlite.checkConnectionsConsistency();
const isConn = (await this.sqlite.isConnection(DB_NAME, false)).result;
if (ret.result && isConn) {
this.db = await this.sqlite.retrieveConnection(DB_NAME, false);
} else {
this.db = await this.sqlite.createConnection(DB_NAME, false, "no-encryption", 1, false);
this.db = await this.sqlite.createConnection(DB_NAME, false, 'no-encryption', 1, false);
}
await this.db.open();
const queries = [
@@ -474,7 +542,7 @@ class CapacitorSQLiteDBImpl implements IDB {
`CREATE TABLE IF NOT EXISTS articles (id TEXT PRIMARY KEY, feedId TEXT, title TEXT, link TEXT, description TEXT, content TEXT, author TEXT, pubDate INTEGER, read INTEGER, saved INTEGER, imageUrl TEXT, readAt INTEGER);`,
`CREATE INDEX IF NOT EXISTS idx_articles_pubDate ON articles(pubDate);`,
`CREATE INDEX IF NOT EXISTS idx_articles_readAt ON articles(readAt);`,
`CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT);`
`CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT);`,
];
for (const q of queries) {
@@ -487,16 +555,20 @@ class CapacitorSQLiteDBImpl implements IDB {
async getFeeds(): Promise<Feed[]> {
const db = await this.open();
const res = await db.query('SELECT * FROM feeds ORDER BY "order" ASC');
return (res.values || []).map(f => ({ ...f, enabled: f.enabled === 1 }));
return (res.values || []).map((f) => ({ ...f, enabled: f.enabled === 1 }));
}
async saveFeed(feed: Feed): Promise<void> { await this.saveFeeds([feed]); }
async saveFeed(feed: Feed): Promise<void> {
await this.saveFeeds([feed]);
}
async saveFeeds(feeds: Feed[]): Promise<void> {
const db = await this.open();
for (const f of feeds) {
await db.run('INSERT OR REPLACE INTO feeds (id, title, categoryId, "order", enabled, fetchInterval) VALUES (?, ?, ?, ?, ?, ?)',
[f.id, f.title, f.categoryId, f.order, f.enabled ? 1 : 0, f.fetchInterval]);
await db.run(
'INSERT OR REPLACE INTO feeds (id, title, categoryId, "order", enabled, fetchInterval) VALUES (?, ?, ?, ?, ?, ?)',
[f.id, f.title, f.categoryId, f.order, f.enabled ? 1 : 0, f.fetchInterval]
);
}
}
@@ -512,12 +584,18 @@ class CapacitorSQLiteDBImpl implements IDB {
return res.values || [];
}
async saveCategory(category: Category): Promise<void> { await this.saveCategories([category]); }
async saveCategory(category: Category): Promise<void> {
await this.saveCategories([category]);
}
async saveCategories(categories: Category[]): Promise<void> {
const db = await this.open();
for (const c of categories) {
await db.run('INSERT OR REPLACE INTO categories (id, name, "order") VALUES (?, ?, ?)', [c.id, c.name, c.order]);
await db.run('INSERT OR REPLACE INTO categories (id, name, "order") VALUES (?, ?, ?)', [
c.id,
c.name,
c.order,
]);
}
}
@@ -531,48 +609,78 @@ class CapacitorSQLiteDBImpl implements IDB {
const db = await this.open();
let res;
if (feedId) {
res = await db.query('SELECT * FROM articles WHERE feedId = ? ORDER BY pubDate DESC LIMIT ? OFFSET ?', [feedId, limit, offset]);
res = await db.query(
'SELECT * FROM articles WHERE feedId = ? ORDER BY pubDate DESC LIMIT ? OFFSET ?',
[feedId, limit, offset]
);
} else {
res = await db.query('SELECT * FROM articles ORDER BY pubDate DESC LIMIT ? OFFSET ?', [limit, offset]);
res = await db.query('SELECT * FROM articles ORDER BY pubDate DESC LIMIT ? OFFSET ?', [
limit,
offset,
]);
}
return (res.values || []).map(a => ({ ...a, read: a.read === 1, saved: a.saved === 1 }));
return (res.values || []).map((a) => ({ ...a, read: a.read === 1, saved: a.saved === 1 }));
}
async saveArticles(articles: Article[]): Promise<void> {
const db = await this.open();
for (const a of articles) {
await db.run(`INSERT OR REPLACE INTO articles
await db.run(
`INSERT OR REPLACE INTO articles
(id, feedId, title, link, description, content, author, pubDate, read, saved, imageUrl, readAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[a.id, a.feedId, a.title, a.link, a.description, a.content, a.author, a.pubDate, a.read ? 1 : 0, a.saved ? 1 : 0, a.imageUrl, a.readAt]);
[
a.id,
a.feedId,
a.title,
a.link,
a.description,
a.content,
a.author,
a.pubDate,
a.read ? 1 : 0,
a.saved ? 1 : 0,
a.imageUrl,
a.readAt,
]
);
}
}
async searchArticles(query: string, limit = 50): Promise<Article[]> {
const db = await this.open();
const q = `%${query}%`;
const res = await db.query(`SELECT * FROM articles WHERE title LIKE ? OR description LIKE ? OR content LIKE ? ORDER BY pubDate DESC LIMIT ?`, [q, q, q, limit]);
return (res.values || []).map(a => ({ ...a, read: a.read === 1, saved: a.saved === 1 }));
const res = await db.query(
`SELECT * FROM articles WHERE title LIKE ? OR description LIKE ? OR content LIKE ? ORDER BY pubDate DESC LIMIT ?`,
[q, q, q, limit]
);
return (res.values || []).map((a) => ({ ...a, read: a.read === 1, saved: a.saved === 1 }));
}
async getReadingHistory(days = 30): Promise<{ date: number, count: number }[]> {
async getReadingHistory(days = 30): Promise<{ date: number; count: number }[]> {
const db = await this.open();
const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000);
const res = await db.query(`
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
FROM articles
WHERE read = 1 AND readAt > ?
GROUP BY date
ORDER BY date DESC`, [cutoff]);
return (res.values || []).map(row => ({
ORDER BY date DESC`,
[cutoff]
);
return (res.values || []).map((row) => ({
date: new Date(row.date).getTime(),
count: row.count
count: row.count,
}));
}
async markAsRead(id: string): Promise<void> {
const db = await this.open();
await db.run('UPDATE articles SET read = 1, readAt = ? WHERE id = ? AND read = 0', [Date.now(), id]);
await db.run('UPDATE articles SET read = 1, readAt = ? WHERE id = ? AND read = 0', [
Date.now(),
id,
]);
}
async bulkMarkRead(ids: string[]): Promise<void> {
@@ -591,14 +699,19 @@ class CapacitorSQLiteDBImpl implements IDB {
async bulkToggleSave(ids: string[]): Promise<void> {
const db = await this.open();
for (const id of ids) {
await db.run('UPDATE articles SET saved = CASE WHEN saved = 1 THEN 0 ELSE 1 END WHERE id = ?', [id]);
await db.run(
'UPDATE articles SET saved = CASE WHEN saved = 1 THEN 0 ELSE 1 END WHERE id = ?',
[id]
);
}
}
async purgeOldContent(days: number): Promise<number> {
const db = await this.open();
const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000);
const res = await db.run('UPDATE articles SET content = NULL WHERE saved = 0 AND pubDate < ?', [cutoff]);
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
const res = await db.run('UPDATE articles SET content = NULL WHERE saved = 0 AND pubDate < ?', [
cutoff,
]);
return res.changes?.changes || 0;
}
@@ -609,7 +722,9 @@ class CapacitorSQLiteDBImpl implements IDB {
async toggleSave(id: string): Promise<boolean> {
const db = await this.open();
await db.run('UPDATE articles SET saved = CASE WHEN saved = 1 THEN 0 ELSE 1 END WHERE id = ?', [id]);
await db.run('UPDATE articles SET saved = CASE WHEN saved = 1 THEN 0 ELSE 1 END WHERE id = ?', [
id,
]);
const res = await db.query('SELECT saved FROM articles WHERE id = ?', [id]);
return res.values?.[0]?.saved === 1;
}
@@ -617,16 +732,28 @@ class CapacitorSQLiteDBImpl implements IDB {
async getSavedArticles(): Promise<Article[]> {
const db = await this.open();
const res = await db.query('SELECT * FROM articles WHERE saved = 1 ORDER BY pubDate DESC');
return (res.values || []).map(a => ({ ...a, read: a.read === 1, saved: a.saved === 1 }));
return (res.values || []).map((a) => ({ ...a, read: a.read === 1, saved: a.saved === 1 }));
}
async getSettings(): Promise<Settings> {
const db = await this.open();
const res = await db.query("SELECT value FROM settings WHERE key = 'main'");
const defaults: Settings = {
theme: 'system', globalFetchInterval: 30, autoFetch: true, apiBaseUrl: '/api', smartFeed: false, readingMode: 'inline', paneWidth: 40, fontFamily: 'sans', fontSize: 18, lineHeight: 1.6, contentPurgeDays: 30, authToken: null, muteFilters: [],
theme: 'system',
globalFetchInterval: 30,
autoFetch: true,
apiBaseUrl: '/api',
smartFeed: false,
readingMode: 'inline',
paneWidth: 40,
fontFamily: 'sans',
fontSize: 18,
lineHeight: 1.6,
contentPurgeDays: 30,
authToken: null,
muteFilters: [],
shortcuts: { next: 'j', prev: 'k', save: 's', read: 'r', open: 'o', toggleSelect: 'x' },
relevanceProfile: { categoryScores: {}, feedScores: {}, totalInteractions: 0 }
relevanceProfile: { categoryScores: {}, feedScores: {}, totalInteractions: 0 },
};
if (res.values && res.values.length > 0) {
return { ...defaults, ...JSON.parse(res.values[0].value) };
@@ -636,7 +763,9 @@ class CapacitorSQLiteDBImpl implements IDB {
async saveSettings(settings: Settings): Promise<void> {
const db = await this.open();
await db.run("INSERT OR REPLACE INTO settings (key, value) VALUES ('main', ?)", [JSON.stringify(settings)]);
await db.run("INSERT OR REPLACE INTO settings (key, value) VALUES ('main', ?)", [
JSON.stringify(settings),
]);
}
async clearAll(): Promise<void> {
@@ -656,7 +785,7 @@ class CapacitorSQLiteDBImpl implements IDB {
path: 'Native SQLite',
articles: artRes.values?.[0]?.count || 0,
feeds: feedRes.values?.[0]?.count || 0,
walEnabled: true
walEnabled: true,
};
}
}
@@ -665,78 +794,143 @@ class WailsDBImpl implements IDB {
private async call<T>(method: string, ...args: any[]): Promise<T> {
const app = (window as any).go?.main?.App;
if (!app || !app[method]) throw new Error(`Wails method ${method} not found`);
// Add a 5 second timeout to all Wails calls to prevent infinite hangs
return Promise.race([
app[method](...args),
new Promise((_, reject) =>
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Wails call ${method} timed out after 5s`)), 5000)
)
),
]) as Promise<T>;
}
async getFeeds(): Promise<Feed[]> { return JSON.parse(await this.call('GetFeeds')); }
async saveFeed(feed: Feed): Promise<void> { await this.saveFeeds([feed]); }
async saveFeeds(feeds: Feed[]): Promise<void> { await this.call('SaveFeeds', JSON.stringify(feeds)); }
async deleteFeed(id: string): Promise<void> { await this.call('DeleteFeed', id); }
async getCategories(): Promise<Category[]> { return JSON.parse(await this.call('GetCategories')); }
async saveCategory(category: Category): Promise<void> { await this.saveCategories([category]); }
async saveCategories(categories: Category[]): Promise<void> { await this.call('SaveCategories', JSON.stringify(categories)); }
async getFeeds(): Promise<Feed[]> {
return JSON.parse(await this.call('GetFeeds'));
}
async saveFeed(feed: Feed): Promise<void> {
await this.saveFeeds([feed]);
}
async saveFeeds(feeds: Feed[]): Promise<void> {
await this.call('SaveFeeds', JSON.stringify(feeds));
}
async deleteFeed(id: string): Promise<void> {
await this.call('DeleteFeed', id);
}
async getCategories(): Promise<Category[]> {
return JSON.parse(await this.call('GetCategories'));
}
async saveCategory(category: Category): Promise<void> {
await this.saveCategories([category]);
}
async saveCategories(categories: Category[]): Promise<void> {
await this.call('SaveCategories', JSON.stringify(categories));
}
async deleteCategory(id: string): Promise<void> {
const feeds = await this.getFeeds();
for (const f of feeds) { if (f.categoryId === id) { f.categoryId = 'uncategorized'; await this.saveFeed(f); } }
for (const f of feeds) {
if (f.categoryId === id) {
f.categoryId = 'uncategorized';
await this.saveFeed(f);
}
}
// Wait for feed updates then delete cat
await this.call('SaveCategories', JSON.stringify((await this.getCategories()).filter(c => c.id !== id)));
await this.call(
'SaveCategories',
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 saveArticles(articles: Article[]): Promise<void> { await this.call('SaveArticles', JSON.stringify(articles)); }
async searchArticles(query: string, limit = 50): Promise<Article[]> { return JSON.parse(await this.call('SearchArticles', query, limit)); }
async getReadingHistory(days = 30): Promise<{ date: number, count: number }[]> {
async getArticles(feedId?: string, offset = 0, limit = 20): Promise<Article[]> {
return JSON.parse(await this.call('GetArticles', feedId || '', offset, limit));
}
async saveArticles(articles: Article[]): Promise<void> {
await this.call('SaveArticles', JSON.stringify(articles));
}
async searchArticles(query: string, limit = 50): Promise<Article[]> {
return JSON.parse(await this.call('SearchArticles', query, limit));
}
async getReadingHistory(days = 30): Promise<{ date: number; count: number }[]> {
return JSON.parse(await this.call('GetReadingHistory', days));
}
async markAsRead(id: string): Promise<void> {
await this.call('MarkAsRead', id);
}
async bulkMarkRead(ids: string[]): Promise<void> { for (const id of ids) await this.markAsRead(id); }
async bulkDelete(_ids: string[]): Promise<void> { /* Not directly in SQLite bridge yet, could add */ }
async bulkToggleSave(ids: string[]): Promise<void> { for (const id of ids) await this.toggleSave(id); }
async purgeOldContent(days: number): Promise<number> { return Number(await this.call('PurgeOldContent', days)); }
async bulkMarkRead(ids: string[]): Promise<void> {
for (const id of ids) await this.markAsRead(id);
}
async bulkDelete(_ids: string[]): Promise<void> {
/* Not directly in SQLite bridge yet, could add */
}
async bulkToggleSave(ids: string[]): Promise<void> {
for (const id of ids) await this.toggleSave(id);
}
async purgeOldContent(days: number): Promise<number> {
return Number(await this.call('PurgeOldContent', days));
}
async updateArticleContent(id: string, content: string): Promise<void> {
const articles = await this.getArticles('', 0, 1000);
const a = articles.find(art => art.id === id);
if (a) { a.content = content; await this.call('UpdateArticle', JSON.stringify(a)); }
const a = articles.find((art) => art.id === id);
if (a) {
a.content = content;
await this.call('UpdateArticle', JSON.stringify(a));
}
}
async toggleSave(id: string): Promise<boolean> {
const articles = await this.getArticles('', 0, 1000);
const a = articles.find(art => art.id === id);
if (a) { a.saved = !a.saved; await this.call('UpdateArticle', JSON.stringify(a)); return a.saved; }
const a = articles.find((art) => art.id === id);
if (a) {
a.saved = !a.saved;
await this.call('UpdateArticle', JSON.stringify(a));
return a.saved;
}
return false;
}
async getSavedArticles(): Promise<Article[]> {
const articles = await this.getArticles('', 0, 5000);
return articles.filter(a => a.saved).sort((a, b) => b.pubDate - a.pubDate);
return articles.filter((a) => a.saved).sort((a, b) => b.pubDate - a.pubDate);
}
async getSettings(): Promise<Settings> {
const defaults: Settings = {
theme: 'system', globalFetchInterval: 30, autoFetch: true, apiBaseUrl: '/api', smartFeed: false, readingMode: 'inline', paneWidth: 40, fontFamily: 'sans', fontSize: 18, lineHeight: 1.6, contentPurgeDays: 30, authToken: null, muteFilters: [],
theme: 'system',
globalFetchInterval: 30,
autoFetch: true,
apiBaseUrl: '/api',
smartFeed: false,
readingMode: 'inline',
paneWidth: 40,
fontFamily: 'sans',
fontSize: 18,
lineHeight: 1.6,
contentPurgeDays: 30,
authToken: null,
muteFilters: [],
shortcuts: { next: 'j', prev: 'k', save: 's', read: 'r', open: 'o', toggleSelect: 'x' },
relevanceProfile: { categoryScores: {}, feedScores: {}, totalInteractions: 0 }
relevanceProfile: { categoryScores: {}, feedScores: {}, totalInteractions: 0 },
};
const saved = await this.call<string>('GetSettings');
if (!saved) return defaults; // Handle empty string case
try {
return { ...defaults, ...JSON.parse(saved) };
} catch (e) {
console.error("Failed to parse settings", e);
console.error('Failed to parse settings', e);
return defaults;
}
}
async saveSettings(settings: Settings): Promise<void> { await this.call('SaveSettings', JSON.stringify(settings)); }
async clearAll(): Promise<void> { await this.call('ClearAll'); }
async getStats(): Promise<DBStats> { return await this.call('GetDBStats'); }
async vacuum(): Promise<void> { await this.call('VacuumDB'); }
async integrityCheck(): Promise<string> { return await this.call('CheckDBIntegrity'); }
async saveSettings(settings: Settings): Promise<void> {
await this.call('SaveSettings', JSON.stringify(settings));
}
async clearAll(): Promise<void> {
await this.call('ClearAll');
}
async getStats(): Promise<DBStats> {
return await this.call('GetDBStats');
}
async vacuum(): Promise<void> {
await this.call('VacuumDB');
}
async integrityCheck(): Promise<string> {
return await this.call('CheckDBIntegrity');
}
}
// LazyDBWrapper allows deciding which implementation to use at runtime (lazily),
@@ -746,9 +940,10 @@ class LazyDBWrapper implements IDB {
private getImpl(): IDB {
if (this.impl) return this.impl;
const isWails = typeof window !== 'undefined' && ((window as any).runtime || (window as any).go);
const isCapacitor = typeof window !== 'undefined' && (Capacitor.isNativePlatform());
const isWails =
typeof window !== 'undefined' && ((window as any).runtime || (window as any).go);
const isCapacitor = typeof window !== 'undefined' && Capacitor.isNativePlatform();
if (isWails) {
this.impl = new WailsDBImpl();
@@ -758,40 +953,96 @@ class LazyDBWrapper implements IDB {
this.impl = new IndexedDBImpl();
}
console.log(`DB Initialized using: ${isWails ? 'Wails (SQLite)' : isCapacitor ? 'Capacitor (SQLite)' : 'Browser (IndexedDB)'}`);
console.log(
`DB Initialized using: ${isWails ? 'Wails (SQLite)' : isCapacitor ? 'Capacitor (SQLite)' : 'Browser (IndexedDB)'}`
);
return this.impl;
}
getFeeds() { return this.getImpl().getFeeds(); }
saveFeed(feed: Feed) { return this.getImpl().saveFeed(feed); }
saveFeeds(feeds: Feed[]) { return this.getImpl().saveFeeds(feeds); }
deleteFeed(id: string) { return this.getImpl().deleteFeed(id); }
getCategories() { return this.getImpl().getCategories(); }
saveCategory(category: Category) { return this.getImpl().saveCategory(category); }
saveCategories(categories: Category[]) { return this.getImpl().saveCategories(categories); }
deleteCategory(id: string) { return this.getImpl().deleteCategory(id); }
getArticles(feedId?: string, offset?: number, limit?: number) { return this.getImpl().getArticles(feedId, offset, limit); }
saveArticles(articles: Article[]) { return this.getImpl().saveArticles(articles); }
searchArticles(query: string, limit?: number) { return this.getImpl().searchArticles(query, limit); }
getReadingHistory(days?: number) { return this.getImpl().getReadingHistory(days); }
markAsRead(id: string) { return this.getImpl().markAsRead(id); }
bulkMarkRead(ids: string[]) { return this.getImpl().bulkMarkRead(ids); }
bulkDelete(ids: string[]) { return this.getImpl().bulkDelete(ids); }
bulkToggleSave(ids: string[]) { return this.getImpl().bulkToggleSave(ids); }
purgeOldContent(days: number) { return this.getImpl().purgeOldContent(days); }
updateArticleContent(id: string, content: string) { return this.getImpl().updateArticleContent(id, content); }
toggleSave(id: string) { return this.getImpl().toggleSave(id); }
getSavedArticles() { return this.getImpl().getSavedArticles(); }
getSettings() { return this.getImpl().getSettings(); }
saveSettings(settings: Settings) { return this.getImpl().saveSettings(settings); }
clearAll() { return this.getImpl().clearAll(); }
async getStats() {
const impl = this.getImpl();
return impl.getStats ? impl.getStats() : { size: 0, path: 'IndexedDB', articles: 0, feeds: 0, walEnabled: false };
getFeeds() {
return this.getImpl().getFeeds();
}
saveFeed(feed: Feed) {
return this.getImpl().saveFeed(feed);
}
saveFeeds(feeds: Feed[]) {
return this.getImpl().saveFeeds(feeds);
}
deleteFeed(id: string) {
return this.getImpl().deleteFeed(id);
}
getCategories() {
return this.getImpl().getCategories();
}
saveCategory(category: Category) {
return this.getImpl().saveCategory(category);
}
saveCategories(categories: Category[]) {
return this.getImpl().saveCategories(categories);
}
deleteCategory(id: string) {
return this.getImpl().deleteCategory(id);
}
getArticles(feedId?: string, offset?: number, limit?: number) {
return this.getImpl().getArticles(feedId, offset, limit);
}
saveArticles(articles: Article[]) {
return this.getImpl().saveArticles(articles);
}
searchArticles(query: string, limit?: number) {
return this.getImpl().searchArticles(query, limit);
}
getReadingHistory(days?: number) {
return this.getImpl().getReadingHistory(days);
}
markAsRead(id: string) {
return this.getImpl().markAsRead(id);
}
bulkMarkRead(ids: string[]) {
return this.getImpl().bulkMarkRead(ids);
}
bulkDelete(ids: string[]) {
return this.getImpl().bulkDelete(ids);
}
bulkToggleSave(ids: string[]) {
return this.getImpl().bulkToggleSave(ids);
}
purgeOldContent(days: number) {
return this.getImpl().purgeOldContent(days);
}
updateArticleContent(id: string, content: string) {
return this.getImpl().updateArticleContent(id, content);
}
toggleSave(id: string) {
return this.getImpl().toggleSave(id);
}
getSavedArticles() {
return this.getImpl().getSavedArticles();
}
getSettings() {
return this.getImpl().getSettings();
}
saveSettings(settings: Settings) {
return this.getImpl().saveSettings(settings);
}
clearAll() {
return this.getImpl().clearAll();
}
async getStats() {
const impl = this.getImpl();
return impl.getStats
? impl.getStats()
: { size: 0, path: 'IndexedDB', articles: 0, feeds: 0, walEnabled: false };
}
async vacuum() {
const impl = this.getImpl();
if (impl.vacuum) await impl.vacuum();
}
async integrityCheck() {
const impl = this.getImpl();
return impl.integrityCheck ? await impl.integrityCheck() : 'N/A';
}
async vacuum() { const impl = this.getImpl(); if (impl.vacuum) await impl.vacuum(); }
async integrityCheck() { const impl = this.getImpl(); return impl.integrityCheck ? await impl.integrityCheck() : 'N/A'; }
}
export const db: IDB = new LazyDBWrapper();

View File

@@ -12,32 +12,38 @@ export function exportToOPML(feeds: Feed[], categories: Category[]): string {
// Group feeds by category
const categorizedFeeds: Record<string, Feed[]> = {};
feeds.forEach(f => {
feeds.forEach((f) => {
if (!categorizedFeeds[f.categoryId]) categorizedFeeds[f.categoryId] = [];
categorizedFeeds[f.categoryId].push(f);
});
// Add categories and their feeds
[...categories].sort((a, b) => a.order - b.order).forEach(cat => {
const catFeeds = categorizedFeeds[cat.id] || [];
if (catFeeds.length === 0) return;
[...categories]
.sort((a, b) => a.order - b.order)
.forEach((cat) => {
const catFeeds = categorizedFeeds[cat.id] || [];
if (catFeeds.length === 0) return;
xml += `
<outline text="${escapeHTML(cat.name)}" title="${escapeHTML(cat.name)}">`;
[...catFeeds].sort((a, b) => a.order - b.order).forEach(f => {
xml += `
<outline text="${escapeHTML(cat.name)}" title="${escapeHTML(cat.name)}">`;
[...catFeeds]
.sort((a, b) => a.order - b.order)
.forEach((f) => {
xml += `
<outline type="rss" text="${escapeHTML(f.title)}" title="${escapeHTML(f.title)}" xmlUrl="${escapeHTML(f.id)}" htmlUrl="${escapeHTML(f.siteUrl)}"/>`;
});
xml += `
});
xml += `
</outline>`;
});
});
// Add uncategorized feeds
const uncategorized = categorizedFeeds['uncategorized'] || [];
[...uncategorized].sort((a, b) => a.order - b.order).forEach(f => {
xml += `
[...uncategorized]
.sort((a, b) => a.order - b.order)
.forEach((f) => {
xml += `
<outline type="rss" text="${escapeHTML(f.title)}" title="${escapeHTML(f.title)}" xmlUrl="${escapeHTML(f.id)}" htmlUrl="${escapeHTML(f.siteUrl)}"/>`;
});
});
xml += `
</body>
@@ -46,11 +52,14 @@ export function exportToOPML(feeds: Feed[], categories: Category[]): string {
return xml;
}
export function parseOPML(xml: string): { feeds: Partial<Feed>[], categories: Partial<Category>[] } {
export function parseOPML(xml: string): {
feeds: Partial<Feed>[];
categories: Partial<Category>[];
} {
const parser = new DOMParser();
const doc = parser.parseFromString(xml, 'text/xml');
const outlines = doc.querySelectorAll('body > outline');
const feeds: Partial<Feed>[] = [];
const categories: Partial<Category>[] = [];
@@ -67,7 +76,7 @@ export function parseOPML(xml: string): { feeds: Partial<Feed>[], categories: Pa
categories.push({
id: categoryId,
name: text,
order: categories.length
order: categories.length,
});
const childFeeds = outline.querySelectorAll('outline[type="rss"]');
@@ -90,17 +99,20 @@ function parseFeedOutline(el: Element, categoryId: string, order: number): Parti
enabled: true,
consecutiveErrors: 0,
fetchInterval: 30,
lastFetched: 0
lastFetched: 0,
};
}
function escapeHTML(str: string): string {
return str.replace(/[&<>"']/g, m => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
})[m] || m);
return str.replace(
/[&<>"']/g,
(m) =>
({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[m] || m
);
}

View File

@@ -4,23 +4,25 @@ import { registerPlugin } from '@capacitor/core';
const RSS = registerPlugin<any>('RSS');
export async function fetchFeed(feedUrl: string): Promise<{ feed: Partial<Feed>, articles: Article[] }> {
export async function fetchFeed(
feedUrl: string
): Promise<{ feed: Partial<Feed>; articles: Article[] }> {
// Try native RSS fetch first if on mobile to bypass CORS/Bot protection
if (newsStore.isCapacitor) {
try {
const data = await RSS.fetchFeed({ url: feedUrl });
const articles: Article[] = data.articles.map((item: any) => ({
...item,
description: stripHtml(item.description || '').substring(0, 200)
description: stripHtml(item.description || '').substring(0, 200),
}));
return {
feed: {
...data.feed,
lastFetched: Date.now()
lastFetched: Date.now(),
},
articles
articles,
};
} catch (e: any) {
console.warn('Native RSS fetch failed, falling back to API proxy:', e);
@@ -45,7 +47,7 @@ export async function fetchFeed(feedUrl: string): Promise<{ feed: Partial<Feed>,
const errorText = await response.text();
throw new Error(errorText || `Failed to fetch feed: ${response.status} ${response.statusText}`);
}
const contentType = response.headers.get('content-type');
const text = await response.text();
@@ -65,18 +67,18 @@ export async function fetchFeed(feedUrl: string): Promise<{ feed: Partial<Feed>,
} catch {
throw new Error('Failed to parse server response as JSON');
}
const articles: Article[] = data.articles.map((item: any) => ({
...item,
description: stripHtml(item.description).substring(0, 200)
description: stripHtml(item.description).substring(0, 200),
}));
return {
feed: {
...data.feed,
lastFetched: Date.now()
lastFetched: Date.now(),
},
articles
articles,
};
}
@@ -95,27 +97,27 @@ export async function refreshAllFeeds() {
const feeds = await db.getFeeds();
for (const feed of feeds) {
if (!feed.enabled) continue;
const now = Date.now();
const shouldFetch = now - feed.lastFetched > feed.fetchInterval * 60000 || feed.error;
if (shouldFetch) {
try {
const { feed: updatedFeed, articles } = await fetchFeed(feed.id);
await db.saveFeed({
...feed,
...updatedFeed,
error: undefined,
consecutiveErrors: 0
await db.saveFeed({
...feed,
...updatedFeed,
error: undefined,
consecutiveErrors: 0,
});
await db.saveArticles(articles);
} catch (e: any) {
console.error(`Failed to refresh feed ${feed.id}:`, e);
const consecutiveErrors = (feed.consecutiveErrors || 0) + 1;
await db.saveFeed({
...feed,
await db.saveFeed({
...feed,
error: e.message || 'Unknown error',
consecutiveErrors
consecutiveErrors,
});
}
}

View File

@@ -27,13 +27,13 @@ class NewsStore {
save: 's',
read: 'r',
open: 'o',
toggleSelect: 'x'
toggleSelect: 'x',
},
relevanceProfile: {
categoryScores: {},
feedScores: {},
totalInteractions: 0
}
totalInteractions: 0,
},
});
loading = $state(false);
isInitialLoading = $state(false);
@@ -46,12 +46,12 @@ class NewsStore {
isSelectMode = $state(false);
selectedArticleIds = $state(new Set<string>());
private limit = 20;
// Connection status
isOnline = $state(true);
ping = $state<number | null>(null);
lastStatusCheck = $state<number>(Date.now());
authInfo = $state<{required: boolean, mode: string, canReg: boolean} | null>(null);
authInfo = $state<{ required: boolean; mode: string; canReg: boolean } | null>(null);
isAuthenticated = $state(false);
isWails = typeof window !== 'undefined' && ((window as any).runtime || (window as any).go);
@@ -69,20 +69,21 @@ class NewsStore {
this.loading = true;
this.isInitialLoading = true;
const startTime = Date.now();
log("Init started");
log('Init started');
try {
// Platform detection
const isWails = typeof window !== 'undefined' && ((window as any).runtime || (window as any).go);
const isWails =
typeof window !== 'undefined' && ((window as any).runtime || (window as any).go);
const isCapacitor = typeof window !== 'undefined' && Capacitor.isNativePlatform();
let detectedWailsUrl: string | null = null;
if (isWails) {
log("Wails environment detected");
log('Wails environment detected');
// Wait a bit for bindings if they are not immediately available
let retries = 0;
while (retries < 15 && !(window as any).go?.main?.App?.GetAPIPort) {
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
retries++;
}
@@ -95,16 +96,16 @@ class NewsStore {
log(`Wails GetAPIPort failed: ${e}`);
}
} else {
log("Wails bindings not found after retries, continuing with defaults");
log('Wails bindings not found after retries, continuing with defaults');
}
} else if (isCapacitor) {
log("Capacitor Native environment detected");
log('Capacitor Native environment detected');
}
log("Fetching settings...");
log('Fetching settings...');
this.settings = await db.getSettings();
log("Settings loaded");
log('Settings loaded');
// Override API URL if running in Wails with detected port
// This prevents the DB setting (which might be stale or default) from breaking the connection
if (detectedWailsUrl) {
@@ -112,11 +113,11 @@ class NewsStore {
}
this.applyTheme();
log("Checking status...");
log('Checking status...');
await this.checkStatus();
log(`Status checked. Authenticated: ${this.isAuthenticated}`);
if (this.authInfo?.required) {
if (this.settings.authToken) {
const isValid = await this.verifyAuth(this.settings.authToken);
@@ -129,11 +130,11 @@ class NewsStore {
}
if (this.isAuthenticated) {
log("Loading feeds and categories...");
log('Loading feeds and categories...');
this.feeds = (await db.getFeeds()) || [];
this.categories = (await db.getCategories()) || [];
log(`Loaded ${this.feeds.length} feeds`);
// Ensure at least one category exists
if (this.categories.length === 0) {
const uncategorized = { id: 'uncategorized', name: 'Uncategorized', order: 0 };
@@ -141,9 +142,9 @@ class NewsStore {
this.categories = [uncategorized];
}
log("Loading articles...");
log('Loading articles...');
await this.loadArticles();
log("Articles loaded");
log('Articles loaded');
}
} catch (e) {
log(`Store initialization failed: ${e}`);
@@ -151,18 +152,18 @@ class NewsStore {
// Forced minimum loading time to prevent flickering (1.2 seconds)
const elapsed = Date.now() - startTime;
if (elapsed < 1200) {
await new Promise(resolve => setTimeout(resolve, 1200 - elapsed));
await new Promise((resolve) => setTimeout(resolve, 1200 - elapsed));
}
this.loading = false;
this.isInitialLoading = false;
this.isInitializing = false;
log("Init complete");
log('Init complete');
}
if (this.settings.autoFetch && this.isAuthenticated) {
this.startAutoFetch();
}
this.startStatusChecking();
}
@@ -170,7 +171,7 @@ class NewsStore {
const apiBase = this.settings.apiBaseUrl || '/api';
try {
const response = await fetch(`${apiBase}/auth/verify`, {
headers: { 'X-Account-Number': token }
headers: { 'X-Account-Number': token },
});
return response.ok;
} catch {
@@ -217,7 +218,7 @@ class NewsStore {
async checkStatus() {
if (typeof window === 'undefined') return;
const wasOnline = this.isOnline;
if (!navigator.onLine) {
this.isOnline = false;
@@ -232,10 +233,10 @@ class NewsStore {
// Add a short timeout for the ping
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const response = await fetch(`${apiBase}/ping`, {
const response = await fetch(`${apiBase}/ping`, {
cache: 'no-store',
signal: controller.signal
signal: controller.signal,
});
clearTimeout(timeout);
@@ -268,13 +269,13 @@ class NewsStore {
startStatusChecking() {
this.checkStatus();
if (this.statusInterval) clearInterval(this.statusInterval);
// Only run periodic status checks (ping) on web server
// Mobile and Desktop should avoid unnecessary background CPU/Battery usage
if (!this.isWails && !this.isCapacitor) {
this.statusInterval = setInterval(() => this.checkStatus(), 30000);
}
window.addEventListener('online', () => this.checkStatus());
window.addEventListener('offline', () => {
this.isOnline = false;
@@ -291,33 +292,33 @@ class NewsStore {
articles = await db.searchArticles(this.searchQuery, 100);
this.hasMore = false; // Search results are usually limited
} else {
if (this.currentView === 'saved') {
articles = await db.getSavedArticles();
} else if (this.currentView === 'following') {
articles = await db.getArticles(undefined, 0, this.limit);
} else {
if (this.currentView === 'saved') {
articles = await db.getSavedArticles();
} 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);
}
}
// Apply Mute Filters
if (this.settings.muteFilters && this.settings.muteFilters.length > 0) {
const filters = this.settings.muteFilters.map(f => f.toLowerCase());
articles = articles.filter(a => {
const filters = this.settings.muteFilters.map((f) => f.toLowerCase());
articles = articles.filter((a) => {
const title = a.title.toLowerCase();
return !filters.some(f => title.includes(f));
return !filters.some((f) => title.includes(f));
});
}
if (!this.searchQuery) {
if (this.settings.smartFeed && this.currentView !== 'saved' && !this.selectedFeedId) {
articles = this.rankArticles(articles);
} else {
articles.sort((a, b) => b.pubDate - a.pubDate);
}
this.articles = articles.slice(0, this.limit);
if (articles.length < this.limit) {
this.hasMore = false;
if (this.settings.smartFeed && this.currentView !== 'saved' && !this.selectedFeedId) {
articles = this.rankArticles(articles);
} else {
articles.sort((a, b) => b.pubDate - a.pubDate);
}
this.articles = articles.slice(0, this.limit);
if (articles.length < this.limit) {
this.hasMore = false;
}
} else {
this.articles = articles;
@@ -328,35 +329,36 @@ class NewsStore {
const now = Date.now();
const profile = this.settings.relevanceProfile;
const totalInteractions = profile.totalInteractions || 0;
return articles.map(article => {
const feed = this.feeds.find(f => f.id === article.feedId);
const feedScore = profile.feedScores[article.feedId] || 0;
const catScore = feed ? (profile.categoryScores[feed.categoryId] || 0) : 0;
// Affinity score (0 to 1)
const affinity = totalInteractions > 0
? (feedScore + catScore) / (totalInteractions * 2)
: 0;
// Recency score (decays over 48 hours)
const ageHours = (now - article.pubDate) / (1000 * 60 * 60);
const recency = Math.max(0, 1 - (ageHours / 48));
// Weighted final score: 60% behavior, 40% recency
const score = (affinity * 0.6) + (recency * 0.4);
return { ...article, relevanceScore: score };
}).sort((a: any, b: any) => b.relevanceScore - a.relevanceScore);
return articles
.map((article) => {
const feed = this.feeds.find((f) => f.id === article.feedId);
const feedScore = profile.feedScores[article.feedId] || 0;
const catScore = feed ? profile.categoryScores[feed.categoryId] || 0 : 0;
// Affinity score (0 to 1)
const affinity =
totalInteractions > 0 ? (feedScore + catScore) / (totalInteractions * 2) : 0;
// Recency score (decays over 48 hours)
const ageHours = (now - article.pubDate) / (1000 * 60 * 60);
const recency = Math.max(0, 1 - ageHours / 48);
// Weighted final score: 60% behavior, 40% recency
const score = affinity * 0.6 + recency * 0.4;
return { ...article, relevanceScore: score };
})
.sort((a: any, b: any) => b.relevanceScore - a.relevanceScore);
}
async trackInteraction(articleId: string, type: 'click' | 'save') {
if (!this.settings.smartFeed) return;
const article = this.articles.find(a => a.id === articleId);
const article = this.articles.find((a) => a.id === articleId);
if (!article) return;
const feed = this.feeds.find(f => f.id === article.feedId);
const feed = this.feeds.find((f) => f.id === article.feedId);
const weight = type === 'save' ? 3 : 1;
// Use snapshot to avoid reactive loops while updating profile
@@ -364,7 +366,8 @@ class NewsStore {
profile.totalInteractions += weight;
profile.feedScores[article.feedId] = (profile.feedScores[article.feedId] || 0) + weight;
if (feed && feed.categoryId) {
profile.categoryScores[feed.categoryId] = (profile.categoryScores[feed.categoryId] || 0) + weight;
profile.categoryScores[feed.categoryId] =
(profile.categoryScores[feed.categoryId] || 0) + weight;
}
this.settings.relevanceProfile = profile;
@@ -375,7 +378,7 @@ class NewsStore {
this.settings.relevanceProfile = {
categoryScores: {},
feedScores: {},
totalInteractions: 0
totalInteractions: 0,
};
await db.saveSettings($state.snapshot(this.settings));
await this.loadArticles();
@@ -383,7 +386,11 @@ class NewsStore {
}
async resetAllData() {
if (typeof window !== 'undefined' && !confirm('Are you sure you want to reset everything? All feeds and settings will be deleted.')) return;
if (
typeof window !== 'undefined' &&
!confirm('Are you sure you want to reset everything? All feeds and settings will be deleted.')
)
return;
await db.clearAll();
window.location.reload();
}
@@ -391,18 +398,39 @@ class NewsStore {
async loadDemoData() {
const demoCategories: Category[] = [
{ id: 'tech', name: 'Technology', order: 0 },
{ id: 'news', name: 'General News', order: 1 }
{ id: 'news', name: 'General News', order: 1 },
];
const demoFeeds: Partial<Feed>[] = [
{ id: 'https://news.ycombinator.com/rss', title: 'Hacker News', categoryId: 'tech', order: 0, enabled: true, fetchInterval: 30 },
{ id: 'https://theverge.com/rss/index.xml', title: 'The Verge', categoryId: 'tech', order: 1, enabled: true, fetchInterval: 30 },
{ id: 'https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml', title: 'NYT Top Stories', categoryId: 'news', order: 0, enabled: true, fetchInterval: 30 }
{
id: 'https://news.ycombinator.com/rss',
title: 'Hacker News',
categoryId: 'tech',
order: 0,
enabled: true,
fetchInterval: 30,
},
{
id: 'https://theverge.com/rss/index.xml',
title: 'The Verge',
categoryId: 'tech',
order: 1,
enabled: true,
fetchInterval: 30,
},
{
id: 'https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml',
title: 'NYT Top Stories',
categoryId: 'news',
order: 0,
enabled: true,
fetchInterval: 30,
},
];
await db.saveCategories(demoCategories);
await db.saveFeeds(demoFeeds as Feed[]);
toast.success('Demo data loaded');
await this.init();
}
@@ -410,12 +438,12 @@ class NewsStore {
async loadMore() {
if (this.loading || !this.hasMore) return;
this.loading = true;
let more: Article[] = [];
const offset = this.articles.length;
if (this.currentView === 'saved') {
this.hasMore = false;
this.hasMore = false;
} else {
more = await db.getArticles(this.selectedFeedId || undefined, offset, this.limit);
}
@@ -430,9 +458,8 @@ class NewsStore {
if (this.searchQuery) {
const query = this.searchQuery.toLowerCase();
more = more.filter(a =>
a.title.toLowerCase().includes(query) ||
a.description.toLowerCase().includes(query)
more = more.filter(
(a) => a.title.toLowerCase().includes(query) || a.description.toLowerCase().includes(query)
);
}
@@ -454,11 +481,11 @@ class NewsStore {
async toggleSave(articleId: string) {
const isSaved = await db.toggleSave(articleId);
const article = this.articles.find(a => a.id === articleId);
const article = this.articles.find((a) => a.id === articleId);
if (article) article.saved = isSaved;
if (this.currentView === 'saved' && !isSaved) {
this.articles = this.articles.filter(a => a.id !== articleId);
this.articles = this.articles.filter((a) => a.id !== articleId);
}
return isSaved;
}
@@ -467,7 +494,7 @@ class NewsStore {
async bulkMarkRead() {
const ids = Array.from(this.selectedArticleIds);
await db.bulkMarkRead(ids);
this.articles.forEach(a => {
this.articles.forEach((a) => {
if (this.selectedArticleIds.has(a.id)) a.read = true;
});
this.selectedArticleIds.clear();
@@ -478,7 +505,7 @@ class NewsStore {
async bulkToggleSave() {
const ids = Array.from(this.selectedArticleIds);
await db.bulkToggleSave(ids);
this.articles.forEach(a => {
this.articles.forEach((a) => {
if (this.selectedArticleIds.has(a.id)) a.saved = !a.saved;
});
this.selectedArticleIds.clear();
@@ -490,7 +517,7 @@ class NewsStore {
if (!confirm('Are you sure you want to delete these articles?')) return;
const ids = Array.from(this.selectedArticleIds);
await db.bulkDelete(ids);
this.articles = this.articles.filter(a => !this.selectedArticleIds.has(a.id));
this.articles = this.articles.filter((a) => !this.selectedArticleIds.has(a.id));
this.selectedArticleIds.clear();
this.isSelectMode = false;
toast.success(`Deleted ${ids.length} articles`);
@@ -503,7 +530,7 @@ class NewsStore {
this.readingArticle = this.articles[0];
return;
}
const idx = this.articles.findIndex(a => a.id === this.readingArticle.id);
const idx = this.articles.findIndex((a) => a.id === this.readingArticle.id);
if (idx < this.articles.length - 1) {
this.readingArticle = this.articles[idx + 1];
}
@@ -511,7 +538,7 @@ class NewsStore {
prevArticle() {
if (!this.readingArticle) return;
const idx = this.articles.findIndex(a => a.id === this.readingArticle.id);
const idx = this.articles.findIndex((a) => a.id === this.readingArticle.id);
if (idx > 0) {
this.readingArticle = this.articles[idx - 1];
}
@@ -528,7 +555,7 @@ class NewsStore {
// Fix orphans: if a feed has a categoryId that doesn't exist, move it to the first category
if (this.categories.length > 0) {
const catIds = new Set(this.categories.map(c => c.id));
const catIds = new Set(this.categories.map((c) => c.id));
for (const f of this.feeds) {
if (!catIds.has(f.categoryId)) {
f.categoryId = this.categories[0].id;
@@ -553,7 +580,7 @@ class NewsStore {
async fetchFullText(url: string, articleId?: string) {
// 1. Check local cache first
if (articleId) {
const cached = this.articles.find(a => a.id === articleId);
const cached = this.articles.find((a) => a.id === articleId);
if (cached?.content) {
return {
title: cached.title,
@@ -570,7 +597,9 @@ class NewsStore {
if (this.settings.authToken) {
headers['X-Account-Number'] = this.settings.authToken;
}
const response = await fetch(`${apiBase}/fulltext?url=${encodeURIComponent(url)}`, { headers });
const response = await fetch(`${apiBase}/fulltext?url=${encodeURIComponent(url)}`, {
headers,
});
if (response.status === 401) {
this.logout();
throw new Error('401');
@@ -582,13 +611,13 @@ class NewsStore {
if (articleId) {
// Mark as read when opening
await db.markAsRead(articleId);
const art = this.articles.find(a => a.id === articleId);
const art = this.articles.find((a) => a.id === articleId);
if (art) {
art.read = true;
art.readAt = Date.now();
if (data.content) art.content = data.content;
}
if (data.content) {
await db.updateArticleContent(articleId, data.content);
}
@@ -611,7 +640,7 @@ class NewsStore {
async deleteFeed(id: string) {
await db.deleteFeed(id);
this.feeds = this.feeds.filter(f => f.id !== id);
this.feeds = this.feeds.filter((f) => f.id !== id);
if (this.selectedFeedId === id) this.selectFeed(null);
toast.success('Feed removed');
}
@@ -622,9 +651,9 @@ class NewsStore {
await db.deleteFeed(oldId);
}
await db.saveFeed(plainFeed);
const searchId = oldId || feed.id;
const index = this.feeds.findIndex(f => f.id === searchId);
const index = this.feeds.findIndex((f) => f.id === searchId);
if (index !== -1) {
this.feeds[index] = plainFeed;
} else {
@@ -636,7 +665,7 @@ class NewsStore {
async reorderFeeds(feedIds: string[]) {
const updatedFeeds = $state.snapshot(this.feeds);
feedIds.forEach((id, index) => {
const feed = updatedFeeds.find(f => f.id === id);
const feed = updatedFeeds.find((f) => f.id === id);
if (feed) feed.order = index;
});
await db.saveFeeds(updatedFeeds);
@@ -655,7 +684,7 @@ class NewsStore {
async updateCategory(category: Category) {
const plainCategory = $state.snapshot(category);
await db.saveCategory(plainCategory);
const index = this.categories.findIndex(c => c.id === category.id);
const index = this.categories.findIndex((c) => c.id === category.id);
if (index !== -1) this.categories[index] = plainCategory;
toast.success('Category updated');
}
@@ -668,18 +697,18 @@ class NewsStore {
if (!confirm(`Are you sure? Feeds in this category will be moved.`)) return;
const fallbackCat = this.categories.find(c => c.id !== id);
const fallbackCat = this.categories.find((c) => c.id !== id);
if (!fallbackCat) return;
// Move feeds to fallback category first
const feedsToMove = this.feeds.filter(f => f.categoryId === id);
const feedsToMove = this.feeds.filter((f) => f.categoryId === id);
for (const f of feedsToMove) {
const updatedFeed = { ...$state.snapshot(f), categoryId: fallbackCat.id };
await db.saveFeed(updatedFeed);
}
await db.deleteCategory(id);
this.categories = this.categories.filter(c => c.id !== id);
this.categories = this.categories.filter((c) => c.id !== id);
this.feeds = await db.getFeeds();
toast.success('Category removed');
}
@@ -687,7 +716,7 @@ class NewsStore {
async reorderCategories(catIds: string[]) {
const updatedCats = $state.snapshot(this.categories);
catIds.forEach((id, index) => {
const cat = updatedCats.find(c => c.id === id);
const cat = updatedCats.find((c) => c.id === id);
if (cat) cat.order = index;
});
await db.saveCategories(updatedCats);
@@ -697,7 +726,9 @@ class NewsStore {
applyTheme() {
if (typeof document === 'undefined') return;
const theme = this.settings.theme;
const isDark = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const isDark =
theme === 'dark' ||
(theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.toggle('dark', isDark);
}

View File

@@ -23,14 +23,21 @@ class ToastStore {
}
remove(id: string) {
this.toasts = this.toasts.filter(t => t.id !== id);
this.toasts = this.toasts.filter((t) => t.id !== id);
}
success(message: string) { this.add(message, 'success'); }
error(message: string) { this.add(message, 'error', 5000); }
info(message: string) { this.add(message, 'info'); }
warning(message: string) { this.add(message, 'warning'); }
success(message: string) {
this.add(message, 'success');
}
error(message: string) {
this.add(message, 'error', 5000);
}
info(message: string) {
this.add(message, 'info');
}
warning(message: string) {
this.add(message, 'warning');
}
}
export const toast = new ToastStore();

View File

@@ -77,7 +77,9 @@
<div class="flex items-center gap-3">
<div class="flex-1">
<p class="text-sm font-medium text-text-primary">Update Available</p>
<p class="text-xs text-text-secondary mt-1">A new version is available. Reload to update.</p>
<p class="text-xs text-text-secondary mt-1">
A new version is available. Reload to update.
</p>
</div>
<button
on:click={reloadApp}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,13 @@
import { newsStore } from '$lib/store.svelte';
import Navbar from '../../components/Navbar.svelte';
let article = $state<{ title: string, link: string, description: string, imageUrl?: string, content?: string } | null>(null);
let article = $state<{
title: string;
link: string;
description: string;
imageUrl?: string;
content?: string;
} | null>(null);
let loading = $state(true);
let error = $state('');
@@ -15,18 +21,18 @@
const url = atob(encodedUrl);
const apiBase = newsStore.settings.apiBaseUrl || '/api';
const fullTextUrl = `${apiBase}/fulltext?url=${encodeURIComponent(url)}`;
const response = await fetch(fullTextUrl);
if (!response.ok) throw new Error('Failed to fetch article content');
const data = await response.json();
article = {
title: data.title || url,
link: url,
description: data.excerpt || '',
article = {
title: data.title || url,
link: url,
description: data.excerpt || '',
imageUrl: data.image || '',
content: data.content || ''
content: data.content || '',
};
} catch (e: any) {
error = 'Failed to load article: ' + e.message;
@@ -61,7 +67,7 @@
</svelte:head>
<div class="min-h-screen bg-bg-primary text-text-primary flex flex-col">
<Navbar onAddFeed={() => window.location.href = '/'} />
<Navbar onAddFeed={() => (window.location.href = '/')} />
<main class="flex-1 p-4 lg:p-8 overflow-y-auto">
<div class="max-w-4xl mx-auto">
@@ -80,7 +86,7 @@
</div>
{:else if article}
<div class="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
<a
<a
href="/"
class="flex items-center gap-2 text-text-secondary hover:text-accent-blue transition-colors mb-4"
>
@@ -90,9 +96,13 @@
<div class="card p-6 sm:p-8 space-y-6">
{#if article.imageUrl}
<img src={article.imageUrl} alt="" class="w-full h-64 md:h-96 object-cover rounded-2xl border border-border-color shadow-lg" />
<img
src={article.imageUrl}
alt=""
class="w-full h-64 md:h-96 object-cover rounded-2xl border border-border-color shadow-lg"
/>
{/if}
<div class="space-y-4">
<h1 class="text-3xl sm:text-4xl lg:text-5xl font-bold leading-tight tracking-tight">
{article.title}
@@ -109,9 +119,9 @@
</div>
<div class="pt-8 border-t border-border-color flex flex-wrap gap-4">
<a
href={article.link}
target="_blank"
<a
href={article.link}
target="_blank"
rel="noopener noreferrer"
class="btn-primary flex items-center gap-3 px-8 py-4 text-lg font-bold rounded-2xl"
>
@@ -133,7 +143,9 @@
:global(.prose img) {
border-radius: 1rem;
margin-bottom: 2rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
:global(.prose) {
--tw-prose-body: var(--text-secondary);
@@ -143,4 +155,3 @@
--tw-prose-quotes: var(--text-primary);
}
</style>

View File

@@ -45,7 +45,7 @@ self.addEventListener('fetch', (event) => {
}
// Network-First Strategy for everything else
// This ensures you always see the latest version if online,
// This ensures you always see the latest version if online,
// and only see the cached version if truly offline.
event.respondWith(
fetch(event.request)