refactor(core): overhaul state transition architecture, persistent job queue, and API layer
CI Quality Checks & Tests / Gitleaks Secret Detection (push) Failing after 1m59s
CI Quality Checks & Tests / Go Lint & Unit Tests (push) Failing after 2m26s
CI Quality Checks & Tests / Frontend Typecheck & Build (push) Successful in 2m37s

- Implement atomic state transitions: add EntryService.PatchEntry() with EntryDiff computation and EventEntryTransition event emission.
- Add persistent SQLite task queue (JobEngine): add jobs table with crash recovery, exponential backoff retries, concurrency control, and idempotency key deduplication.
- Decompose monolithic HTTP handlers: split handlers.go into domain handlers (auth, feeds, entries, jobs, settings, rules, sse).
- Implement real-time Server-Sent Events (/api/events/stream) for multi-tab/device state synchronization.
- Fix SQLite concurrency: apply busy_timeout(10000) and journal_mode(WAL) via DSN query parameters to resolve SQLITE_BUSY (5).
- Fix AI settings & prompt loading: unmarshal JSON from ai_settings, apply configured prompt templates, and enforce strict enabled/API key validations.
- Fix undo share state: remove omitempty from ShareUUID to serialize null for state resets.
- Fix middle list counter: compute true unread/total stats from DB and clean switcher button labels.
- Adapt mobile UI for iOS: add viewport-fit=cover in index.html and calibrate safe-area insets for the bottom navigation and article reader toolbar.
- Add architectural documentation and worklog in docs/009_worklog.md.
This commit is contained in:
2026-09-01 00:41:05 -07:00
parent 271a542196
commit ebcaa5d72a
38 changed files with 3748 additions and 1202 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#0f172a" />
<meta name="description" content="RSSPipe - RSS Driven Information Gateway" />
<link rel="manifest" href="/manifest.json" />
+50 -1
View File
@@ -306,9 +306,57 @@ export const App: React.FC = () => {
}
};
// Connect Real-Time SSE Stream for live state sync across tabs and async background jobs
useEffect(() => {
if (!currentUser) return;
const disconnect = api.connectEventStream((eventType, data) => {
if (eventType === 'job_completed' || eventType === 'post_updated' || eventType === 'variant_updated') {
const updatedEntry = data?.entry || data;
if (updatedEntry && updatedEntry.id) {
setEntries((prev) =>
prev.map((e) =>
e.id === updatedEntry.id
? {
...e,
...updatedEntry,
variants: { ...(e.variants || {}), ...(updatedEntry.variants || {}) },
}
: e
)
);
}
} else if (eventType === 'post_created') {
loadSidebarData();
} else if (eventType === 'preferences_updated') {
if (data && typeof data === 'object') {
if (data.auto_read !== undefined) {
localStorage.setItem('rsspipe_auto_read_scroll', data.auto_read ? 'true' : 'false');
}
if (data.view_preference) {
setViewPreference(data.view_preference);
localStorage.setItem('rsspipe_view_preference', data.view_preference);
}
}
}
});
return () => disconnect();
}, [currentUser, loadSidebarData]);
// Update Single Entry Action
const handleUpdateEntry = (updated: FeedEntry) => {
setEntries((prev) => prev.map((e) => (e.id === updated.id ? updated : e)));
setEntries((prev) =>
prev.map((e) =>
e.id === updated.id
? {
...e,
...updated,
variants: { ...(e.variants || {}), ...(updated.variants || {}) },
}
: e
)
);
loadSidebarData();
};
@@ -500,6 +548,7 @@ export const App: React.FC = () => {
onMarkAllRead={handleMarkAllRead}
onUpdateEntry={handleUpdateEntry}
stats={stats}
totalEntries={totalEntries}
onOpenSettings={() => setViewMode('settings')}
onOpenAddFeed={() => setShowAddFeedModal(true)}
onPlayAudio={handlePlayAudio}
@@ -23,6 +23,8 @@ interface DesktopArticleListProps {
onToggleStar: (entry: FeedEntry, e: React.MouseEvent) => void;
feeds?: Feed[];
categories?: Category[];
stats?: import('../../types').Stats | null;
totalEntries?: number;
onFilterChange?: (newFilter: Partial<FilterState>) => void;
}
@@ -52,6 +54,8 @@ export const DesktopArticleList: React.FC<DesktopArticleListProps> = ({
onToggleStar,
feeds = [],
categories = [],
stats,
totalEntries = 0,
onFilterChange,
}) => {
const getFilterTitle = () => {
@@ -70,7 +74,35 @@ export const DesktopArticleList: React.FC<DesktopArticleListProps> = ({
return 'Inbox';
};
const unreadCount = entries.filter((e) => !e.is_read).length;
// Calculate accurate unread and total counts from feeds/categories/stats
const getFilterCounts = () => {
if (filter.feedId) {
const activeFeed = feeds.find((f) => f.id === filter.feedId);
return {
unread: activeFeed?.unread_count || 0,
total: activeFeed?.total_count || totalEntries || 0,
};
}
if (filter.categoryId) {
const catFeeds = feeds.filter((f) => f.category_id === filter.categoryId);
const unread = catFeeds.reduce((acc, f) => acc + (f.unread_count || 0), 0);
const total = catFeeds.reduce((acc, f) => acc + (f.total_count || 0), 0);
return { unread, total: total || totalEntries || 0 };
}
if (filter.view === 'starred') {
return { unread: 0, total: stats?.total_starred || totalEntries || 0 };
}
if (filter.view === 'all') {
return { unread: stats?.total_unread || 0, total: totalEntries || 0 };
}
if (filter.view === 'shared') {
return { unread: 0, total: stats?.total_shared || totalEntries || 0 };
}
// 'inbox'
return { unread: stats?.total_unread || 0, total: stats?.total_unread || totalEntries || 0 };
};
const { unread: trueUnreadCount, total: trueTotalCount } = getFilterCounts();
return (
<div className="desktop-list-pane" style={{ width: `${width}px` }}>
@@ -80,7 +112,21 @@ export const DesktopArticleList: React.FC<DesktopArticleListProps> = ({
<span className="list-title-text" title={getFilterTitle()}>
{getFilterTitle()}
</span>
{unreadCount > 0 && <span className="badge badge-unread">{unreadCount}</span>}
{filter.view === 'inbox' ? (
trueUnreadCount > 0 && <span className="badge badge-unread">{trueUnreadCount}</span>
) : filter.view === 'all' ? (
trueTotalCount > 0 && (
<span className="badge badge-unread" style={{ background: 'rgba(148, 163, 184, 0.15)', color: 'var(--text-secondary)' }}>
{trueTotalCount}
</span>
)
) : filter.view === 'starred' ? (
trueTotalCount > 0 && (
<span className="badge badge-unread" style={{ background: 'rgba(234, 179, 8, 0.15)', color: 'var(--accent-star)' }}>
{trueTotalCount}
</span>
)
) : null}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
@@ -104,7 +150,7 @@ export const DesktopArticleList: React.FC<DesktopArticleListProps> = ({
</div>
)}
{unreadCount > 0 && (
{trueUnreadCount > 0 && (
<button
className="btn-icon"
onClick={onMarkAllRead}
+5 -1
View File
@@ -226,7 +226,11 @@ export const DesktopReader: React.FC<DesktopReaderProps> = ({
setIsSharing(true);
try {
const res = await api.toggleShare(entry.id);
onUpdateEntry?.(res.entry);
const updatedEntry = {
...res.entry,
share_uuid: res.share_uuid ?? null,
};
onUpdateEntry?.(updatedEntry);
if (res.shared && res.share_uuid) {
const shareUrl = `${window.location.origin}/share/${res.share_uuid}`;
navigator.clipboard.writeText(shareUrl);
@@ -18,6 +18,7 @@ interface ThreePaneViewProps {
onMarkAllRead: () => void;
onUpdateEntry?: (entry: FeedEntry) => void;
stats: Stats | null;
totalEntries?: number;
onOpenSettings: () => void;
onOpenAddFeed: () => void;
onPlayAudio?: (entry: FeedEntry, streamUrl: string) => void;
@@ -38,6 +39,7 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
onMarkAllRead,
onUpdateEntry,
stats,
totalEntries = 0,
onOpenSettings,
onOpenAddFeed,
onPlayAudio,
@@ -115,6 +117,8 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
onToggleStar={onToggleStar}
feeds={feeds}
categories={categories}
stats={stats}
totalEntries={totalEntries}
onFilterChange={onFilterChange}
/>
@@ -201,7 +201,11 @@ export const MobileArticleSheet: React.FC<MobileArticleSheetProps> = ({
try {
const res = await api.toggleShare(entry.id);
if (res && res.entry) {
onUpdateEntry?.(res.entry);
const updatedEntry = {
...res.entry,
share_uuid: res.share_uuid ?? null,
};
onUpdateEntry?.(updatedEntry);
}
if (res && res.shared && res.share_uuid) {
const shareUrl = `${window.location.origin}/share/${res.share_uuid}`;
+74
View File
@@ -306,8 +306,82 @@ export const api = {
return request<void>(`/search/history${qParam}`, {
method: 'DELETE',
});
},
// Patch Entry (Atomic transition mutation)
async patchEntry(
id: string,
patch: {
is_read?: boolean;
is_starred?: boolean;
metadata?: Record<string, any>;
variants?: Record<string, any>;
}
): Promise<{ entry: FeedEntry; diff: any }> {
return request<{ entry: FeedEntry; diff: any }>(`/entries/${id}`, {
method: 'PATCH',
body: JSON.stringify(patch),
});
},
// Background Task Queue & Jobs
async listJobs(params?: { type?: string; status?: string; limit?: number; offset?: number }): Promise<{ jobs: any[]; limit: number; offset: number }> {
const query = new URLSearchParams();
if (params?.type) query.set('type', params.type);
if (params?.status) query.set('status', params.status);
if (params?.limit) query.set('limit', params.limit.toString());
if (params?.offset) query.set('offset', params.offset.toString());
return request(`/jobs?${query.toString()}`);
},
async getJob(id: string): Promise<any> {
return request<any>(`/jobs/${id}`);
},
async cancelJob(id: string): Promise<{ message: string }> {
return request<{ message: string }>(`/jobs/${id}/cancel`, { method: 'POST' });
},
async retryJob(id: string): Promise<{ message: string }> {
return request<{ message: string }>(`/jobs/${id}/retry`, { method: 'POST' });
},
// Real-Time Server-Sent Events Stream
connectEventStream(onEvent: (eventType: string, data: any) => void): () => void {
const eventSource = new EventSource(`${API_BASE}/events/stream`);
const eventNames = [
'connected',
'ping',
'preferences_updated',
'job_progress',
'job_completed',
'job_failed',
'post_created',
'post_updated',
];
eventNames.forEach((name) => {
eventSource.addEventListener(name, (e: MessageEvent) => {
try {
const data = JSON.parse(e.data);
onEvent(name, data);
} catch {
onEvent(name, e.data);
}
});
});
eventSource.onerror = (err) => {
console.warn('SSE connection error:', err);
};
return () => {
eventSource.close();
};
}
};
+3 -2
View File
@@ -64,8 +64,9 @@
/* Layout Dimensions */
--header-height: 50px;
--bottom-nav-height: 72px;
--docked-audio-height: 56px;
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
--bottom-nav-height: calc(50px + env(safe-area-inset-bottom, 0px));
--docked-audio-height: 54px;
--sidebar-min-width: 200px;
--sidebar-default-width: 250px;
--list-min-width: 300px;
+7 -5
View File
@@ -375,11 +375,12 @@
-webkit-backdrop-filter: blur(20px);
border-top: 1px solid var(--border-subtle);
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-around;
padding: 6px 0.5rem calc(16px + env(safe-area-inset-bottom, 0px));
padding: 4px 0.5rem calc(var(--safe-area-bottom) + 2px);
z-index: 50;
user-select: none;
box-sizing: border-box;
}
.bottom-nav-item {
@@ -467,7 +468,7 @@
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
padding: 1.15rem 1rem calc(65px + env(safe-area-inset-bottom, 0px));
padding: 1.15rem 1rem calc(56px + var(--safe-area-bottom));
width: 100%;
max-width: 100vw;
box-sizing: border-box;
@@ -479,8 +480,8 @@
bottom: 0;
left: 0;
right: 0;
height: calc(52px + env(safe-area-inset-bottom, 0px));
padding: 0 0.85rem env(safe-area-inset-bottom, 0px);
height: calc(48px + var(--safe-area-bottom));
padding: 4px 0.85rem calc(var(--safe-area-bottom) + 2px);
background: var(--bg-docked);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
@@ -490,6 +491,7 @@
justify-content: space-between;
z-index: 110;
user-select: none;
box-sizing: border-box;
}
.mobile-sheet-bottom-left {
+1 -1
View File
@@ -67,7 +67,7 @@ export interface FeedEntry {
published_at: string;
is_read: boolean;
is_starred: boolean;
share_uuid?: string;
share_uuid?: string | null;
variants?: Record<string, EntryVariant>;
metadata?: Record<string, any>;
created_at: string;