From 751b784f13ccceab7715835aaa82c99a80ff6469 Mon Sep 17 00:00:00 2001 From: newbe36524 Date: Tue, 10 Feb 2026 19:59:05 +0800 Subject: [PATCH] rss feed support --- .../proposal.md | 230 ++++++++++ .../tasks.md | 398 ++++++++++++++++++ package-lock.json | 42 ++ package.json | 3 +- src/main/main.ts | 65 ++- src/main/rss-feed-manager.ts | 247 +++++++++++ src/main/types/rss-types.ts | 46 ++ src/preload/index.ts | 7 + src/renderer/components/BlogFeedCard.tsx | 206 +++++++++ .../components/SystemManagementView.tsx | 11 + .../i18n/locales/en-US/components.json | 8 + .../i18n/locales/zh-CN/components.json | 8 + src/renderer/store/index.ts | 7 + src/renderer/store/sagas/rssFeedSaga.ts | 120 ++++++ src/renderer/store/slices/rssFeedSlice.ts | 89 ++++ 15 files changed, 1484 insertions(+), 3 deletions(-) create mode 100644 openspec/changes/archive/2025-02-10-official-blog-rss-feed-integration/proposal.md create mode 100644 openspec/changes/archive/2025-02-10-official-blog-rss-feed-integration/tasks.md create mode 100644 src/main/rss-feed-manager.ts create mode 100644 src/main/types/rss-types.ts create mode 100644 src/renderer/components/BlogFeedCard.tsx create mode 100644 src/renderer/store/sagas/rssFeedSaga.ts create mode 100644 src/renderer/store/slices/rssFeedSlice.ts diff --git a/openspec/changes/archive/2025-02-10-official-blog-rss-feed-integration/proposal.md b/openspec/changes/archive/2025-02-10-official-blog-rss-feed-integration/proposal.md new file mode 100644 index 00000000..b2cacabf --- /dev/null +++ b/openspec/changes/archive/2025-02-10-official-blog-rss-feed-integration/proposal.md @@ -0,0 +1,230 @@ +# 官方博客 RSS 订阅源集成提案 + +## 概述 + +在 Hagicode Desktop 应用主页集成 Hagicode 官方博客的 RSS 订阅源,允许用户直接在应用内查看最新的官方动态、技术文章和产品更新公告。 + +**变更 ID**: `official-blog-rss-feed-integration` +**状态**: ExecutionCompleted +**目标版本**: 待定 + +## 背景与动机 + +### 当前状态 + +Hagicode Desktop 是一个基于 Electron 的跨平台桌面应用,提供 Web 服务管理、包管理等功能。目前主页缺少官方博客内容的展示,用户需要: + +- 手动访问外部网站获取 Hagicode 最新动态 +- 无法及时了解产品更新和功能发布公告 +- 缺少快速访问官方文档和教程资源的入口 + +### 目标 + +通过集成官方博客 RSS 订阅源(`https://docs.hagicode.com/blog/rss.xml`),在应用主页提供: +- 最新 5 条博客文章的快速预览 +- 自动刷新机制,确保内容及时更新 +- 一键跳转至完整文章 + +## 范围 + +### 包含内容 + +1. **主进程 RSS 管理器** + - 创建 `rss-feed-manager.ts` 单例管理器 + - 实现从 `https://docs.hagicode.com/blog/rss.xml` 获取和解析 RSS 数据 + - 使用 electron-store 持久化缓存 RSS 数据 + - 实现每 24 小时自动刷新机制 + +2. **IPC 通信层** + - `rss-get-feed-items` - 获取 RSS 文章列表 + - `rss-refresh-feed` - 手动触发刷新 + - `rss-get-last-update` - 获取上次更新时间 + +3. **渲染进程状态管理** + - 创建 `rssFeedSlice.ts` 管理 RSS 状态 + - 创建 `rssFeedSaga.ts` 处理异步操作 + +4. **UI 组件** + - 创建 `BlogFeedCard.tsx` 展示博客文章列表 + - 集成到主页(SystemManagementView.tsx) + +5. **国际化支持** + - 中英文翻译文件更新 + +### 排除内容 + +- RSS 订阅源配置界面(仅使用固定的官方博客地址) +- 离线模式下的缓存阅读(仅缓存数据用于刷新间隔) +- 博客内容的全文展示(仅摘要和跳转链接) + +## 技术设计 + +### 架构概览 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 渲染进程 (Renderer) │ +├─────────────────────────────────────────────────────────────┤ +│ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ BlogFeedCard │◄────│ rssFeedSlice │ │ +│ │ (UI 组件) │ │ (Redux 状态) │ │ +│ └─────────────────┘ └─────────────────┘ │ +│ ▲ ▲ │ +│ │ │ │ +│ └───────┬───────────────┘ │ +│ │ │ +│ ┌────────▼────────┐ │ +│ │ rssFeedSaga │ │ +│ │ (异步处理) │ │ +│ └────────┬────────┘ │ +└──────────────────┼───────────────────────────────────────────┘ + │ IPC 通信 + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 主进程 (Main) │ +├─────────────────────────────────────────────────────────────┤ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ rss-feed-manager.ts (单例) │ │ +│ │ ┌─────────────────────────────────────────────────┐ │ │ +│ │ │ • fetchRSSFeed() - 获取 RSS 数据 │ │ │ +│ │ │ • parseRSSXML() - 解析 XML │ │ │ +│ │ │ • getCachedFeed() - 获取缓存数据 │ │ │ +│ │ │ • refreshFeed() - 刷新 RSS 数据 │ │ │ +│ │ │ • startAutoRefresh() - 启动定时刷新 │ │ │ +│ │ └─────────────────────────────────────────────────┘ │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ electron-store (持久化) │ │ +│ │ • rssFeed.items - 文章列表 │ │ +│ │ • rssFeed.lastUpdate - 最后更新时间 │ │ +│ └──────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 外部数据源 │ +│ https://docs.hagicode.com/blog/rss.xml │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 数据结构 + +```typescript +// RSS 文章项 +interface RSSFeedItem { + title: string; // 文章标题 + link: string; // 文章链接 + pubDate: string; // 发布日期 (ISO 8601) + description: string; // 文章描述/摘要 + guid?: string; // 唯一标识符 +} + +// RSS 缓存数据 +interface RSSFeedCache { + items: RSSFeedItem[]; + lastUpdate: string; // ISO 8601 时间戳 +} +``` + +### IPC 通道定义 + +| 通道名称 | 类型 | 参数 | 返回值 | 描述 | +|---------|------|------|-------|------| +| `rss-get-feed-items` | invoke | 无 | `RSSFeedItem[]` | 获取缓存的 RSS 文章列表 | +| `rss-refresh-feed` | invoke | 无 | `Promise` | 手动触发刷新并返回最新文章 | +| `rss-get-last-update` | invoke | 无 | `string \| null` | 获取上次更新时间戳 | + +## 实施计划 + +详细的实施任务清单请参见 [tasks.md](./tasks.md)。 + +## 影响分析 + +### 用户体验影响 + +**正面影响**: +- 用户可快速获取官方最新资讯 +- 减少访问外部网站的需求 +- 提升应用的信息聚合能力 + +**潜在风险**: +- 网络故障可能导致加载失败(需友好错误提示) +- RSS 源不可用时的降级处理 + +### 性能影响 + +- RSS 数据缓存减少网络请求 +- 定时刷新在后台执行,不影响用户操作 +- 仅显示最新 5 条文章,内存开销极小 + +### 兼容性影响 + +- 需要网络连接获取 RSS 数据 +- 离线状态下显示缓存数据和提示信息 + +## 依赖项 + +### 新增依赖 + +| 包名 | 用途 | 版本要求 | +|-----|------|---------| +| `rss-parser` | RSS/XML 解析 | ^3.13.0 | +| `electron-store` | 数据持久化 | 已安装 ^10.0.0 | + +### 现有依赖利用 + +- `axios` ^1.13.2 - HTTP 请求(已安装) +- `@reduxjs/toolkit` ^2.5.0 - 状态管理(已安装) +- `redux-saga` ^1.4.2 - 异步操作(已安装) +- `shadcn` ^3.6.2 - UI 组件(已安装) + +## 风险与缓解 + +| 风险 | 影响 | 缓解措施 | +|-----|------|---------| +| RSS 源不可用 | 用户无法加载博客内容 | 显示友好错误提示,提供重试按钮 | +| RSS 格式变更 | 解析失败 | 添加错误处理和日志记录 | +| 网络延迟 | 加载时间过长 | 使用缓存数据,后台刷新 | +| 解析性能问题 | 主进程阻塞 | 使用异步解析,限制文章数量 | + +## 测试策略 + +1. **单元测试** + - RSS 解析逻辑 + - 缓存读写操作 + - Redux slice reducers + +2. **集成测试** + - IPC 通信正确性 + - 自动刷新机制 + +3. **UI 测试** + - 组件渲染正确性 + - 加载状态显示 + - 错误状态处理 + +## 成功标准 + +- [x] 主进程成功获取并解析 RSS 数据 +- [x] 渲染进程正确显示最新 5 条博客文章 +- [x] 自动刷新机制正常工作(24小时间隔) +- [x] 手动刷新按钮功能正常 +- [x] 网络错误时有友好提示 +- [x] 中英文国际化支持完整 +- [x] 点击文章正确跳转到浏览器 + +## 附录 + +### 参考资料 + +- [RSS 2.0 规范](https://www.rssboard.org/rss-specification) +- [rss-parser 文档](https://github.com/rbren/rss-parser) +- [项目代码结构文档](../../../docs/) + +### 变更历史 + +| 日期 | 版本 | 作者 | 变更说明 | +|-----|------|------|---------| +| 2025-02-10 | 1.0 | Claude | 初始提案版本 | diff --git a/openspec/changes/archive/2025-02-10-official-blog-rss-feed-integration/tasks.md b/openspec/changes/archive/2025-02-10-official-blog-rss-feed-integration/tasks.md new file mode 100644 index 00000000..7fefcef3 --- /dev/null +++ b/openspec/changes/archive/2025-02-10-official-blog-rss-feed-integration/tasks.md @@ -0,0 +1,398 @@ +# 官方博客 RSS 订阅源集成 - 实施任务清单 + +本文档列出了实施"官方博客 RSS 订阅源集成"功能的详细任务清单。 + +## 任务依赖关系图 + +``` +Phase 1: 准备工作 +├── T1.1 安装 rss-parser 依赖 +└── T1.2 定义 TypeScript 类型 + +Phase 2: 主进程开发 +├── T2.1 创建 RSS Feed Manager +├── T2.2 实现 IPC 通道注册 +└── T2.3 配置预加载脚本 + +Phase 3: 渲染进程状态管理 +├── T3.1 创建 rssFeedSlice +└── T3.2 创建 rssFeedSaga + +Phase 4: UI 组件开发 +├── T4.1 创建 BlogFeedCard 组件 +├── T4.2 集成到主页 +└── T4.3 添加国际化翻译 + +Phase 5: 测试与验证 +├── T5.1 功能测试 +├── T5.2 错误处理测试 +└── T5.3 国际化验证 +``` + +--- + +## Phase 1: 准备工作 + +### T1.1 安装 rss-parser 依赖 + +**优先级**: 高 +**预计工作量**: 5 分钟 +**依赖**: 无 + +**描述**: +安装 `rss-parser` 库用于解析 RSS XML 数据。 + +**执行步骤**: +1. 运行安装命令:`npm install rss-parser` +2. 验证 package.json 中已添加依赖 + +**验收标准**: +- [ ] `rss-parser` 已添加到 package.json dependencies +- [ ] `npm install` 无错误完成 + +--- + +### T1.2 定义 TypeScript 类型 + +**优先级**: 高 +**预计工作量**: 10 分钟 +**依赖**: T1.1 + +**描述**: +定义 RSS 相关的 TypeScript 接口和类型。 + +**执行步骤**: +1. 创建文件 `src/main/types/rss-types.ts` +2. 定义 `RSSFeedItem` 接口 +3. 定义 `RSSFeedCache` 接口 + +**代码位置**: `src/main/types/rss-types.ts` (新建) + +**验收标准**: +- [ ] 类型定义文件已创建 +- [ ] 类型包含所有必需字段 +- [ ] 类型导出正确 + +--- + +## Phase 2: 主进程开发 + +### T2.1 创建 RSS Feed Manager + +**优先级**: 高 +**预计工作量**: 60 分钟 +**依赖**: T1.2 + +**描述**: +创建单例模式的 RSS Feed Manager,负责获取、解析和缓存 RSS 数据。 + +**执行步骤**: +1. 创建 `src/main/rss-feed-manager.ts` +2. 实现单例模式(参考现有管理器如 `web-service-manager.ts`) +3. 实现 `fetchRSSFeed()` 方法 - 使用 axios 获取 RSS 数据 +4. 实现 `parseRSSXML()` 方法 - 使用 rss-parser 解析 XML +5. 实现 `getCachedFeed()` 方法 - 从 electron-store 读取缓存 +6. 实现 `refreshFeed()` 方法 - 获取并更新缓存 +7. 实现 `startAutoRefresh()` 方法 - 设置 24 小时定时刷新 +8. 实现 `getLastUpdateTime()` 方法 - 返回最后更新时间 + +**代码位置**: `src/main/rss-feed-manager.ts` (新建) + +**验收标准**: +- [ ] 单例模式正确实现 +- [ ] 成功从 `https://docs.hagicode.com/blog/rss.xml` 获取数据 +- [ ] RSS XML 正确解析为 `RSSFeedItem[]` +- [ ] 数据正确缓存到 electron-store +- [ ] 自动刷新定时器正确设置 + +--- + +### T2.2 实现 IPC 通道注册 + +**优先级**: 高 +**预计工作量**: 30 分钟 +**依赖**: T2.1 + +**描述**: +在主进程注册 RSS 相关的 IPC 处理程序。 + +**执行步骤**: +1. 编辑 `src/main/index.ts` +2. 导入 `RssFeedManager` +3. 获取管理器实例 +4. 注册 `rss-get-feed-items` 处理程序 +5. 注册 `rss-refresh-feed` 处理程序 +6. 注册 `rss-get-last-update` 处理程序 + +**代码位置**: `src/main/index.ts` (修改) + +**验收标准**: +- [ ] 所有 IPC 通道已注册 +- [ ] 通道正确调用管理器方法 +- [ ] 错误处理已实现 + +--- + +### T2.3 配置预加载脚本 + +**优先级**: 高 +**预计工作量**: 20 分钟 +**依赖**: T2.2 + +**描述**: +在预加载脚本中暴露 RSS API 给渲染进程。 + +**执行步骤**: +1. 编辑 `src/preload/index.ts` +2. 在 `electronAPI` 对象中添加 RSS 方法 +3. 添加类型定义(如需要) + +**代码位置**: `src/preload/index.ts` (修改) + +**验收标准**: +- [ ] RSS API 已暴露给渲染进程 +- [ ] 类型定义正确 +- [ ] 预加载脚本编译无错误 + +--- + +## Phase 3: 渲染进程状态管理 + +### T3.1 创建 rssFeedSlice + +**优先级**: 高 +**预计工作量**: 30 分钟 +**依赖**: T2.3 + +**描述**: +创建 Redux Toolkit slice 管理 RSS 状态。 + +**执行步骤**: +1. 创建 `src/renderer/store/slices/rssFeedSlice.ts` +2. 定义状态接口:`items`, `loading`, `error`, `lastUpdate` +3. 实现 `setItems` reducer +4. 实现 `setLoading` reducer +5. 实现 `setError` reducer +6. 实现 `setLastUpdate` reducer + +**代码位置**: `src/renderer/store/slices/rssFeedSlice.ts` (新建) + +**验收标准**: +- [ ] Slice 正确导出 +- [ ] Reducers 正确实现 +- [ ] Actions 正确导出 +- [ ] Slice 已添加到 store 配置 + +--- + +### T3.2 创建 rssFeedSaga + +**优先级**: 高 +**预计工作量**: 40 分钟 +**依赖**: T3.1 + +**描述**: +创建 Redux Saga 处理 RSS 相关的异步操作。 + +**执行步骤**: +1. 创建 `src/renderer/store/sagas/rssFeedSaga.ts` +2. 实现 `fetchFeedItemsSaga` - 获取文章列表 +3. 实现 `refreshFeedSaga` - 刷新 RSS 数据 +4. 实现 `fetchLastUpdateSaga` - 获取最后更新时间 +5. 注册 saga 监听器 + +**代码位置**: `src/renderer/store/sagas/rssFeedSaga.ts` (新建) + +**验收标准**: +- [ ] Saga 正确监听 actions +- [ ] IPC 调用正确实现 +- [ ] 状态更新正确 +- [ ] 错误处理已实现 +- [ ] Saga 已添加到 root saga + +--- + +## Phase 4: UI 组件开发 + +### T4.1 创建 BlogFeedCard 组件 + +**优先级**: 高 +**预计工作量**: 60 分钟 +**依赖**: T3.2 + +**描述**: +创建博客文章展示卡片组件。 + +**执行步骤**: +1. 创建 `src/renderer/components/BlogFeedCard.tsx` +2. 使用 shadcn/ui Card 组件作为容器 +3. 实现文章列表渲染(最多 5 条) +4. 实现每条文章的显示:标题、日期、摘要 +5. 添加刷新按钮 +6. 实现加载状态 UI +7. 实现错误状态 UI +8. 实现点击跳转功能 +9. 应用国际化翻译 + +**代码位置**: `src/renderer/components/BlogFeedCard.tsx` (新建) + +**UI 设计参考**: +``` +┌─────────────────────────────────────────┐ +│ 📰 Hagicode 博客 [刷新] │ +├─────────────────────────────────────────┤ +│ ◦ 产品发布:Hagicode v2.0 正式发布 │ +│ 2025-01-15 │ +├─────────────────────────────────────────┤ +│ ◦ 教程:如何配置自定义包源 │ +│ 2025-01-10 │ +├─────────────────────────────────────────┤ +│ ◦ 更新日志:版本 v1.9.5 修复内容 │ +│ 2025-01-05 │ +├─────────────────────────────────────────┤ +│ ◦ 技术文章:Electron 应用性能优化实践 │ +│ 2024-12-28 │ +├─────────────────────────────────────────┤ +│ ◦ 公告:服务器维护通知 │ +│ 2024-12-20 │ +└─────────────────────────────────────────┘ +``` + +**验收标准**: +- [ ] 组件正确渲染 +- [ ] 显示最多 5 条文章 +- [ ] 加载状态显示正确 +- [ ] 错误状态显示正确 +- [ ] 刷新按钮功能正常 +- [ ] 点击文章正确跳转 +- [ ] 国际化文本显示正确 + +--- + +### T4.2 集成到主页 + +**优先级**: 高 +**预计工作量**: 15 分钟 +**依赖**: T4.1 + +**描述**: +将 BlogFeedCard 组件集成到应用主页。 + +**执行步骤**: +1. 编辑 `src/renderer/components/SystemManagementView.tsx` +2. 导入 `BlogFeedCard` 组件 +3. 导入 `useAppDispatch` 和 `useAppSelector` +4. 在组件挂载时 dispatch 获取 RSS 数据的 action +5. 在适当位置添加 `BlogFeedCard` 组件 + +**代码位置**: `src/renderer/components/SystemManagementView.tsx` (修改) + +**验收标准**: +- [ ] BlogFeedCard 已添加到主页 +- [ ] 组件在挂载时自动加载数据 +- [ ] 布局正确,不影响其他组件 + +--- + +### T4.3 添加国际化翻译 + +**优先级**: 中 +**预计工作量**: 15 分钟 +**依赖**: T4.1 + +**描述**: +添加中英文翻译支持。 + +**执行步骤**: +1. 编辑 `src/renderer/i18n/locales/zh-CN/components.json` +2. 添加以下翻译键: + - `blogFeed.title` - "Hagicode 博客" + - `blogFeed.refresh` - "刷新" + - `blogFeed.loading` - "加载中..." + - `blogFeed.error` - "加载失败,请稍后重试" + - `blogFeed.noArticles` - "暂无文章" + - `blogFeed.lastUpdate` - "最后更新:{date}" + +3. 编辑 `src/renderer/i18n/locales/en-US/components.json` +4. 添加对应的英文翻译 + +**代码位置**: +- `src/renderer/i18n/locales/zh-CN/components.json` (修改) +- `src/renderer/i18n/locales/en-US/components.json` (修改) + +**验收标准**: +- [ ] 中英文翻译已添加 +- [ ] 组件正确使用翻译键 +- [ ] 语言切换时翻译正确更新 + +--- + +## Phase 5: 测试与验证 + +### T5.1 功能测试 + +**优先级**: 高 +**预计工作量**: 30 分钟 +**依赖**: T4.2 + +**描述**: +验证核心功能正常工作。 + +**测试用例**: +- [ ] 应用启动后自动加载 RSS 数据 +- [ ] 博客文章正确显示(标题、日期、摘要) +- [ ] 最多显示 5 条文章 +- [ ] 点击文章在浏览器中打开 +- [ ] 刷新按钮能手动更新数据 +- [ ] 最后更新时间正确显示 + +--- + +### T5.2 错误处理测试 + +**优先级**: 高 +**预计工作量**: 20 分钟 +**依赖**: T5.1 + +**描述**: +验证错误场景的处理。 + +**测试用例**: +- [ ] 网络断开时显示错误提示 +- [ ] RSS 源不可用时显示友好消息 +- [ ] RSS 格式错误时有降级处理 +- [ ] 加载状态正确显示 + +--- + +### T5.3 国际化验证 + +**优先级**: 中 +**预计工作量**: 10 分钟 +**依赖**: T5.1 + +**描述**: +验证国际化功能。 + +**测试用例**: +- [ ] 中文界面翻译正确 +- [ ] 英文界面翻译正确 +- [ ] 语言切换时组件正确更新 + +--- + +## 总结 + +**总任务数**: 15 +**预计总工作量**: 约 5 小时 + +**关键里程碑**: +- M1: 主进程 RSS 管理器完成 (T2.1) +- M2: IPC 通信层完成 (T2.3) +- M3: 状态管理层完成 (T3.2) +- M4: UI 组件完成 (T4.2) +- M5: 全部功能验证通过 (T5.2) + +**并行执行机会**: +- T4.3 (国际化) 可以与 T4.2 (集成到主页) 并行 +- T5.1、T5.2、T5.3 (测试任务) 可以并行执行 diff --git a/package-lock.json b/package-lock.json index 7f45d4d5..d9b4fcc3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -71,6 +71,7 @@ "react-window": "^2.2.3", "recharts": "^2.15.4", "redux-saga": "^1.4.2", + "rss-parser": "^3.13.0", "semver": "^7.7.3", "shadcn": "^3.6.2", "sonner": "^2.0.7", @@ -8207,6 +8208,15 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz", @@ -13324,6 +13334,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/rss-parser": { + "version": "3.13.0", + "resolved": "https://registry.npmmirror.com/rss-parser/-/rss-parser-3.13.0.tgz", + "integrity": "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w==", + "license": "MIT", + "dependencies": { + "entities": "^2.0.3", + "xml2js": "^0.5.0" + } + }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmmirror.com/run-applescript/-/run-applescript-7.1.0.tgz", @@ -15334,6 +15354,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmmirror.com/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz", diff --git a/package.json b/package.json index 4d1d05ab..c80937ea 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "dev:renderer": "vite", "dev:main": "tsc --watch", "dev:preload": "vite build --config vite.preload.config.ts --watch", - "dev": " npm run predev && rm -rf dist && concurrently -k \"npm run dev:renderer\" \"npm run dev:main\" \"npm run dev:preload\" \"wait-on dist/preload/index.mjs && wait-on dist/main/main.js && cross-env NODE_ENV=development HAGICO_CONFIG_PATH=local_data_root electron .\"", + "dev": "rm -rf dist && concurrently -k \"npm run dev:renderer\" \"npm run dev:main\" \"npm run dev:preload\" \"wait-on dist/preload/index.mjs && wait-on dist/main/main.js && cross-env NODE_ENV=development HAGICO_CONFIG_PATH=local_data_root electron .\"", "start": "npm run build:all && electron .", "prebuild": "npm run build:all", "build": "electron-builder", @@ -129,6 +129,7 @@ "react-window": "^2.2.3", "recharts": "^2.15.4", "redux-saga": "^1.4.2", + "rss-parser": "^3.13.0", "semver": "^7.7.3", "shadcn": "^3.6.2", "sonner": "^2.0.7", diff --git a/src/main/main.ts b/src/main/main.ts index 1cc86d1e..e88d0b75 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -15,6 +15,8 @@ import { VersionManager } from './version-manager.js'; import { PackageSourceConfigManager } from './package-source-config-manager.js'; import { LicenseManager } from './license-manager.js'; import { OnboardingManager } from './onboarding-manager.js'; +import { RSSFeedManager, DEFAULT_RSS_FEED_URL } from './rss-feed-manager.js'; +import type { RSSFeedItem } from './types/rss-types.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -60,6 +62,7 @@ let menuManager: MenuManager | null = null; let npmMirrorHelper: NpmMirrorHelper | null = null; let licenseManager: LicenseManager | null = null; let onboardingManager: OnboardingManager | null = null; +let rssFeedManager: RSSFeedManager | null = null; function createWindow(): void { console.log('[Hagicode] Creating window...'); @@ -1169,6 +1172,7 @@ ipcMain.handle('open-external', async (_event, url: string) => { '127.0.0.1', '0.0.0.0', 'hagicode.com', + 'docs.hagicode.com', 'qq.com', 'github.com', 'qm.qq.com' @@ -1186,8 +1190,8 @@ ipcMain.handle('open-external', async (_event, url: string) => { }; } - // Open external link - await shell.openExternal(url); + // Open external link with activate option to ensure browser window is focused + await shell.openExternal(url, { activate: true }); return { success: true @@ -1584,6 +1588,45 @@ ipcMain.handle('onboarding:reset', async () => { } }); +// RSS Feed IPC Handlers +ipcMain.handle('rss-get-feed-items', async () => { + if (!rssFeedManager) { + return []; + } + try { + const items = await rssFeedManager.getFeedItems(); + return items; + } catch (error) { + console.error('Failed to get RSS feed items:', error); + return []; + } +}); + +ipcMain.handle('rss-refresh-feed', async () => { + if (!rssFeedManager) { + return []; + } + try { + const items = await rssFeedManager.refreshFeed(); + return items; + } catch (error) { + console.error('Failed to refresh RSS feed:', error); + return []; + } +}); + +ipcMain.handle('rss-get-last-update', async () => { + if (!rssFeedManager) { + return null; + } + try { + return rssFeedManager.getLastUpdateTime(); + } catch (error) { + console.error('Failed to get last RSS update time:', error); + return null; + } +}); + function startStatusPolling(): void { if (statusPollingInterval) { clearInterval(statusPollingInterval); @@ -1672,6 +1715,21 @@ app.whenReady().then(async () => { log.info('[App] Onboarding Manager initialized'); } + // Initialize RSS Feed Manager + rssFeedManager = RSSFeedManager.getInstance( + { + feedUrl: DEFAULT_RSS_FEED_URL, + refreshInterval: 24 * 60 * 60 * 1000, // 24 hours + maxItems: 20, + storeKey: 'rssFeed', + }, + configManager.getStore() as unknown as Store + ); + log.info('[App] RSS Feed Manager initialized'); + + // Start auto-refresh for RSS feed + rssFeedManager.startAutoRefresh(); + // Register license sync status callback to forward to renderer licenseManager.onSyncStatus((status) => { log.info('[App] License sync status:', status); @@ -1756,6 +1814,9 @@ app.on('before-quit', async (event) => { if (webServiceManager) { await webServiceManager.cleanup(); } + if (rssFeedManager) { + rssFeedManager.destroy(); + } destroyTray(); } catch (error) { console.error('[App] Error during cleanup:', error); diff --git a/src/main/rss-feed-manager.ts b/src/main/rss-feed-manager.ts new file mode 100644 index 00000000..49bf3384 --- /dev/null +++ b/src/main/rss-feed-manager.ts @@ -0,0 +1,247 @@ +import Parser from 'rss-parser'; +import Store from 'electron-store'; +import axios from 'axios'; +import log from 'electron-log'; +import type { + RSSFeedItem, + RSSFeedCache, + RSSFeedConfig +} from './types/rss-types.js'; + +/** + * RSS Feed Manager + * Singleton class for managing RSS feed fetching, parsing, and caching + */ +export class RSSFeedManager { + private static instance: RSSFeedManager | null = null; + private parser: Parser; + private store: Store; + private config: Required; + private refreshTimer: NodeJS.Timeout | null = null; + + private constructor(config: RSSFeedConfig, store?: Store) { + this.config = { + feedUrl: config.feedUrl, + refreshInterval: config.refreshInterval ?? 24 * 60 * 60 * 1000, // 24 hours + maxItems: config.maxItems ?? 20, + storeKey: config.storeKey ?? 'rssFeed', + }; + + // Initialize parser with custom options + this.parser = new Parser({ + timeout: 10000, + customFields: { + item: ['description', 'content:encoded'], + }, + }); + + // Use provided store or create new one + this.store = store ?? new Store(); + + log.info('[RSSFeedManager] Initialized with config:', { + feedUrl: this.config.feedUrl, + refreshInterval: this.config.refreshInterval, + maxItems: this.config.maxItems, + storeKey: this.config.storeKey, + }); + } + + /** + * Get singleton instance + */ + static getInstance(config?: RSSFeedConfig, store?: Store): RSSFeedManager { + if (!RSSFeedManager.instance) { + if (!config) { + throw new Error('[RSSFeedManager] Config required for first initialization'); + } + RSSFeedManager.instance = new RSSFeedManager(config, store); + } + return RSSFeedManager.instance; + } + + /** + * Fetch RSS feed from URL + */ + private async fetchRSSFeed(): Promise { + log.info('[RSSFeedManager] Fetching RSS feed from:', this.config.feedUrl); + + const response = await axios.get(this.config.feedUrl, { + timeout: 15000, + headers: { + 'Accept': 'application/rss+xml, application/xml, text/xml', + }, + }); + + return response.data; + } + + /** + * Parse RSS XML content into feed items + */ + private async parseRSSXML(xmlContent: string): Promise { + log.info('[RSSFeedManager] Parsing RSS XML content'); + + try { + const feed = await this.parser.parseString(xmlContent); + + if (!feed.items || feed.items.length === 0) { + log.warn('[RSSFeedManager] No items found in RSS feed'); + return []; + } + + const items: RSSFeedItem[] = feed.items + .map((item: any) => ({ + title: item.title || 'Untitled', + link: item.link || '', + pubDate: item.pubDate || item.isoDate || new Date().toISOString(), + description: item.contentSnippet || item.description || '', + guid: item.guid || item.link, + contentSnippet: item.contentSnippet || item.description || '', + })) + .filter((item: RSSFeedItem) => item.link) // Filter out items without link + .slice(0, this.config.maxItems); // Limit max items + + log.info('[RSSFeedManager] Parsed', items.length, 'RSS items'); + return items; + } catch (error) { + log.error('[RSSFeedManager] Failed to parse RSS XML:', error); + throw new Error(`RSS parsing failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Get cached feed data + */ + getCachedFeed(): RSSFeedCache | null { + try { + const cached = this.store.get(this.config.storeKey) as RSSFeedCache | undefined; + if (cached) { + log.info('[RSSFeedManager] Retrieved cached feed from', cached.lastUpdate); + return cached; + } + return null; + } catch (error) { + log.error('[RSSFeedManager] Failed to get cached feed:', error); + return null; + } + } + + /** + * Save feed data to cache + */ + private saveCache(items: RSSFeedItem[]): void { + try { + const cache: RSSFeedCache = { + items, + lastUpdate: new Date().toISOString(), + }; + + this.store.set(this.config.storeKey, cache); + log.info('[RSSFeedManager] Saved', items.length, 'items to cache'); + } catch (error) { + log.error('[RSSFeedManager] Failed to save cache:', error); + } + } + + /** + * Refresh RSS feed data + */ + async refreshFeed(): Promise { + log.info('[RSSFeedManager] Refreshing RSS feed'); + + try { + // Fetch RSS data + const xmlContent = await this.fetchRSSFeed(); + + // Parse RSS data + const items = await this.parseRSSXML(xmlContent); + + // Save to cache + this.saveCache(items); + + return items; + } catch (error) { + log.error('[RSSFeedManager] Failed to refresh feed:', error); + + // Return cached data as fallback + const cached = this.getCachedFeed(); + if (cached) { + log.info('[RSSFeedManager] Returning cached data as fallback'); + return cached.items; + } + + throw error; + } + } + + /** + * Get feed items (from cache or fetch if needed) + */ + async getFeedItems(forceRefresh: boolean = false): Promise { + const cached = this.getCachedFeed(); + + // If cache exists and not forcing refresh, check if still valid + if (cached && !forceRefresh) { + const cacheAge = Date.now() - new Date(cached.lastUpdate).getTime(); + if (cacheAge < this.config.refreshInterval) { + log.info('[RSSFeedManager] Returning cached feed (age:', Math.round(cacheAge / 1000 / 60), 'minutes)'); + return cached.items; + } + } + + // Fetch fresh data + return await this.refreshFeed(); + } + + /** + * Get last update time + */ + getLastUpdateTime(): string | null { + const cached = this.getCachedFeed(); + return cached?.lastUpdate ?? null; + } + + /** + * Start auto-refresh timer + */ + startAutoRefresh(): void { + // Stop existing timer if any + this.stopAutoRefresh(); + + log.info('[RSSFeedManager] Starting auto-refresh timer (interval:', this.config.refreshInterval, 'ms)'); + + this.refreshTimer = setInterval(async () => { + log.info('[RSSFeedManager] Auto-refresh triggered'); + try { + await this.refreshFeed(); + } catch (error) { + log.error('[RSSFeedManager] Auto-refresh failed:', error); + } + }, this.config.refreshInterval); + } + + /** + * Stop auto-refresh timer + */ + stopAutoRefresh(): void { + if (this.refreshTimer) { + clearInterval(this.refreshTimer); + this.refreshTimer = null; + log.info('[RSSFeedManager] Stopped auto-refresh timer'); + } + } + + /** + * Cleanup resources + */ + destroy(): void { + this.stopAutoRefresh(); + RSSFeedManager.instance = null; + log.info('[RSSFeedManager] Destroyed'); + } +} + +/** + * Default RSS feed URL for Hagicode official blog + */ +export const DEFAULT_RSS_FEED_URL = 'https://docs.hagicode.com/blog/rss.xml'; diff --git a/src/main/types/rss-types.ts b/src/main/types/rss-types.ts new file mode 100644 index 00000000..ffffef77 --- /dev/null +++ b/src/main/types/rss-types.ts @@ -0,0 +1,46 @@ +/** + * RSS Feed Types + * Types for RSS feed data structures used in blog feed integration + */ + +/** + * Represents a single RSS feed item (blog article) + */ +export interface RSSFeedItem { + /** Article title */ + title: string; + /** URL to the full article */ + link: string; + /** Publication date in ISO 8601 format */ + pubDate: string; + /** Article description/summary */ + description: string; + /** Unique identifier (optional) */ + guid?: string; + /** Content snippet (optional, may contain HTML) */ + contentSnippet?: string; +} + +/** + * Cached RSS feed data stored in electron-store + */ +export interface RSSFeedCache { + /** Array of RSS feed items */ + items: RSSFeedItem[]; + /** ISO 8601 timestamp of last update */ + lastUpdate: string; +} + +/** + * Configuration for RSS feed manager + */ +export interface RSSFeedConfig { + /** URL of the RSS feed */ + feedUrl: string; + /** Auto-refresh interval in milliseconds (default: 24 hours) */ + refreshInterval?: number; + /** Maximum number of items to store (default: 20) */ + maxItems?: number; + /** Store key for caching */ + storeKey?: string; +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 1d5e5db5..04ab3a7e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -232,6 +232,13 @@ const electronAPI = { ipcRenderer.on('onboarding:service-progress', listener); return () => ipcRenderer.removeListener('onboarding:service-progress', listener); }, + + // RSS Feed APIs + rss: { + getFeedItems: () => ipcRenderer.invoke('rss-get-feed-items'), + refreshFeed: () => ipcRenderer.invoke('rss-refresh-feed'), + getLastUpdate: () => ipcRenderer.invoke('rss-get-last-update'), + }, }; contextBridge.exposeInMainWorld('electronAPI', electronAPI); diff --git a/src/renderer/components/BlogFeedCard.tsx b/src/renderer/components/BlogFeedCard.tsx new file mode 100644 index 00000000..3da9cf47 --- /dev/null +++ b/src/renderer/components/BlogFeedCard.tsx @@ -0,0 +1,206 @@ +import { useEffect } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { useTranslation } from 'react-i18next'; +import { motion } from 'motion/react'; +import { format } from 'date-fns'; +import { zhCN } from 'date-fns/locale'; +import { + selectRSSFeedItems, + selectRSSFeedLoading, + selectRSSFeedError, + selectRSSFeedLastUpdate, + type RSSFeedItem, +} from '../store/slices/rssFeedSlice'; +import { + fetchFeedItemsAction, + refreshFeedAction, +} from '../store/sagas/rssFeedSaga'; +import { RootState, AppDispatch } from '../store'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Loader2, RefreshCw, Newspaper, ExternalLink, Clock } from 'lucide-react'; + +const BlogFeedCard: React.FC = () => { + const { t, i18n } = useTranslation(['components', 'common']); + const dispatch = useDispatch(); + + const items = useSelector((state: RootState) => selectRSSFeedItems(state)); + const loading = useSelector(selectRSSFeedLoading); + const error = useSelector(selectRSSFeedError); + const lastUpdate = useSelector(selectRSSFeedLastUpdate); + + // Load feed items on mount + useEffect(() => { + if (items.length === 0) { + dispatch(fetchFeedItemsAction()); + } + }, [dispatch, items.length]); + + // Handle refresh button click + const handleRefresh = () => { + dispatch(refreshFeedAction()); + }; + + // Handle article click - open in browser + const handleArticleClick = (link: string) => { + window.electronAPI.openExternal(link); + }; + + // Format date based on current language + const formatDate = (dateString: string) => { + try { + const date = new Date(dateString); + const locale = i18n.language === 'zh-CN' ? zhCN : undefined; + return format(date, 'yyyy-MM-dd', { locale }); + } catch { + return dateString; + } + }; + + // Format last update time + const formatLastUpdate = (dateString: string | null) => { + if (!dateString) return null; + try { + const date = new Date(dateString); + const locale = i18n.language === 'zh-CN' ? zhCN : undefined; + return format(date, 'yyyy-MM-dd HH:mm', { locale }); + } catch { + return dateString; + } + }; + + // Strip HTML from description + const stripHtml = (html: string) => { + const tmp = document.createElement('div'); + tmp.innerHTML = html; + return tmp.textContent || tmp.innerText || ''; + }; + + // Get truncated description + const getTruncatedDescription = (description: string, maxLength: number = 100) => { + const text = stripHtml(description); + return text.length > maxLength ? text.substring(0, maxLength) + '...' : text; + }; + + // Display items (max 5) + const displayItems = items.slice(0, 5); + + return ( + + + +
+
+ + + + + {t('blogFeed.title', 'Hagicode 博客')} + +
+ +
+ {lastUpdate && ( + + + {t('blogFeed.lastUpdate', '最后更新:{{date}}', { + date: formatLastUpdate(lastUpdate), + })} + + )} +
+ + + {error && ( + +

+ {t('blogFeed.error', '加载失败,请稍后重试')} +

+

{error}

+
+ )} + + {!loading && !error && displayItems.length === 0 && ( +
+ +

+ {t('blogFeed.noArticles', '暂无文章')} +

+
+ )} + + {loading && displayItems.length === 0 && ( +
+ + + {t('blogFeed.loading', '加载中...')} + +
+ )} + + {displayItems.map((item, index) => ( + +
handleArticleClick(item.link)} + className="p-3 rounded-lg border border-border/50 hover:border-primary/50 hover:bg-accent/5 transition-all cursor-pointer" + > +
+
+

+ {item.title} +

+

+ {getTruncatedDescription(item.description)} +

+
+ + {formatDate(item.pubDate)} + +
+
+ +
+
+
+ ))} +
+
+
+ ); +}; + +export default BlogFeedCard; diff --git a/src/renderer/components/SystemManagementView.tsx b/src/renderer/components/SystemManagementView.tsx index e8f06640..25d67dd6 100644 --- a/src/renderer/components/SystemManagementView.tsx +++ b/src/renderer/components/SystemManagementView.tsx @@ -6,6 +6,7 @@ import { Package, CheckCircle, AlertCircle, Activity } from 'lucide-react'; import { useSelector, useDispatch } from 'react-redux'; import { toast } from 'sonner'; import WebServiceStatusCard from './WebServiceStatusCard'; +import BlogFeedCard from './BlogFeedCard'; import { selectWebServiceInfo } from '../store/slices/webServiceSlice'; import { resetOnboarding, checkOnboardingTrigger } from '../store/thunks/onboardingThunks'; import type { RootState } from '../store'; @@ -219,6 +220,16 @@ export default function SystemManagementView() { + {/* Blog Feed Card */} + + + + {/* Active Version Card */} {activeVersion && ( diff --git a/src/renderer/i18n/locales/en-US/components.json b/src/renderer/i18n/locales/en-US/components.json index 0b73e298..0a7c132c 100644 --- a/src/renderer/i18n/locales/en-US/components.json +++ b/src/renderer/i18n/locales/en-US/components.json @@ -345,5 +345,13 @@ "title": "About License", "description": "Global license configuration applies to all versions of Hagicode service. Default public beta license is used." } + }, + "blogFeed": { + "title": "Hagicode Blog", + "refresh": "Refresh", + "loading": "Loading...", + "error": "Failed to load, please try again later", + "noArticles": "No articles available", + "lastUpdate": "Last updated: {{date}}" } } diff --git a/src/renderer/i18n/locales/zh-CN/components.json b/src/renderer/i18n/locales/zh-CN/components.json index 5a67eb9e..919e622d 100644 --- a/src/renderer/i18n/locales/zh-CN/components.json +++ b/src/renderer/i18n/locales/zh-CN/components.json @@ -345,5 +345,13 @@ "title": "关于许可证", "description": "全局许可证配置将应用于所有版本的 Hagicode 服务。默认使用公测许可证。" } + }, + "blogFeed": { + "title": "Hagicode 博客", + "refresh": "刷新", + "loading": "加载中...", + "error": "加载失败,请稍后重试", + "noArticles": "暂无文章", + "lastUpdate": "最后更新:{{date}}" } } diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index aa185b2a..80de9c2c 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -7,12 +7,14 @@ import viewReducer from './slices/viewSlice'; import packageSourceReducer from './slices/packageSourceSlice'; import licenseReducer from './slices/licenseSlice'; import onboardingReducer from './slices/onboardingSlice'; +import rssFeedReducer from './slices/rssFeedSlice'; import { webServiceSaga, initializeWebServiceSaga } from './sagas/webServiceSaga'; import { i18nSaga, initializeI18nSaga } from './sagas/i18nSaga'; import { dependencySaga, initializeDependencySaga } from './sagas/dependencySaga'; import { viewSaga, initializeViewSaga } from './sagas/viewSaga'; import { packageSourceSaga, initializePackageSourceSaga } from './sagas/packageSourceSaga'; import { licenseSaga, initializeLicenseSaga } from './sagas/licenseSaga'; +import { rssFeedSaga, initializeRSSFeedSaga } from './sagas/rssFeedSaga'; import { checkOnboardingTrigger } from './thunks/onboardingThunks'; // Redux logger to track all actions @@ -36,6 +38,7 @@ export const store = configureStore({ packageSource: packageSourceReducer, license: licenseReducer, onboarding: onboardingReducer, + rssFeed: rssFeedReducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ @@ -77,6 +80,10 @@ store.dispatch({ type: 'license/fetch' }); // Initialize onboarding (using thunk instead of saga) store.dispatch(checkOnboardingTrigger()); +// Initialize RSS feed +sagaMiddleware.run(rssFeedSaga); +sagaMiddleware.run(initializeRSSFeedSaga); + // Infer the `RootState` and `AppDispatch` types from the store itself export type RootState = ReturnType; export type AppDispatch = typeof store.dispatch; diff --git a/src/renderer/store/sagas/rssFeedSaga.ts b/src/renderer/store/sagas/rssFeedSaga.ts new file mode 100644 index 00000000..e235a77c --- /dev/null +++ b/src/renderer/store/sagas/rssFeedSaga.ts @@ -0,0 +1,120 @@ +import { call, put, takeEvery, takeLatest } from 'redux-saga/effects'; +import { + setItems, + setLoading, + setError, + setLastUpdate, + clearError, + type RSSFeedItem, +} from '../slices/rssFeedSlice'; + +// Action types for sagas +export const FETCH_FEED_ITEMS = 'rssFeed/fetchItemsSaga'; +export const REFRESH_FEED = 'rssFeed/refreshFeedSaga'; +export const FETCH_LAST_UPDATE = 'rssFeed/fetchLastUpdateSaga'; + +// Action creators +export const fetchFeedItemsAction = () => ({ type: FETCH_FEED_ITEMS }); +export const refreshFeedAction = () => ({ type: REFRESH_FEED }); +export const fetchLastUpdateAction = () => ({ type: FETCH_LAST_UPDATE }); + +// Types for window electronAPI +declare global { + interface Window { + electronAPI: { + rss: { + getFeedItems: () => Promise; + refreshFeed: () => Promise; + getLastUpdate: () => Promise; + }; + }; + } +} + +// Saga: Fetch RSS feed items +function* fetchFeedItemsSaga() { + try { + yield put(setLoading(true)); + yield put(clearError()); + + const items: RSSFeedItem[] = yield call(window.electronAPI.rss.getFeedItems); + + yield put(setItems(items)); + yield put(setLoading(false)); + + // Also fetch last update time + const lastUpdate: string | null = yield call(window.electronAPI.rss.getLastUpdate); + if (lastUpdate) { + yield put(setLastUpdate(lastUpdate)); + } + } catch (error) { + console.error('Fetch feed items saga error:', error); + yield put(setError(error instanceof Error ? error.message : 'Failed to fetch feed items')); + yield put(setLoading(false)); + } +} + +// Saga: Refresh RSS feed +function* refreshFeedSaga() { + try { + yield put(setLoading(true)); + yield put(clearError()); + + const items: RSSFeedItem[] = yield call(window.electronAPI.rss.refreshFeed); + + yield put(setItems(items)); + yield put(setLoading(false)); + + // Also fetch last update time + const lastUpdate: string | null = yield call(window.electronAPI.rss.getLastUpdate); + if (lastUpdate) { + yield put(setLastUpdate(lastUpdate)); + } + } catch (error) { + console.error('Refresh feed saga error:', error); + yield put(setError(error instanceof Error ? error.message : 'Failed to refresh feed')); + yield put(setLoading(false)); + } +} + +// Saga: Fetch last update time +function* fetchLastUpdateSaga() { + try { + const lastUpdate: string | null = yield call(window.electronAPI.rss.getLastUpdate); + + if (lastUpdate) { + yield put(setLastUpdate(lastUpdate)); + } + } catch (error) { + console.error('Fetch last update saga error:', error); + // Don't set error for last update fetch failure + } +} + +// Root saga for RSS feed +export function* rssFeedSaga() { + // Watch for actions + yield takeLatest(FETCH_FEED_ITEMS, fetchFeedItemsSaga); + yield takeLatest(REFRESH_FEED, refreshFeedSaga); + yield takeEvery(FETCH_LAST_UPDATE, fetchLastUpdateSaga); +} + +// Initial data fetching saga +export function* initializeRSSFeedSaga() { + yield put(setLoading(true)); + + // Try to fetch initial data + try { + const items: RSSFeedItem[] = yield call(window.electronAPI.rss.getFeedItems); + yield put(setItems(items)); + + const lastUpdate: string | null = yield call(window.electronAPI.rss.getLastUpdate); + if (lastUpdate) { + yield put(setLastUpdate(lastUpdate)); + } + } catch (e) { + console.log('RSS feed not available yet'); + } finally { + yield put(setLoading(false)); + } +} diff --git a/src/renderer/store/slices/rssFeedSlice.ts b/src/renderer/store/slices/rssFeedSlice.ts new file mode 100644 index 00000000..9d9c60f9 --- /dev/null +++ b/src/renderer/store/slices/rssFeedSlice.ts @@ -0,0 +1,89 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +/** + * RSS feed item type (matches main process type) + */ +export interface RSSFeedItem { + title: string; + link: string; + pubDate: string; + description: string; + guid?: string; + contentSnippet?: string; +} + +/** + * RSS Feed state + */ +export interface RSSFeedState { + /** RSS feed items */ + items: RSSFeedItem[]; + /** Loading state */ + loading: boolean; + /** Error message */ + error: string | null; + /** Last update timestamp (ISO 8601) */ + lastUpdate: string | null; +} + +const initialState: RSSFeedState = { + items: [], + loading: false, + error: null, + lastUpdate: null, +}; + +export const rssFeedSlice = createSlice({ + name: 'rssFeed', + initialState, + reducers: { + // Set RSS feed items + setItems: (state, action: PayloadAction) => { + state.items = action.payload; + state.error = null; + }, + + // Set loading state + setLoading: (state, action: PayloadAction) => { + state.loading = action.payload; + }, + + // Set error message + setError: (state, action: PayloadAction) => { + state.error = action.payload; + state.loading = false; + }, + + // Set last update time + setLastUpdate: (state, action: PayloadAction) => { + state.lastUpdate = action.payload; + }, + + // Clear error + clearError: (state) => { + state.error = null; + }, + + // Reset state + reset: () => initialState, + }, +}); + +// Export actions +export const { + setItems, + setLoading, + setError, + setLastUpdate, + clearError, + reset, +} = rssFeedSlice.actions; + +// Selectors +export const selectRSSFeedItems = (state: { rssFeed: RSSFeedState }) => state.rssFeed.items; +export const selectRSSFeedLoading = (state: { rssFeed: RSSFeedState }) => state.rssFeed.loading; +export const selectRSSFeedError = (state: { rssFeed: RSSFeedState }) => state.rssFeed.error; +export const selectRSSFeedLastUpdate = (state: { rssFeed: RSSFeedState }) => state.rssFeed.lastUpdate; + +// Export reducer +export default rssFeedSlice.reducer;