dsh-rss 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -1,4 +1,14 @@
1
- # dsh-rss
1
+ # dsh-rss
2
+
3
+ ## 0.5.0 update (2026-10-02)
4
+
5
+ Adds **Settings → RSS subscriptions** with add/edit/remove, search, category filters, feed checks and OPML import/export. The panel and conversation tools share the existing subscription list. No migration is needed. UI labels follow the host language and appearance settings.
6
+
7
+ Import previews new feeds, duplicate changes and rejected items before confirmation. Existing labels/categories are preserved by default; updates require an explicit checkbox. Failed saves and stale previews preserve your input. Removing a feed requires confirmation of its name and URL.
8
+
9
+ Fixes partial imports of malformed XML, acceptance of invalid URLs or embedded credentials, and lost subscriptions during concurrent additions. Check history lasts for the current DSH run; importing does not fetch every feed.
10
+
11
+ All 94 Windows tests pass. Actual browser interaction in official-source Harness `0.2.0-rc.2` covers add/edit/filter/check/import/remove, Tab/Enter/Escape, two-page conflicts and restart persistence. Native desktop and narrow-screen interaction remain unverified in this run. Synthetic feeds do not establish production-network compatibility.
2
12
 
3
13
  ## 0.4.3 update (2026-10-01)
4
14
 
@@ -26,7 +36,17 @@ Fetches honor the Harness cancellation signal through DNS, response streaming, a
26
36
  dsh plugin --profile web add dsh-rss
27
37
  ```
28
38
 
29
- Restart the web service after installing. Ask your assistant to subscribe to an RSS URL or list your subscriptions; ordinary subscription management needs no configuration-file editing.
39
+ Restart the web service after installing. Ask your assistant to subscribe to an RSS URL or list your subscriptions; ordinary subscription management needs no configuration-file editing.
40
+
41
+ For the desktop profile, use `dsh plugin --profile desktop add dsh-rss` and reopen DSH after installation or updates. The shared web settings surface provides the panel in both shells. Update older installed versions through the plugin manager first.
42
+
43
+ ## Subscription panel
44
+
45
+ Open **Settings → RSS subscriptions**. New URLs are fetched and checked before saving; the name and category are optional. Renaming or recategorizing an unchanged URL works without the remote feed being online.
46
+
47
+ Choose an OPML file (up to 1 MiB) or paste its text, review the preview, then confirm. Rejected URLs include a reason. If another page or tool changed the list, preview again without retyping. Export downloads OPML without changing subscriptions.
48
+
49
+ Cards show the most recent check/fetch time, article count or error during this run. Read-only configurations allow viewing, checking and exporting.
30
50
 
31
51
  ## Uninstall
32
52
 
package/README.md CHANGED
@@ -1,6 +1,16 @@
1
1
  [English](README.en.md)
2
2
 
3
- # dsh-rss
3
+ # dsh-rss
4
+
5
+ ## 0.5.0 更新(2026-10-02)
6
+
7
+ 新增 **设置 → RSS 订阅** 面板:添加、编辑、移除、搜索、分类筛选、检查可用性与 OPML 导入导出。面板与对话工具共用原有订阅列表,不需要迁移数据。跟随 DSH 的中文/英文和外观设置。
8
+
9
+ OPML 导入先预览新增、重复和无效项,再确认保存。默认保留重复订阅的名称和分类;勾选更新时会展示变化。保存失败或预览过期时保留输入。移除订阅需要确认具体名称和地址。
10
+
11
+ 同时修复损坏 XML 被部分导入、无效/含密码 URL 被接受,以及并发添加覆盖另一笔订阅的问题。检查记录只保留在本次 DSH 运行中,导入不会自动抓取全部订阅。
12
+
13
+ 94 项 Windows 测试通过;在官方 Harness `0.2.0-rc.2` 中实际操作了添加、编辑、筛选、检查、导入、移除、Tab/Enter/Esc、双页面冲突和重启持久化。桌面原生窗口与窄屏交互本轮未完成验收,外部生产订阅未替代为本地模拟测试。
4
14
 
5
15
  ## 0.4.3 更新(2026-10-01)
6
16
 
@@ -31,7 +41,17 @@ DSH(DeepSeek Harness)的 RSS/Atom 订阅工具插件:管理订阅源,抓
31
41
  dsh plugin --profile web add dsh-rss
32
42
  ```
33
43
 
34
- 安装后重启 Web 服务即可。直接对助手说「订阅这个 RSS 地址,命名为技术资讯」或「列出我的订阅」即可使用,日常增删订阅不需要编辑配置文件。
44
+ 安装后重启 Web 服务即可。直接对助手说「订阅这个 RSS 地址,命名为技术资讯」或「列出我的订阅」即可使用,日常增删订阅不需要编辑配置文件。
45
+
46
+ 桌面版使用 `dsh plugin --profile desktop add dsh-rss`,安装或更新后重新打开 DSH。网页和桌面的共享设置页面均可显示订阅面板;已安装的旧版本请先通过插件管理器更新。
47
+
48
+ ## 订阅面板
49
+
50
+ 打开 **设置 → RSS 订阅**。添加时填写 RSS/Atom 地址,名称可留空,分类可选。新地址通过抓取检查后才会保存;编辑原地址的名称或分类不需要源网站在线。
51
+
52
+ 导入可选择不超过 1 MiB 的 OPML 文件或粘贴文本。先检查预览,再点击确认;跳过的地址会说明原因。已有订阅发生变化时重新预览即可,原输入仍保留。导出通过浏览器下载 OPML,不会修改订阅。
53
+
54
+ 列表卡片显示最近一次手动检查或工具抓取的时间和文章数,失败时显示具体原因。只读配置仍可查看、检查和导出。
35
55
 
36
56
  ## 卸载
37
57
 
