Files
webnews/src/lib/db.ts
Sudo-Ivan 28273473e1
Some checks failed
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 28s
CI / build-backend (push) Failing after 31s
CI / build-frontend (push) Successful in 50s
0.1.0
2025-12-26 21:31:05 -06:00

798 lines
32 KiB
TypeScript

import { Capacitor } from '@capacitor/core';
import { SQLiteConnection, type SQLiteDBConnection, CapacitorSQLite } from '@capacitor-community/sqlite';
export interface Category {
id: string;
name: string;
order: number;
}
export interface Feed {
id: string; // URL as ID
title: string;
siteUrl: string;
description: string;
icon?: string;
categoryId: string;
lastFetched: number;
fetchInterval: number; // in minutes
enabled: boolean;
order: number;
error?: string;
consecutiveErrors: number;
}
export interface Article {
id: string; // GUID or URL
feedId: string;
title: string;
link: string;
description: string;
content?: string;
author?: string;
pubDate: number;
read: boolean;
saved: boolean;
imageUrl?: string;
readAt?: number;
}
export interface Settings {
theme: 'light' | 'dark' | 'system';
globalFetchInterval: number; // in minutes
autoFetch: boolean;
apiBaseUrl: string;
smartFeed: boolean;
readingMode: 'inline' | 'pane';
paneWidth: number; // percentage
fontFamily: 'sans' | 'serif';
fontSize: number; // in pixels
lineHeight: number; // multiplier
contentPurgeDays: number;
authToken: string | null;
muteFilters: string[];
shortcuts: {
next: string;
prev: string;
save: string;
read: string;
open: string;
toggleSelect: string;
};
relevanceProfile: {
categoryScores: Record<string, number>;
feedScores: Record<string, number>;
totalInteractions: number;
};
}
export interface DBStats {
size: number;
path: string;
articles: number;
feeds: number;
walEnabled: boolean;
}
export interface IDB {
getFeeds(): Promise<Feed[]>;
saveFeed(feed: Feed): Promise<void>;
saveFeeds(feeds: Feed[]): Promise<void>;
deleteFeed(id: string): Promise<void>;
getCategories(): Promise<Category[]>;
saveCategory(category: Category): Promise<void>;
saveCategories(categories: Category[]): Promise<void>;
deleteCategory(id: string): Promise<void>;
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 }[]>;
markAsRead(id: string): Promise<void>;
bulkMarkRead(ids: string[]): Promise<void>;
bulkDelete(ids: string[]): Promise<void>;
bulkToggleSave(ids: string[]): Promise<void>;
purgeOldContent(days: number): Promise<number>;
updateArticleContent(id: string, content: string): Promise<void>;
toggleSave(id: string): Promise<boolean>;
getSavedArticles(): Promise<Article[]>;
getSettings(): Promise<Settings>;
saveSettings(settings: Settings): Promise<void>;
clearAll(): Promise<void>;
// Desktop specific
getStats?(): Promise<DBStats>;
vacuum?(): Promise<void>;
integrityCheck?(): Promise<string>;
}
const DB_NAME = 'WebNewsDB';
const DB_VERSION = 5;
class IndexedDBImpl implements IDB {
private db: IDBDatabase | null = null;
async open(): Promise<IDBDatabase> {
if (this.db) return this.db;
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve(request.result);
};
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 });
};
});
}
async getFeeds(): Promise<Feed[]> {
const db = await this.open();
return new Promise((resolve, reject) => {
const transaction = db.transaction('feeds', 'readonly');
const request = transaction.objectStore('feeds').getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async saveFeed(feed: Feed): Promise<void> {
const db = await this.open();
return new Promise((resolve, reject) => {
const transaction = db.transaction('feeds', 'readwrite');
const request = transaction.objectStore('feeds').put(feed);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
async saveFeeds(feeds: Feed[]): Promise<void> {
const db = await this.open();
return new Promise((resolve, reject) => {
const transaction = db.transaction('feeds', 'readwrite');
const store = transaction.objectStore('feeds');
feeds.forEach(f => store.put(f));
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
async deleteFeed(id: string): Promise<void> {
const db = await this.open();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['feeds', 'articles'], 'readwrite');
transaction.objectStore('feeds').delete(id);
const articleStore = transaction.objectStore('articles');
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(); }
};
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
async getCategories(): Promise<Category[]> {
const db = await this.open();
return new Promise((resolve, reject) => {
const transaction = db.transaction('categories', 'readonly');
const request = transaction.objectStore('categories').getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async saveCategory(category: Category): Promise<void> {
const db = await this.open();
return new Promise((resolve, reject) => {
const transaction = db.transaction('categories', 'readwrite');
const request = transaction.objectStore('categories').put(category);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
async saveCategories(categories: Category[]): Promise<void> {
const db = await this.open();
return new Promise((resolve, reject) => {
const transaction = db.transaction('categories', 'readwrite');
const store = transaction.objectStore('categories');
categories.forEach(c => store.put(c));
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
async deleteCategory(id: string): Promise<void> {
const db = await this.open();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['categories', 'feeds'], 'readwrite');
transaction.objectStore('categories').delete(id);
const feedStore = transaction.objectStore('feeds');
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); } });
};
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
async getArticles(feedId?: string, offset = 0, limit = 20): Promise<Article[]> {
const db = await this.open();
return new Promise((resolve, reject) => {
const transaction = db.transaction('articles', '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();
request.onsuccess = () => {
const articles = request.result as Article[];
articles.sort((a, b) => b.pubDate - a.pubDate);
resolve(articles.slice(offset, offset + limit));
};
request.onerror = () => reject(request.error);
});
}
async saveArticles(articles: Article[]): Promise<void> {
const db = await this.open();
return new Promise((resolve, reject) => {
const transaction = db.transaction('articles', 'readwrite');
const store = transaction.objectStore('articles');
articles.forEach(article => store.put(article));
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
async searchArticles(query: string, limit = 50): Promise<Article[]> {
const db = await this.open();
return new Promise((resolve, reject) => {
const transaction = db.transaction('articles', 'readonly');
const store = transaction.objectStore('articles');
const request = store.openCursor(null, 'prev');
const results: Article[] = [];
const lowQuery = query.toLowerCase();
request.onsuccess = (event) => {
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))) {
results.push(article);
}
cursor.continue();
} else resolve(results);
};
request.onerror = () => reject(request.error);
});
}
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 request = index.openCursor(IDBKeyRange.lowerBound(startTime));
const history: Record<string, number> = {};
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
if (cursor) {
const article = cursor.value as Article;
if (article.readAt) {
const date = new Date(article.readAt).toISOString().split('T')[0];
history[date] = (history[date] || 0) + 1;
}
cursor.continue();
} else {
resolve(Object.entries(history).map(([date, count]) => ({ date: new Date(date).getTime(), count })));
}
};
request.onerror = () => reject(request.error);
});
}
async markAsRead(id: string): Promise<void> {
const db = await this.open();
const transaction = db.transaction('articles', 'readwrite');
const store = transaction.objectStore('articles');
const request = store.get(id);
request.onsuccess = () => {
const article = request.result as Article;
if (article && !article.read) {
article.read = true;
article.readAt = Date.now();
store.put(article);
}
};
}
async bulkMarkRead(ids: string[]): Promise<void> {
const db = await this.open();
const transaction = db.transaction('articles', 'readwrite');
const store = transaction.objectStore('articles');
const now = Date.now();
for (const id of ids) {
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); }
};
}
}
async bulkDelete(ids: string[]): Promise<void> {
const db = await this.open();
const transaction = db.transaction('articles', 'readwrite');
const store = transaction.objectStore('articles');
for (const id of ids) store.delete(id);
}
async bulkToggleSave(ids: string[]): Promise<void> {
const db = await this.open();
const transaction = db.transaction('articles', 'readwrite');
const store = transaction.objectStore('articles');
for (const id of ids) {
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 }); }
};
}
}
async purgeOldContent(days: number): Promise<number> {
const db = await this.open();
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 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++; }
cursor.continue();
} else resolve(count);
};
request.onerror = () => reject(request.error);
});
}
async updateArticleContent(id: string, content: string): Promise<void> {
const db = await this.open();
const transaction = db.transaction('articles', 'readwrite');
const store = transaction.objectStore('articles');
const request = store.get(id);
request.onsuccess = () => {
const article = request.result as Article;
if (article) { article.content = content; store.put(article); }
};
}
async toggleSave(id: string): Promise<boolean> {
const db = await this.open();
return new Promise((resolve, reject) => {
const transaction = db.transaction('articles', 'readwrite');
const store = transaction.objectStore('articles');
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);
};
request.onerror = () => reject(request.error);
});
}
async getSavedArticles(): Promise<Article[]> {
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));
request.onsuccess = () => {
const articles = request.result as Article[];
articles.sort((a, b) => b.pubDate - a.pubDate);
resolve(articles);
};
request.onerror = () => reject(request.error);
});
}
async getSettings(): Promise<Settings> {
const db = await this.open();
return new Promise((resolve) => {
const transaction = db.transaction('settings', 'readonly');
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: [],
shortcuts: { next: 'j', prev: 'k', save: 's', read: 'r', open: 'o', toggleSelect: 'x' },
relevanceProfile: { categoryScores: {}, feedScores: {}, totalInteractions: 0 }
};
resolve({ ...defaults, ...(request.result || {}) });
};
});
}
async saveSettings(settings: Settings): Promise<void> {
const db = await this.open();
return new Promise((resolve, reject) => {
const transaction = db.transaction('settings', 'readwrite');
const request = transaction.objectStore('settings').put({ ...settings, id: 'main' });
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
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();
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
}
class CapacitorSQLiteDBImpl implements IDB {
private sqlite: SQLiteConnection | null = null;
private db: SQLiteDBConnection | null = null;
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);
}
await this.db.open();
const queries = [
`CREATE TABLE IF NOT EXISTS categories (id TEXT PRIMARY KEY, name TEXT, "order" INTEGER);`,
`CREATE TABLE IF NOT EXISTS feeds (id TEXT PRIMARY KEY, title TEXT, categoryId TEXT, "order" INTEGER, enabled INTEGER, fetchInterval INTEGER);`,
`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);`
];
for (const q of queries) {
await this.db.execute(q);
}
return this.db;
}
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 }));
}
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]);
}
}
async deleteFeed(id: string): Promise<void> {
const db = await this.open();
await db.run('DELETE FROM articles WHERE feedId = ?', [id]);
await db.run('DELETE FROM feeds WHERE id = ?', [id]);
}
async getCategories(): Promise<Category[]> {
const db = await this.open();
const res = await db.query('SELECT * FROM categories ORDER BY "order" ASC');
return res.values || [];
}
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]);
}
}
async deleteCategory(id: string): Promise<void> {
const db = await this.open();
await db.run('UPDATE feeds SET categoryId = "uncategorized" WHERE categoryId = ?', [id]);
await db.run('DELETE FROM categories WHERE id = ?', [id]);
}
async getArticles(feedId?: string, offset = 0, limit = 20): Promise<Article[]> {
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]);
} else {
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 }));
}
async saveArticles(articles: Article[]): Promise<void> {
const db = await this.open();
for (const a of 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]);
}
}
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 }));
}
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(`
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 => ({
date: new Date(row.date).getTime(),
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]);
}
async bulkMarkRead(ids: string[]): Promise<void> {
const db = await this.open();
const now = Date.now();
for (const id of ids) {
await db.run('UPDATE articles SET read = 1, readAt = ? WHERE id = ? AND read = 0', [now, id]);
}
}
async bulkDelete(ids: string[]): Promise<void> {
const db = await this.open();
for (const id of ids) await db.run('DELETE FROM articles WHERE id = ?', [id]);
}
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]);
}
}
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]);
return res.changes?.changes || 0;
}
async updateArticleContent(id: string, content: string): Promise<void> {
const db = await this.open();
await db.run('UPDATE articles SET content = ? WHERE id = ?', [content, id]);
}
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]);
const res = await db.query('SELECT saved FROM articles WHERE id = ?', [id]);
return res.values?.[0]?.saved === 1;
}
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 }));
}
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: [],
shortcuts: { next: 'j', prev: 'k', save: 's', read: 'r', open: 'o', toggleSelect: 'x' },
relevanceProfile: { categoryScores: {}, feedScores: {}, totalInteractions: 0 }
};
if (res.values && res.values.length > 0) {
return { ...defaults, ...JSON.parse(res.values[0].value) };
}
return defaults;
}
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)]);
}
async clearAll(): Promise<void> {
const db = await this.open();
await db.run('DELETE FROM articles');
await db.run('DELETE FROM feeds');
await db.run('DELETE FROM categories');
await db.run('DELETE FROM settings');
}
async getStats(): Promise<DBStats> {
const db = await this.open();
const artRes = await db.query('SELECT COUNT(*) as count FROM articles');
const feedRes = await db.query('SELECT COUNT(*) as count FROM feeds');
return {
size: 0, // Hard to get exact file size easily here
path: 'Native SQLite',
articles: artRes.values?.[0]?.count || 0,
feeds: feedRes.values?.[0]?.count || 0,
walEnabled: true
};
}
}
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) =>
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 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); } }
// Wait for feed updates then delete cat
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 }[]> {
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 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)); }
}
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; }
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);
}
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: [],
shortcuts: { next: 'j', prev: 'k', save: 's', read: 'r', open: 'o', toggleSelect: 'x' },
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);
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'); }
}
// LazyDBWrapper allows deciding which implementation to use at runtime (lazily),
// correcting for race conditions where window.go might not be available at import time.
class LazyDBWrapper implements IDB {
private impl: IDB | null = null;
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());
if (isWails) {
this.impl = new WailsDBImpl();
} else if (isCapacitor) {
this.impl = new CapacitorSQLiteDBImpl();
} else {
this.impl = new IndexedDBImpl();
}
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 };
}
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();