package/lib/client.js ADDED
@@ -0,0 +1,202 @@
1
+ // This is the browser source. tsc does not rebuild it; check with node --check.
2
+ window.__ModuleLoader__.load({ id: 'dsh-rss', factory: require => {
3
+ const React = require('react');
4
+ const { useState, useEffect, useRef, useId } = React;
5
+ const h = React.createElement;
6
+ const ROUTE = '_dsh/dsh-rss/subscriptions';
7
+ let language = 'zh';
8
+ const listeners = new Set();
9
+ const words = {
10
+ title: ['RSS 订阅', 'RSS subscriptions'], intro: ['在这里管理订阅,也可以在对话里让 AI 读取和搜索文章。', 'Manage feeds here, then ask AI to read or search articles in a conversation.'],
11
+ add: ['添加订阅', 'Add feed'], import: ['导入 OPML', 'Import OPML'], export: ['导出 OPML', 'Export OPML'], refresh: ['刷新列表', 'Refresh list'],
12
+ search: ['搜索名称或地址', 'Search name or URL'], category: ['分类', 'Category'], all: ['全部分类', 'All categories'], uncategorized: ['未分类', 'Uncategorized'],
13
+ name: ['显示名称', 'Display name'], nameHint: ['留空时使用订阅源的标题', 'Leave blank to use the feed title'], url: ['订阅源地址', 'Feed URL'], categoryHint: ['例如:科技、阅读', 'For example: Tech, Reading'],
14
+ save: ['保存订阅', 'Save feed'], saving: ['正在处理…', 'Working…'], cancel: ['取消', 'Cancel'], close: ['关闭', 'Close'], edit: ['编辑', 'Edit'], remove: ['移除', 'Remove'], check: ['检查可用性', 'Check feed'],
15
+ empty: ['还没有订阅', 'No subscriptions yet'], emptyHint: ['添加 RSS/Atom 地址,或从阅读器导入 OPML 文件。', 'Add an RSS/Atom URL or import an OPML file from your reader.'], noMatch: ['没有匹配的订阅,试试清空筛选。', 'No matches. Try clearing the filters.'], clear: ['清空筛选', 'Clear filters'],
16
+ readonly: ['当前配置为只读。你可以查看、检查和导出订阅。', 'These settings are read-only. You can view, check and export feeds.'],
17
+ pending: ['本次运行尚未检查', 'Not checked during this run'], entries: ['篇文章', 'articles'], checked: ['检查通过', 'Check passed'], failed: ['检查失败', 'Check failed'],
18
+ statusHint: ['检查记录仅保留在本次 DSH 运行中;导入不会自动抓取所有订阅。', 'Check history lasts for this DSH run. Import does not fetch every feed.'],
19
+ deleteTitle: ['移除这个订阅?', 'Remove this feed?'], deleteHint: ['只移除订阅,不会删除原网站的文章。', 'This removes the subscription; the original articles remain on the website.'],
20
+ opml: ['OPML 文本', 'OPML text'], file: ['选择 OPML 文件', 'Choose OPML file'], importHint: ['选择文件或粘贴内容,先预览,再确认导入。最多 1 MiB。', 'Choose a file or paste its content, preview, then confirm. Maximum 1 MiB.'],
21
+ update: ['用导入文件的名称和分类更新重复订阅', 'Update duplicate names and categories from this file'], preserve: ['默认保留现有订阅的名称和分类。', 'Existing names and categories are preserved by default.'],
22
+ preview: ['预览导入', 'Preview import'], confirm: ['确认导入', 'Confirm import'], new: ['新增', 'New'], duplicates: ['重复', 'Duplicates'], invalid: ['跳过无效项', 'Invalid items skipped'], previewEmpty: ['文件中没有可导入的订阅。请确认 outline 包含 xmlUrl。', 'No importable feeds. Check that outlines contain xmlUrl.'],
23
+ keep: ['保留', 'Keep'], change: ['更新为', 'Change to'],
24
+ previewLimit: ['预览最多显示前 20 项,确认会处理全部有效项。', 'Preview shows up to 20 items. Confirmation processes all valid items.'], saved: ['订阅已保存', 'Feed saved'], removed: ['订阅已移除', 'Feed removed'], imported: ['导入完成', 'Import complete'], downloaded: ['已下载 OPML 文件', 'OPML downloaded'],
25
+ loading: ['正在读取订阅…', 'Loading subscriptions…'], retry: ['重新读取', 'Retry'], loadError: ['无法读取订阅。请确认 DSH 仍在运行,然后重试。', 'Cannot load feeds. Check that DSH is running, then retry.'],
26
+ conflict: ['订阅列表已变化,请刷新列表,再保存或重新预览。输入已保留。', 'The list has changed. Refresh it, then save or preview again. Your input is preserved.'], duplicate: ['这个地址已经订阅,请编辑现有订阅。', 'This URL is already subscribed. Edit the existing feed.'],
27
+ bodyLarge: ['文件超过 1 MiB,请拆分后导入。', 'File exceeds 1 MiB. Split it before importing.'], session: ['DSH 会话已失效,请重新打开 DSH 页面。', 'The DSH session has expired. Reopen the DSH page.'], fileError: ['无法读取文件,请重新选择或粘贴 OPML 文本。', 'Cannot read this file. Choose it again or paste OPML text.'],
28
+ };
29
+ const t = key => (words[key] || [key, key])[language === 'en' ? 1 : 0];
30
+ const CSS = `
31
+ .dshr{color:var(--dsw-alias-label-primary,#202737);font-size:14px;line-height:1.55;max-width:920px;min-width:0}
32
+ .dshr *,.dshr-dialog *{box-sizing:border-box}.dshr h2{font-size:22px;margin:0 0 6px}.dshr p{margin:0 0 12px}
33
+ .dshr-muted{color:var(--dsw-alias-label-tertiary,#657083);font-size:13px}.dshr-toolbar{display:flex;flex-wrap:wrap;gap:8px;margin:16px 0}
34
+ .dshr button,.dshr-dialog button{font:inherit;min-height:38px;padding:7px 12px;border:1px solid var(--dsw-alias-border-l2,#d9dee7);border-radius:9px;color:inherit;background:var(--dsw-alias-bg-layer-1,#fff);cursor:pointer}
35
+ .dshr button:disabled,.dshr-dialog button:disabled{opacity:.5;cursor:not-allowed}.dshr button:hover:not(:disabled),.dshr-dialog button:hover:not(:disabled){border-color:#5685d4}
36
+ .dshr .primary,.dshr-dialog .primary{background:#366fd2;color:#fff;border-color:#366fd2}.dshr-dialog .danger{background:#b93838;border-color:#b93838;color:#fff}
37
+ .dshr button:focus-visible,.dshr-dialog button:focus-visible,.dshr input:focus-visible,.dshr select:focus-visible,.dshr-dialog input:focus-visible,.dshr-dialog textarea:focus-visible{outline:2px solid #5685d4;outline-offset:2px}
38
+ .dshr-filters{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:16px}.dshr-filters label:first-child{flex:1 1 220px}.dshr label,.dshr-dialog label{display:flex;flex-direction:column;gap:5px;font-size:13px}
39
+ .dshr input,.dshr select,.dshr-dialog input,.dshr-dialog textarea{font:inherit;font-size:16px;width:100%;min-width:0;min-height:40px;border:1px solid var(--dsw-alias-border-l2,#d9dee7);border-radius:8px;padding:8px 10px;background:var(--dsw-alias-bg-layer-1,#fff);color:inherit}
40
+ .dshr-list{display:grid;gap:10px;margin:0;padding:0;list-style:none}.dshr-card{background:var(--dsw-alias-bg-layer-1,#fff);border:1px solid var(--dsw-alias-border-l2,#d9dee7);border-radius:12px;padding:14px;display:flex;flex-wrap:wrap;gap:12px}
41
+ .dshr-info{flex:1 1 240px;min-width:0}.dshr-info h3{font-size:15px;margin:0 0 4px;overflow-wrap:anywhere}.dshr-url{font-size:12px;overflow-wrap:anywhere;color:var(--dsw-alias-label-tertiary,#657083)}
42
+ .dshr-tag{display:inline-block;border-radius:6px;padding:2px 7px;background:var(--dsw-alias-bg-layer-2,#edf3fc);margin-top:7px;font-size:12px}.dshr-actions{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
43
+ .dshr-status{margin-top:8px;font-size:12px;overflow-wrap:anywhere}.dshr-alert{padding:10px 12px;border-radius:8px;margin:12px 0;overflow-wrap:anywhere;border:1px solid #dba2a2;background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-state-error-primary,#a42a2a)}
44
+ .dshr-notice{padding:9px 12px;border-left:3px solid #366fd2;margin:10px 0;background:var(--dsw-alias-bg-layer-2,#edf3fc);overflow-wrap:anywhere}.dshr-empty{text-align:center;border:1px dashed var(--dsw-alias-border-l2,#c7d2e4);border-radius:12px;padding:32px 16px}
45
+ .dshr-dialog{font:inherit;line-height:1.55;color:var(--dsw-alias-label-primary,#202737);background:var(--dsw-alias-bg-layer-1,#fff);border:1px solid var(--dsw-alias-border-l2,#d9dee7);border-radius:14px;padding:22px;max-width:620px;width:calc(100% - 28px);max-height:calc(100dvh - 28px);overflow:auto;box-shadow:0 18px 70px #0003}
46
+ .dshr-dialog::backdrop{background:#14203366}.dshr-dialog h3{font-size:19px;margin:0 0 12px}.dshr-dialog form{display:grid;gap:14px}.dshr-dialog textarea{min-height:140px;resize:vertical}.dshr-dialog p{margin:0}.dshr-dialog .dshr-buttons{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px;margin-top:14px}
47
+ .dshr-dialog label.dshr-checkbox{display:flex;flex-direction:row;align-items:flex-start;gap:8px}.dshr-checkbox input{width:18px;height:18px;min-height:18px;margin-top:3px;flex-shrink:0}.dshr-preview{border-top:1px solid var(--dsw-alias-border-l2,#d9dee7);padding-top:12px;overflow-wrap:anywhere}.dshr-preview ul{margin:8px 0;padding-left:20px}.dshr-preview li{margin-bottom:5px}
48
+ @media(max-width:520px){.dshr-dialog{padding:16px}.dshr-actions{width:100%}.dshr-filters label{width:100%}.dshr-toolbar button{flex:1 1 auto}.dshr-card{padding:12px}}
49
+ `;
50
+ async function request(action, args, signal) {
51
+ const response = await fetch(new URL(ROUTE, document.baseURI), action ? {
52
+ method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, ...args }), signal,
53
+ } : { credentials: 'same-origin', signal });
54
+ let value;
55
+ try { value = await response.json(); } catch { throw new Error(response.status === 401 ? t('session') : t('loadError')); }
56
+ if (!response.ok) {
57
+ const error = new Error(['conflict', 'duplicate', 'readonly'].includes(value.code) ? t(value.code) : value.error || t('loadError'));
58
+ error.snapshot = value.snapshot;
59
+ throw error;
60
+ }
61
+ return value;
62
+ }
63
+ function Dialog({ title, busy, onClose, children }) {
64
+ const ref = useRef(null);
65
+ const id = useId();
66
+ useEffect(() => { ref.current.showModal(); return () => ref.current?.close(); }, []);
67
+ return h('dialog', { ref, className: 'dshr-dialog', 'aria-labelledby': id,
68
+ onKeyDownCapture: event => {
69
+ if (event.nativeEvent.isComposing) return;
70
+ if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); if (!busy) onClose(); }
71
+ if (event.key === 'Tab') {
72
+ event.stopPropagation();
73
+ const targets = [...event.currentTarget.querySelectorAll('input:not([disabled]),textarea:not([disabled]),select:not([disabled]),button:not([disabled])')];
74
+ const first = targets[0], last = targets[targets.length - 1];
75
+ if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
76
+ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
77
+ }
78
+ },
79
+ onCancel: event => { event.preventDefault(); event.stopPropagation(); if (!busy) onClose(); } },
80
+ h('h3', { id }, title), children);
81
+ }
82
+ function Panel() {
83
+ const [, setLanguage] = useState(language);
84
+ const [state, setState] = useState(null), [loading, setLoading] = useState(true), [busy, setBusy] = useState('');
85
+ const [error, setError] = useState(''), [notice, setNotice] = useState(''), [query, setQuery] = useState(''), [category, setCategory] = useState('*');
86
+ const [modal, setModal] = useState(null), [draft, setDraft] = useState({ url: '', name: '', category: '' });
87
+ const [opml, setOpml] = useState(''), [updateExisting, setUpdateExisting] = useState(false), [preview, setPreview] = useState(null);
88
+ const controller = useRef(null), trigger = useRef(null);
89
+ useEffect(() => { listeners.add(setLanguage); return () => listeners.delete(setLanguage); }, []);
90
+ useEffect(() => { void run(null); return () => controller.current?.abort(); }, []);
91
+ async function run(action, args = {}, onSuccess) {
92
+ if (controller.current) return;
93
+ const abort = new AbortController(); controller.current = abort;
94
+ setBusy(action === 'check' && args.id ? args.id : action || 'load'); setError(''); setNotice('');
95
+ try {
96
+ const value = await request(action, args, abort.signal);
97
+ if (value.snapshot) setState(value.snapshot);
98
+ if (onSuccess) onSuccess(value);
99
+ return value;
100
+ } catch (failure) {
101
+ if (!abort.signal.aborted) { if (failure.snapshot) setState(failure.snapshot); setError(failure.message || t('loadError')); }
102
+ } finally { if (!abort.signal.aborted) { setBusy(''); setLoading(false); } controller.current = null; }
103
+ }
104
+ function close() { setModal(null); setError(''); setPreview(null); queueMicrotask(() => trigger.current?.focus()); }
105
+ function open(type, feed, event) {
106
+ trigger.current = event.currentTarget; setError(''); setNotice(''); setPreview(null);
107
+ if (type === 'feed') setDraft(feed ? { url: feed.url, name: feed.name, category: feed.category } : { url: '', name: '', category: '' });
108
+ if (type === 'import') { setOpml(''); setUpdateExisting(false); }
109
+ setModal({ type, feed });
110
+ }
111
+ async function chooseFile(event) {
112
+ const file = event.target.files?.[0];
113
+ event.target.value = '';
114
+ if (!file) return;
115
+ if (file.size > 1024 * 1024) { setError(t('bodyLarge')); return; }
116
+ try { setOpml(await file.text()); setPreview(null); setError(''); } catch { setError(t('fileError')); }
117
+ }
118
+ const field = (key, hint) => h('label', { key }, t(key), h('input', {
119
+ name: key, type: key === 'url' ? 'url' : 'text', value: draft[key], placeholder: hint, required: key === 'url', disabled: !!busy,
120
+ autoFocus: key === 'url', onChange: event => setDraft({ ...draft, [key]: event.target.value }),
121
+ }));
122
+ const buttons = submit => h('div', { className: 'dshr-buttons' },
123
+ h('button', { type: 'button', disabled: !!busy, onClick: close }, t('cancel')), submit);
124
+ const alert = error ? h('div', { role: 'alert', className: 'dshr-alert' }, error) : null;
125
+ const feeds = state?.feeds || [], writable = state?.writable === true;
126
+ const categories = [...new Set(feeds.map(feed => feed.category))].sort();
127
+ const filtered = feeds.filter(feed => (category === '*' || feed.category === category) && (feed.name + ' ' + feed.url).toLowerCase().includes(query.trim().toLowerCase()));
128
+ return h('section', { className: 'dshr', 'aria-label': t('title') },
129
+ h('h2', null, t('title')), h('p', { className: 'dshr-muted' }, t('intro')),
130
+ state && !writable ? h('p', { className: 'dshr-notice' }, t('readonly')) : null,
131
+ h('div', { className: 'dshr-toolbar' },
132
+ h('button', { className: 'primary', disabled: !writable || !!busy, onClick: event => open('feed', null, event) }, t('add')),
133
+ h('button', { disabled: !writable || !!busy, onClick: event => open('import', null, event) }, t('import')),
134
+ h('button', { disabled: !state || !!busy, onClick: () => run('export', {}, value => {
135
+ const url = URL.createObjectURL(new Blob([value.opml], { type: 'text/x-opml;charset=utf-8' }));
136
+ const a = document.createElement('a'); a.href = url; a.download = 'rss-subscriptions.opml'; a.click();
137
+ setTimeout(() => URL.revokeObjectURL(url), 10000); setNotice(t('downloaded'));
138
+ }) }, t('export')),
139
+ h('button', { disabled: !!busy, onClick: () => run(null) }, t('refresh'))),
140
+ !modal ? alert : null, notice ? h('div', { role: 'status', className: 'dshr-notice' }, notice) : null,
141
+ loading ? h('p', { role: 'status' }, t('loading')) : !state ? h('button', { disabled: !!busy, onClick: () => run(null) }, t('retry')) :
142
+ h(React.Fragment, null,
143
+ feeds.length ? h('div', { className: 'dshr-filters' },
144
+ h('label', null, t('search'), h('input', { type: 'search', value: query, onChange: event => setQuery(event.target.value) })),
145
+ h('label', null, t('category'), h('select', { value: category, onChange: event => setCategory(event.target.value) },
146
+ h('option', { value: '*' }, t('all')), categories.map(value => h('option', { key: value, value }, value || t('uncategorized')))))) : null,
147
+ !feeds.length ? h('div', { className: 'dshr-empty' }, h('strong', null, t('empty')), h('p', { className: 'dshr-muted' }, t('emptyHint'))) :
148
+ !filtered.length ? h('div', { className: 'dshr-empty' }, h('p', null, t('noMatch')), h('button', { onClick: () => { setQuery(''); setCategory('*'); } }, t('clear'))) :
149
+ h('ul', { className: 'dshr-list' }, filtered.map(feed => h('li', { key: feed.id, className: 'dshr-card', 'aria-label': feed.name || feed.url },
150
+ h('div', { className: 'dshr-info' }, h('h3', null, feed.name || feed.url), h('div', { className: 'dshr-url' }, feed.url),
151
+ h('span', { className: 'dshr-tag' }, feed.category || t('uncategorized')),
152
+ h('div', { className: 'dshr-status' }, feed.check ? [
153
+ new Date(feed.check.checkedAt).toLocaleString(language === 'en' ? 'en-US' : 'zh-CN'), ' · ',
154
+ feed.check.ok ? feed.check.entryCount + ' ' + t('entries') + ' · ' + t('checked') : t('failed') + ':' + feed.check.error,
155
+ ] : t('pending'))),
156
+ h('div', { className: 'dshr-actions' },
157
+ h('button', { disabled: !!busy, onClick: () => run('check', { id: feed.id }, value => setNotice(t('checked') + ' · ' + value.check.entryCount + ' ' + t('entries'))) }, busy === feed.id ? t('saving') : t('check')),
158
+ h('button', { disabled: !writable || !!busy, onClick: event => open('feed', feed, event) }, t('edit')),
159
+ h('button', { disabled: !writable || !!busy, onClick: event => open('remove', feed, event) }, t('remove')))))),
160
+ feeds.length ? h('p', { className: 'dshr-muted', style: { marginTop: 12 } }, t('statusHint')) : null),
161
+ modal ? h(Dialog, { title: modal.type === 'import' ? t('import') : modal.type === 'remove' ? t('deleteTitle') : modal.feed ? t('edit') : t('add'), busy: !!busy, onClose: close },
162
+ alert,
163
+ modal.type === 'feed' ? h('form', { onSubmit: event => { event.preventDefault(); void run(modal.feed ? 'edit' : 'add', { ...draft, id: modal.feed?.id, revision: state.revision }, () => { close(); setNotice(t('saved')); }); } },
164
+ field('url', 'https://example.com/feed.xml'), field('name', t('nameHint')), field('category', t('categoryHint')),
165
+ error ? h('button', { type: 'button', disabled: !!busy, onClick: () => run(null) }, t('refresh')) : null,
166
+ buttons(h('button', { type: 'submit', className: 'primary', disabled: !!busy }, t(busy ? 'saving' : 'save')))) :
167
+ modal.type === 'remove' ? h('div', null, h('strong', null, modal.feed.name || modal.feed.url), h('p', { className: 'dshr-url' }, modal.feed.url), h('p', null, t('deleteHint')),
168
+ buttons(h('button', { className: 'danger', disabled: !!busy, onClick: () => run('remove', { id: modal.feed.id, revision: state.revision }, () => { close(); setNotice(t('removed')); }) }, t(busy ? 'saving' : 'remove')))) :
169
+ h('form', { onSubmit: event => { event.preventDefault(); void run('preview', { opml, updateExisting }, value => setPreview(value.preview)); } },
170
+ h('p', { className: 'dshr-muted' }, t('importHint')), h('label', null, t('file'), h('input', { type: 'file', accept: '.opml,.xml,text/xml,application/xml,text/x-opml', disabled: !!busy, onChange: chooseFile })),
171
+ h('label', null, t('opml'), h('textarea', { required: true, autoFocus: true, value: opml, disabled: !!busy, onChange: event => { setOpml(event.target.value); setPreview(null); } })),
172
+ h('label', { className: 'dshr-checkbox' }, h('input', { type: 'checkbox', checked: updateExisting, disabled: !!busy, onChange: event => { setUpdateExisting(event.target.checked); setPreview(null); } }), t('update')),
173
+ h('p', { className: 'dshr-muted' }, t('preserve')),
174
+ preview ? h('div', { className: 'dshr-preview', role: 'status' },
175
+ h('strong', null, `${t('new')} ${preview.addedCount} · ${t('duplicates')} ${preview.existedCount} · ${t('invalid')} ${preview.skippedCount}`),
176
+ !preview.addedCount && !preview.existedCount ? h('p', null, t('previewEmpty')) : null,
177
+ h('ul', null, preview.added.slice(0, 20).map(feed => h('li', { key: feed.url }, t('new'), ':', feed.name || feed.url, ' · ', feed.category || t('uncategorized'))),
178
+ (preview.duplicates || []).slice(0, 20).map((item, index) => h('li', { key: 'duplicate-' + index },
179
+ t('duplicates'), ':', item.before.name || item.url, ' · ', item.before.category || t('uncategorized'),
180
+ updateExisting ? ' → ' + (item.after.name || item.url) + ' · ' + (item.after.category || t('uncategorized')) : ' · ' + t('keep'))),
181
+ preview.skipped.slice(0, 20).map((item, index) => h('li', { key: index }, item.title, ' · ', item.url, ':', item.reason))),
182
+ preview.added.length > 20 || preview.skipped.length > 20 || (preview.duplicates || []).length > 20 ? h('p', { className: 'dshr-muted' }, t('previewLimit')) : null) : null,
183
+ h('div', { className: 'dshr-buttons' }, h('button', { type: 'button', disabled: !!busy, onClick: close }, t('cancel')),
184
+ h('button', { type: 'submit', disabled: !!busy || !opml.trim() }, t(busy === 'preview' ? 'saving' : 'preview')),
185
+ preview ? h('button', { type: 'button', className: 'primary', disabled: !!busy || !(preview.addedCount || updateExisting && preview.existedCount), onClick: () => run('import', { opml, updateExisting, revision: preview.revision, token: preview.token }, value => {
186
+ close(); setNotice(`${t('imported')} · ${t('new')} ${value.result.addedCount} · ${t('duplicates')} ${value.result.existedCount} · ${t('invalid')} ${value.result.skippedCount}`);
187
+ }) }, t(busy === 'import' ? 'saving' : 'confirm')) : null))) : null);
188
+ }
189
+ function apply(ctx) {
190
+ ctx.effect(() => {
191
+ const service = ctx.get ? ctx.get('locale') : ctx.locale;
192
+ const update = () => { language = String(service?.getSnapshot?.().active || 'zh').startsWith('en') ? 'en' : 'zh'; for (const notify of listeners) notify(language); };
193
+ update(); return service?.subscribe?.(update) || (() => {});
194
+ }, 'dsh-rss: locale');
195
+ ctx.effect(() => {
196
+ const style = document.createElement('style'); style.dataset.pluginCss = 'dsh-rss/client'; style.textContent = CSS; document.head.appendChild(style);
197
+ return () => style.remove();
198
+ }, 'dsh-rss: styles');
199
+ ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'dsh-rss', order: 55, label: () => t('title'), inject: () => ({}) }, Panel));
200
+ }
201
+ return { apply, inject: ['slots', 'locale'] };
202
+ } });
package/lib/index.d.ts CHANGED
@@ -28,6 +28,7 @@ type RssPreExecuteListener = (exec: RssPendingToolExecution, next: () => Promise
28
28
  /** 插件所需的最小 ctx 面(社区插件不依赖宿主内部类型)。 */
29
29
  export interface RssPluginContext {
30
30
  settings: {
31
+ readonly writable?: boolean;
31
32
  register?(ns: string, schema: unknown, options?: {
32
33
  base?: Record<string, unknown>;
33
34
  applies?: string;
@@ -41,6 +42,7 @@ export interface RssPluginContext {
41
42
  };
42
43
  };
43
44
  };
45
+ inject?: Function;
44
46
  tools: {
45
47
  register(definition: RssToolDefinition): () => void;
46
48
  };
@@ -60,3 +62,4 @@ export * from './parser.js';
60
62
  export * from './proxy-fetch.js';
61
63
  export * from './settings.js';
62
64
  export * from './tools.js';
65
+ export * from './web.js';
package/lib/index.js CHANGED
@@ -10,6 +10,7 @@ import { resolveConfig } from './config.js';
10
10
  import { RSS_SETTINGS_NAMESPACE, RssSettingsSchema, liveConfig } from './settings.js';
11
11
  import { buildRssTools } from './tools.js';
12
12
  import { installLegacySettingsImport } from './legacy-settings.js';
13
+ import { createRssSettingsBackend, installRssSettingsWeb } from './web.js';
13
14
  /** cordis 服务注入:apply 里要用 ctx.settings 与 ctx.tools,必须显式声明,否则宿主会抛 cannot get property without inject。 */
14
15
  export const name = 'rss';
15
16
  export const inject = ['settings', 'tools'];
@@ -34,6 +35,7 @@ export function apply(ctx, config) {
34
35
  },
35
36
  };
36
37
  const disposers = [];
38
+ installRssSettingsWeb(ctx, createRssSettingsBackend(cfg, settingsScope, { writable: () => ctx.settings.writable !== false }));
37
39
  for (const definition of buildRssTools(cfg, settingsScope)) {
38
40
  disposers.push(ctx.tools.register(definition));
39
41
  }
@@ -63,3 +65,4 @@ export * from './parser.js';
63
65
  export * from './proxy-fetch.js';
64
66
  export * from './settings.js';
65
67
  export * from './tools.js';
68
+ export * from './web.js';
package/lib/opml.d.ts CHANGED
@@ -27,6 +27,11 @@ export interface OpmlImportResult {
27
27
  existedCount: number;
28
28
  skippedCount: number;
29
29
  skipped: OpmlSkipped[];
30
+ duplicates: Array<{
31
+ url: string;
32
+ before: Feed;
33
+ after: Feed;
34
+ }>;
30
35
  }
31
36
  /** OPML 文档解析结果。 */
32
37
  export interface OpmlDocument {
@@ -40,4 +45,4 @@ export declare function parseOpml(text: string): OpmlDocument;
40
45
  /** 把订阅列表序列化为 OPML 2.0 文本。 */
41
46
  export declare function buildOpml(feeds: Feed[]): string;
42
47
  /** 把 OPML 导入合并进现有订阅(按 url 去重),非法项跳过。 */
43
- export declare function importOpmlFeeds(current: Feed[], document: OpmlDocument): OpmlImportResult;
48
+ export declare function importOpmlFeeds(current: Feed[], document: OpmlDocument, updateExisting?: boolean): OpmlImportResult;
package/lib/opml.js CHANGED
@@ -6,8 +6,9 @@
6
6
  *
7
7
  * @module dsh-rss/opml
8
8
  */
9
- import { XMLParser } from 'fast-xml-parser';
10
- import { addFeed } from './feeds.js';
9
+ import { XMLParser, XMLValidator } from 'fast-xml-parser';
10
+ import { addFeed, sameFeedUrl } from './feeds.js';
11
+ import { assertHttpUrl } from './network.js';
11
12
  const opmlParser = new XMLParser({
12
13
  ignoreAttributes: false,
13
14
  attributeNamePrefix: '@_',
@@ -42,6 +43,9 @@ export function parseOpml(text) {
42
43
  const trimmed = (text ?? '').trim();
43
44
  if (trimmed === '')
44
45
  throw new Error('OPML 内容为空,请提供 OPML XML 文本。');
46
+ const validation = XMLValidator.validate(trimmed);
47
+ if (validation !== true)
48
+ throw new Error('OPML 解析失败:第 ' + validation.err.line + ' 行,' + validation.err.msg);
45
49
  let doc;
46
50
  try {
47
51
  doc = opmlParser.parse(trimmed);
@@ -120,21 +124,36 @@ export function buildOpml(feeds) {
120
124
  }
121
125
  const HTTP_URL = /^https?:\/\//i;
122
126
  /** 把 OPML 导入合并进现有订阅(按 url 去重),非法项跳过。 */
123
- export function importOpmlFeeds(current, document) {
127
+ export function importOpmlFeeds(current, document, updateExisting = true) {
124
128
  const feeds = [...current];
125
129
  const added = [];
126
130
  const skipped = [];
131
+ const duplicates = [];
127
132
  let existedCount = 0;
128
133
  for (const outline of document.outlines) {
129
134
  if (!HTTP_URL.test(outline.url)) {
130
135
  skipped.push({ title: outline.name, url: outline.url, reason: 'URL 不是 http(s) 地址' });
131
136
  continue;
132
137
  }
138
+ try {
139
+ assertHttpUrl(outline.url);
140
+ }
141
+ catch (error) {
142
+ skipped.push({ title: outline.name, url: outline.url, reason: error instanceof Error ? error.message : String(error) });
143
+ continue;
144
+ }
145
+ const previous = feeds.find(feed => sameFeedUrl(feed.url, outline.url));
146
+ if (!updateExisting && previous) {
147
+ existedCount += 1;
148
+ duplicates.push({ url: outline.url, before: previous, after: previous });
149
+ continue;
150
+ }
133
151
  const before = feeds.length;
134
152
  const outcome = addFeed(feeds, outline.url, outline.name, outline.category);
135
153
  feeds.splice(0, feeds.length, ...outcome.feeds);
136
154
  if (outcome.existed) {
137
155
  existedCount += 1;
156
+ duplicates.push({ url: outline.url, before: previous, after: outcome.added });
138
157
  }
139
158
  else if (feeds.length > before) {
140
159
  added.push(outcome.added);
@@ -147,5 +166,6 @@ export function importOpmlFeeds(current, document) {
147
166
  existedCount,
148
167
  skippedCount: skipped.length,
149
168
  skipped,
169
+ duplicates,
150
170
  };
151
171
  }
@@ -0,0 +1,33 @@
1
+ import { type Feed } from './feeds.js';
2
+ import type { RssSettingsScope } from './tools.js';
3
+ import type { ResolvedRssConfig } from './config.js';
4
+ import type { RssToolExecution } from './execution.js';
5
+ import { type FetchLike, type DnsLookupLike } from './network.js';
6
+ export declare class SubscriptionError extends Error {
7
+ readonly status: number;
8
+ readonly code: string;
9
+ constructor(message: string, status?: number, code?: string);
10
+ }
11
+ export declare const feedId: (url: string) => string;
12
+ export declare function subscriptionSnapshot(scope: RssSettingsScope): {
13
+ feeds: Feed[];
14
+ revision: string;
15
+ };
16
+ /** Read inside the queue: concurrent tool/UI writes cannot overwrite one another. */
17
+ export declare function mutateSubscriptions<T>(scope: RssSettingsScope, mutate: (feeds: Feed[]) => {
18
+ feeds: Feed[];
19
+ value: T;
20
+ }, expectedRevision?: string): Promise<T>;
21
+ export interface FeedCheck {
22
+ checkedAt: string;
23
+ ok: boolean;
24
+ title?: string;
25
+ entryCount?: number;
26
+ error?: string;
27
+ }
28
+ export declare function recentFeedChecks(scope: RssSettingsScope): Map<string, FeedCheck>;
29
+ /** Same network boundary, parsing and status for tool calls and manual checks. */
30
+ export declare function readRssFeed(scope: RssSettingsScope, url: string, config: ResolvedRssConfig, exec?: RssToolExecution, fetchImpl?: FetchLike, lookupImpl?: DnsLookupLike): Promise<{
31
+ parsed: import("./parser.js").ParsedFeed;
32
+ finalUrl: string;
33
+ }>;
@@ -0,0 +1,76 @@
1
+ /** Shared subscription storage for tools and the settings panel. */
2
+ import { createHash } from 'node:crypto';
3
+ import { parseFeedsYaml, serializeFeeds } from './feeds.js';
4
+ import { fetchFeedXml } from './network.js';
5
+ import { parseFeed } from './parser.js';
6
+ export class SubscriptionError extends Error {
7
+ status;
8
+ code;
9
+ constructor(message, status = 400, code = 'invalid') {
10
+ super(message);
11
+ this.status = status;
12
+ this.code = code;
13
+ }
14
+ }
15
+ const writes = new WeakMap();
16
+ export const feedId = (url) => createHash('sha256').update(url).digest('hex');
17
+ export function subscriptionSnapshot(scope) {
18
+ const value = scope.get();
19
+ const yaml = value && typeof value.feedsYaml === 'string' ? value.feedsYaml : '';
20
+ return { feeds: parseFeedsYaml(yaml), revision: feedId(yaml) };
21
+ }
22
+ /** Read inside the queue: concurrent tool/UI writes cannot overwrite one another. */
23
+ export async function mutateSubscriptions(scope, mutate, expectedRevision) {
24
+ const task = (writes.get(scope) ?? Promise.resolve()).catch(() => { }).then(async () => {
25
+ const snapshot = subscriptionSnapshot(scope);
26
+ if (expectedRevision !== undefined && snapshot.revision !== expectedRevision) {
27
+ throw new SubscriptionError('订阅列表已经变化。请刷新列表后重新预览或保存;你的输入已保留。', 409, 'conflict');
28
+ }
29
+ const result = mutate(snapshot.feeds);
30
+ try {
31
+ await scope.update({ feedsYaml: serializeFeeds(result.feeds) });
32
+ }
33
+ catch (error) {
34
+ throw new SubscriptionError('写入订阅配置失败(可能已被其他会话修改,请重试):' + (error instanceof Error ? error.message : String(error)), 503, 'save_failed');
35
+ }
36
+ return result.value;
37
+ });
38
+ writes.set(scope, task);
39
+ try {
40
+ return await task;
41
+ }
42
+ finally {
43
+ if (writes.get(scope) === task)
44
+ writes.delete(scope);
45
+ }
46
+ }
47
+ const checks = new WeakMap();
48
+ export function recentFeedChecks(scope) {
49
+ let map = checks.get(scope);
50
+ if (!map) {
51
+ map = new Map();
52
+ checks.set(scope, map);
53
+ }
54
+ return map;
55
+ }
56
+ /** Same network boundary, parsing and status for tool calls and manual checks. */
57
+ export async function readRssFeed(scope, url, config, exec, fetchImpl, lookupImpl) {
58
+ const map = recentFeedChecks(scope);
59
+ const save = (check) => {
60
+ map.delete(url);
61
+ map.set(url, check);
62
+ if (map.size > 256)
63
+ map.delete(map.keys().next().value);
64
+ };
65
+ try {
66
+ const { xml, finalUrl } = await fetchFeedXml(url, config, exec, fetchImpl, lookupImpl);
67
+ const parsed = parseFeed(xml, finalUrl);
68
+ save({ checkedAt: new Date().toISOString(), ok: true, title: parsed.feed.title, entryCount: parsed.entries.length });
69
+ return { parsed, finalUrl };
70
+ }
71
+ catch (error) {
72
+ if (!exec?.signal.aborted)
73
+ save({ checkedAt: new Date().toISOString(), ok: false, error: error instanceof Error ? error.message : String(error) });
74
+ throw error;
75
+ }
76
+ }
package/lib/tools.js CHANGED
@@ -5,10 +5,11 @@
5
5
  * @module dsh-rss/tools
6
6
  */
7
7
  import { writeFile } from 'node:fs/promises';
8
- import { addFeed, findFeedsByName, parseFeedsYaml, removeFeed, sameFeedUrl, serializeFeeds } from './feeds.js';
8
+ import { addFeed, findFeedsByName, removeFeed, sameFeedUrl } from './feeds.js';
9
9
  import { buildOpml, importOpmlFeeds, parseOpml } from './opml.js';
10
10
  import { parseFeed } from './parser.js';
11
- import { assertHttpUrl, fetchFeedXml } from './network.js';
11
+ import { assertHttpUrl } from './network.js';
12
+ import { subscriptionSnapshot, mutateSubscriptions, readRssFeed } from './subscriptions.js';
12
13
  import { resolveOpmlOutputPath } from './workspace.js';
13
14
  export { isBlockedNetworkAddress } from './network.js';
14
15
  const TIMEOUT_MS = 30000;
@@ -405,19 +406,8 @@ function renderHealth(_args, value) {
405
406
  */
406
407
  export function buildRssTools(config, settingsScope, fetchImpl, lookupImpl) {
407
408
  const cfg = config;
408
- const getFeeds = () => {
409
- const value = settingsScope.get();
410
- const yamlText = value !== null && typeof value === 'object' && typeof value.feedsYaml === 'string' ? value.feedsYaml : '';
411
- return parseFeedsYaml(yamlText);
412
- };
413
- const persistFeeds = async (feeds) => {
414
- try {
415
- await settingsScope.update({ feedsYaml: serializeFeeds(feeds) });
416
- }
417
- catch (error) {
418
- throw new Error('写入订阅配置失败(可能已被其他会话修改,请重试):' + (error instanceof Error ? error.message : String(error)));
419
- }
420
- };
409
+ const getFeeds = () => subscriptionSnapshot(settingsScope).feeds;
410
+ const readFeed = (url, exec) => readRssFeed(settingsScope, url, cfg, exec, fetchImpl, lookupImpl);
421
411
  const rssList = {
422
412
  name: 'rss_list',
423
413
  description: '列出已订阅的 RSS/Atom 源(存在 settings 的 dsh-rss.feedsYaml 里,可用 rss_add / rss_remove 管理)。返回每个订阅的 url、name、category 与总数。',
@@ -450,12 +440,12 @@ export function buildRssTools(config, settingsScope, fetchImpl, lookupImpl) {
450
440
  assertHttpUrl(url);
451
441
  const name = optionalString(args, 'name') ?? '';
452
442
  const category = optionalString(args, 'category') ?? '';
453
- const { xml, finalUrl } = await fetchFeedXml(url, cfg, exec, fetchImpl, lookupImpl);
454
- const parsed = parseFeed(xml, finalUrl);
443
+ const { parsed } = await readFeed(url, exec);
455
444
  const effectiveName = name !== '' ? name : parsed.feed.title;
456
- const { feeds, added, existed } = addFeed(getFeeds(), url, effectiveName, category);
457
- await persistFeeds(feeds);
458
- return { added, existed, count: feeds.length, feedTitle: parsed.feed.title, feedType: parsed.feed.feedType };
445
+ return mutateSubscriptions(settingsScope, current => {
446
+ const { feeds, added, existed } = addFeed(current, url, effectiveName, category);
447
+ return { feeds, value: { added, existed, count: feeds.length, feedTitle: parsed.feed.title, feedType: parsed.feed.feedType } };
448
+ });
459
449
  },
460
450
  timeoutMs: cfg.timeoutMs,
461
451
  };
@@ -472,9 +462,10 @@ export function buildRssTools(config, settingsScope, fetchImpl, lookupImpl) {
472
462
  },
473
463
  async execute(rawArgs) {
474
464
  const args = asRecord(rawArgs);
475
- const { feeds, removed } = removeFeed(getFeeds(), optionalString(args, 'url'), optionalString(args, 'name'));
476
- await persistFeeds(feeds);
477
- return { removed, count: feeds.length };
465
+ return mutateSubscriptions(settingsScope, current => {
466
+ const { feeds, removed } = removeFeed(current, optionalString(args, 'url'), optionalString(args, 'name'));
467
+ return { feeds, value: { removed, count: feeds.length } };
468
+ });
478
469
  },
479
470
  timeoutMs: TIMEOUT_MS,
480
471
  };
@@ -523,8 +514,7 @@ export function buildRssTools(config, settingsScope, fetchImpl, lookupImpl) {
523
514
  assertHttpUrl(url);
524
515
  const limit = clampedInteger(args, 'limit', 20, 1, 100);
525
516
  const incremental = args.incremental === true;
526
- const { xml, finalUrl } = await fetchFeedXml(url, cfg, exec, fetchImpl, lookupImpl);
527
- const parsed = parseFeed(xml, finalUrl);
517
+ const { parsed, finalUrl } = await readFeed(url, exec);
528
518
  if (!incremental) {
529
519
  const entries = parsed.entries.slice(0, limit);
530
520
  return { url: finalUrl, feed: parsed.feed, entries, truncated: parsed.entries.length > limit };
@@ -552,8 +542,7 @@ export function buildRssTools(config, settingsScope, fetchImpl, lookupImpl) {
552
542
  const args = asRecord(rawArgs);
553
543
  const url = requiredString(args, 'url', '订阅源地址');
554
544
  assertHttpUrl(url);
555
- const { xml, finalUrl } = await fetchFeedXml(url, cfg, exec, fetchImpl, lookupImpl);
556
- const parsed = parseFeed(xml, finalUrl);
545
+ const { parsed, finalUrl } = await readFeed(url, exec);
557
546
  return {
558
547
  ok: true,
559
548
  url: finalUrl,
@@ -605,16 +594,14 @@ export function buildRssTools(config, settingsScope, fetchImpl, lookupImpl) {
605
594
  const args = asRecord(rawArgs);
606
595
  const opmlText = requiredString(args, 'opml', 'OPML 文本');
607
596
  const document = parseOpml(opmlText);
608
- const outcome = importOpmlFeeds(getFeeds(), document);
609
- await persistFeeds(outcome.feeds);
610
- return {
611
- addedCount: outcome.addedCount,
612
- existedCount: outcome.existedCount,
613
- skippedCount: outcome.skippedCount,
614
- totalCount: outcome.feeds.length,
615
- added: outcome.added,
616
- skipped: outcome.skipped,
617
- };
597
+ return mutateSubscriptions(settingsScope, current => {
598
+ const outcome = importOpmlFeeds(current, document);
599
+ return { feeds: outcome.feeds, value: {
600
+ addedCount: outcome.addedCount, existedCount: outcome.existedCount,
601
+ skippedCount: outcome.skippedCount, totalCount: outcome.feeds.length,
602
+ added: outcome.added, skipped: outcome.skipped,
603
+ } };
604
+ });
618
605
  },
619
606
  timeoutMs: TIMEOUT_MS,
620
607
  };
@@ -656,8 +643,7 @@ export function buildRssTools(config, settingsScope, fetchImpl, lookupImpl) {
656
643
  if (hits.length >= limit)
657
644
  break;
658
645
  try {
659
- const { xml, finalUrl } = await fetchFeedXml(feed.url, cfg, exec, fetchImpl, lookupImpl);
660
- const parsed = parseFeed(xml, finalUrl);
646
+ const { parsed } = await readFeed(feed.url, exec);
661
647
  for (const entry of parsed.entries) {
662
648
  const rec = asRecord(entry);
663
649
  const at = Date.parse(typeof rec.pubDate === 'string' ? rec.pubDate : '');
package/lib/web.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ /** Authenticated, local settings route; no separate copy of subscription data. */
2
+ import type { IncomingMessage, ServerResponse } from 'node:http';
3
+ import type { RssSettingsScope } from './tools.js';
4
+ import type { ResolvedRssConfig } from './config.js';
5
+ import { type FetchLike, type DnsLookupLike } from './network.js';
6
+ export declare const RSS_SETTINGS_ROUTE = "/_dsh/dsh-rss/subscriptions";
7
+ export declare function createRssSettingsBackend(config: ResolvedRssConfig, scope: RssSettingsScope, options?: {
8
+ writable?: () => boolean;
9
+ fetchImpl?: FetchLike;
10
+ lookupImpl?: DnsLookupLike;
11
+ }): {
12
+ snapshot: () => {
13
+ revision: string;
14
+ writable: boolean;
15
+ feeds: {
16
+ id: string;
17
+ check: import("./subscriptions.js").FeedCheck | null;
18
+ url: string;
19
+ name: string;
20
+ category: string;
21
+ }[];
22
+ };
23
+ handle(req: IncomingMessage, res: ServerResponse): Promise<void>;
24
+ };
25
+ export declare function installRssSettingsWeb(ctx: {
26
+ inject?: Function;
27
+ }, backend: ReturnType<typeof createRssSettingsBackend>): void;
package/lib/web.js ADDED
@@ -0,0 +1,217 @@
1
+ import { sameFeedUrl } from './feeds.js';
2
+ import { assertHttpUrl } from './network.js';
3
+ import { buildOpml, parseOpml, importOpmlFeeds } from './opml.js';
4
+ import { feedId, subscriptionSnapshot, mutateSubscriptions, recentFeedChecks, readRssFeed, SubscriptionError } from './subscriptions.js';
5
+ export const RSS_SETTINGS_ROUTE = '/_dsh/dsh-rss/subscriptions';
6
+ // JSON quoting can double the OPML file size; limit the actual text separately.
7
+ const MAX_OPML = 1024 * 1024;
8
+ const MAX_BODY = MAX_OPML * 2 + 16384;
9
+ const loopback = (host) => ['127.0.0.1', 'localhost', '::1', '[::1]', '::ffff:127.0.0.1'].includes(host.toLowerCase());
10
+ const record = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
11
+ const text = (args, key) => typeof args[key] === 'string' ? args[key].trim() : '';
12
+ function requestGuard(req) {
13
+ const host = req.headers.host;
14
+ if (!host || !loopback(req.socket.remoteAddress ?? ''))
15
+ throw new SubscriptionError('设置面板仅允许本机访问。', 403, 'local_only');
16
+ let target;
17
+ try {
18
+ target = new URL('http://' + host);
19
+ }
20
+ catch {
21
+ throw new SubscriptionError('无效的请求地址。', 403);
22
+ }
23
+ if (!loopback(target.hostname) || target.username || target.password || target.pathname !== '/' || target.search || target.hash) {
24
+ throw new SubscriptionError('设置面板仅允许本机访问。', 403, 'local_only');
25
+ }
26
+ const protocol = req.socket.encrypted ? 'https:' : 'http:';
27
+ target.protocol = protocol;
28
+ const origin = req.headers.origin;
29
+ if ((req.method === 'POST' && !origin) || (origin !== undefined && origin !== target.origin)) {
30
+ throw new SubscriptionError('请从当前 DSH 设置页面操作。', 403, 'origin');
31
+ }
32
+ if (req.method === 'POST' && !/^application\/json(?:\s*;|$)/i.test(req.headers['content-type'] ?? '')) {
33
+ throw new SubscriptionError('请求必须使用 JSON。', 415);
34
+ }
35
+ }
36
+ function readBody(req, signal) {
37
+ return new Promise((resolve, reject) => {
38
+ let length = 0;
39
+ const chunks = [];
40
+ const cleanup = () => { req.off('data', data); req.off('end', end); req.off('error', error); signal.removeEventListener('abort', abort); };
41
+ const fail = (reason) => { cleanup(); reject(reason); };
42
+ const data = (chunk) => {
43
+ length += chunk.length;
44
+ if (length > MAX_BODY) {
45
+ chunks.length = 0;
46
+ fail(new SubscriptionError('内容超过 1 MiB,请拆分 OPML 文件后重试。', 413));
47
+ req.resume();
48
+ }
49
+ else
50
+ chunks.push(chunk);
51
+ };
52
+ const error = (reason) => fail(reason);
53
+ const abort = () => fail(signal.reason);
54
+ const end = () => {
55
+ cleanup();
56
+ try {
57
+ const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
58
+ if (!value || typeof value !== 'object' || Array.isArray(value))
59
+ throw new Error();
60
+ resolve(value);
61
+ }
62
+ catch {
63
+ reject(new SubscriptionError('请求内容不是有效的 JSON。'));
64
+ }
65
+ };
66
+ if (signal.aborted) {
67
+ abort();
68
+ return;
69
+ }
70
+ req.on('data', data);
71
+ req.on('end', end);
72
+ req.on('error', error);
73
+ signal.addEventListener('abort', abort, { once: true });
74
+ });
75
+ }
76
+ export function createRssSettingsBackend(config, scope, options = {}) {
77
+ const writable = () => options.writable?.() !== false;
78
+ const snapshot = () => {
79
+ const state = subscriptionSnapshot(scope);
80
+ const checks = recentFeedChecks(scope);
81
+ return { revision: state.revision, writable: writable(), feeds: state.feeds.map(feed => ({ ...feed, id: feedId(feed.url), check: checks.get(feed.url) ?? null })) };
82
+ };
83
+ const find = (feeds, id) => {
84
+ const feed = feeds.find(feed => feedId(feed.url) === id);
85
+ if (!feed)
86
+ throw new SubscriptionError('这个订阅已被移除,请刷新列表。', 409, 'conflict');
87
+ return feed;
88
+ };
89
+ const check = (url, signal) => {
90
+ assertHttpUrl(url);
91
+ return readRssFeed(scope, url, config, { signal }, options.fetchImpl, options.lookupImpl);
92
+ };
93
+ const preview = (args) => {
94
+ const opml = text(args, 'opml');
95
+ if (Buffer.byteLength(opml, 'utf8') > MAX_OPML)
96
+ throw new SubscriptionError('OPML 内容超过 1 MiB,请拆分后导入。', 413, 'bodyLarge');
97
+ const state = subscriptionSnapshot(scope);
98
+ const updateExisting = args.updateExisting === true;
99
+ const outcome = importOpmlFeeds(state.feeds, parseOpml(opml), updateExisting);
100
+ // Never return credentials embedded in rejected URLs.
101
+ const skipped = outcome.skipped.map(item => ({ ...item, url: item.url.replace(/(https?:\/\/)[^/@\s]+@/gi, '$1***@') }));
102
+ return { ...outcome, feeds: undefined, skipped, revision: state.revision,
103
+ token: feedId(JSON.stringify([state.revision, opml, updateExisting])) };
104
+ };
105
+ async function dispatch(args, signal) {
106
+ const action = text(args, 'action');
107
+ if (action === 'export')
108
+ return { opml: buildOpml(subscriptionSnapshot(scope).feeds) };
109
+ if (action === 'preview')
110
+ return { preview: preview(args) };
111
+ if (action === 'check') {
112
+ const url = text(args, 'id') ? find(subscriptionSnapshot(scope).feeds, text(args, 'id')).url : text(args, 'url');
113
+ const { parsed } = await check(url, signal);
114
+ return { check: { ok: true, title: parsed.feed.title, entryCount: parsed.entries.length } };
115
+ }
116
+ if (!['add', 'edit', 'remove', 'import'].includes(action))
117
+ throw new SubscriptionError('未知的操作。');
118
+ if (!writable())
119
+ throw new SubscriptionError('当前配置是只读的,无法保存订阅。', 403, 'readonly');
120
+ const revision = text(args, 'revision');
121
+ if (!revision)
122
+ throw new SubscriptionError('请先刷新订阅列表。', 409, 'conflict');
123
+ let result;
124
+ if (action === 'import') {
125
+ const candidate = preview(args);
126
+ if (candidate.revision !== revision || candidate.token !== text(args, 'token')) {
127
+ throw new SubscriptionError('订阅列表或导入内容已经变化,请重新预览;你的输入已保留。', 409, 'conflict');
128
+ }
129
+ result = await mutateSubscriptions(scope, feeds => {
130
+ signal.throwIfAborted();
131
+ const outcome = importOpmlFeeds(feeds, parseOpml(text(args, 'opml')), args.updateExisting === true);
132
+ return { feeds: outcome.feeds, value: { addedCount: outcome.addedCount, existedCount: outcome.existedCount, skippedCount: outcome.skippedCount } };
133
+ }, revision);
134
+ }
135
+ else if (action === 'remove') {
136
+ result = await mutateSubscriptions(scope, feeds => {
137
+ signal.throwIfAborted();
138
+ const target = find(feeds, text(args, 'id'));
139
+ return { feeds: feeds.filter(feed => feed !== target), value: { removed: target.name || target.url } };
140
+ }, revision);
141
+ }
142
+ else {
143
+ const url = text(args, 'url');
144
+ assertHttpUrl(url);
145
+ const current = subscriptionSnapshot(scope);
146
+ if (current.revision !== revision)
147
+ throw new SubscriptionError('订阅列表已经变化,请刷新后再保存;你的输入已保留。', 409, 'conflict');
148
+ const old = action === 'edit' ? find(current.feeds, text(args, 'id')) : undefined;
149
+ let defaultName = '';
150
+ if (!old || !sameFeedUrl(old.url, url))
151
+ defaultName = (await check(url, signal)).parsed.feed.title;
152
+ result = await mutateSubscriptions(scope, feeds => {
153
+ signal.throwIfAborted();
154
+ const previous = action === 'edit' ? find(feeds, text(args, 'id')) : undefined;
155
+ if (feeds.some(feed => feed !== previous && sameFeedUrl(feed.url, url))) {
156
+ throw new SubscriptionError('这个地址已经订阅,请编辑现有订阅。', 409, 'duplicate');
157
+ }
158
+ const next = { url, name: text(args, 'name') || defaultName, category: text(args, 'category') };
159
+ return { feeds: previous ? feeds.map(feed => feed === previous ? next : feed) : [...feeds, next], value: { saved: next.name || next.url } };
160
+ }, revision);
161
+ }
162
+ return { result };
163
+ }
164
+ return {
165
+ snapshot,
166
+ async handle(req, res) {
167
+ const controller = new AbortController();
168
+ const abort = () => { if (!res.writableFinished)
169
+ controller.abort(new Error('请求已取消')); };
170
+ req.on('aborted', abort);
171
+ res.on('close', abort);
172
+ const send = (status, value) => {
173
+ if (res.destroyed)
174
+ return;
175
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' });
176
+ res.end(JSON.stringify(value));
177
+ };
178
+ try {
179
+ requestGuard(req);
180
+ if (req.method === 'GET')
181
+ send(200, { snapshot: snapshot() });
182
+ else if (req.method === 'POST') {
183
+ const result = await dispatch(await readBody(req, controller.signal), controller.signal);
184
+ send(200, { ...result, snapshot: snapshot() });
185
+ }
186
+ else {
187
+ res.setHeader('Allow', 'GET, POST');
188
+ send(405, { error: '仅支持 GET 和 POST。', code: 'method' });
189
+ }
190
+ }
191
+ catch (error) {
192
+ if (!controller.signal.aborted) {
193
+ let state;
194
+ try {
195
+ state = snapshot();
196
+ }
197
+ catch { /* Malformed saved YAML must not hide the original error. */ }
198
+ send(error instanceof SubscriptionError ? error.status : 400, {
199
+ error: error instanceof Error ? error.message : String(error), code: error instanceof SubscriptionError ? error.code : 'invalid', snapshot: state,
200
+ });
201
+ }
202
+ }
203
+ finally {
204
+ req.off('aborted', abort);
205
+ res.off('close', abort);
206
+ }
207
+ },
208
+ };
209
+ }
210
+ export function installRssSettingsWeb(ctx, backend) {
211
+ if (typeof ctx.inject !== 'function')
212
+ return;
213
+ ctx.inject(['webServer'], (webCtx) => webCtx.effect(() => webCtx.webServer.register({
214
+ kind: 'exact', path: RSS_SETTINGS_ROUTE,
215
+ handler: (req, res) => backend.handle(req, res),
216
+ }), 'dsh-rss: settings route'));
217
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rss",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "description": "DSH RSS 插件:订阅源管理、OPML 导入导出、抓取解析 RSS/Atom 并保留正文。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -10,6 +10,7 @@
10
10
  "types": "./lib/index.d.ts",
11
11
  "default": "./lib/index.js"
12
12
  },
13
+ "./client": "./lib/client.js",
13
14
  "./cordis.patch.yml": "./cordis.patch.yml",
14
15
  "./package.json": "./package.json"
15
16
  },
@@ -26,6 +27,10 @@
26
27
  "prepublishOnly": "pnpm run build"
27
28
  },
28
29
  "dsh": {
30
+ "client": {
31
+ "inject": ["@deepseek-ai/dsh-client-ui-settings"],
32
+ "platform": "web"
33
+ },
29
34
  "bundle": {
30
35
  "patch": "./cordis.patch.yml"
31
36
  }