dsh-rss 0.7.1 → 0.8.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/CHANGELOG.md +7 -0
- package/README.en.md +2 -0
- package/README.md +2 -0
- package/docs/WORKBENCH.md +29 -0
- package/lib/client.js +461 -310
- package/lib/web.js +8 -5
- package/package.json +5 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# 更新记录
|
|
2
2
|
|
|
3
|
+
## 0.8.0(2026-10-25)
|
|
4
|
+
|
|
5
|
+
- RSS 主工作台,订阅与目录分区、来源详情与分类清楚展示;保存的来源可主动预览最多五篇文章。
|
|
6
|
+
- 提供 DSH 原生侧栏入口,保留设置页;统一中英、深浅色、窄屏、键盘焦点与真实空/失败状态。
|
|
7
|
+
- 浏览器源码移至 templates/client.js、templates/panels/ 与共享组件,独立构建为 lib/client.js;移除重复的首次引导。
|
|
8
|
+
- 官方 DSH 0.2.1-rc.3 Web 逐项实际操作验收,账户模拟和未验收范围见 [工作台说明](docs/WORKBENCH.md)。
|
|
9
|
+
|
|
3
10
|
## 0.7.1(2026-10-25)
|
|
4
11
|
|
|
5
12
|
- 修复 #3:RSS / Atom 摘要、正文和订阅描述含大量不闭合的尖括号时,文本清理不再反复扫描剩余内容,避免阻塞 DSH 的其他请求。
|
package/README.en.md
CHANGED
|
@@ -13,6 +13,8 @@ Feedback and contributions are welcome: report [issues](https://github.com/STARD
|
|
|
13
13
|
|
|
14
14
|
See the [0.6.3 validation record](docs/validation-0.6.3.md) for DSH `0.2.1-alpha.5` checks and the narrow-window layout fix.
|
|
15
15
|
|
|
16
|
+
Open the new native sidebar workbench after updating and restarting DSH. Settings remains available. See the [workbench guide and validation boundaries](docs/WORKBENCH.md).
|
|
17
|
+
|
|
16
18
|
## What it does
|
|
17
19
|
|
|
18
20
|
- Add feeds, read articles and fetch updates.
|
package/README.md
CHANGED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# dsh-rss 工作台 / Workbench
|
|
2
|
+
|
|
3
|
+
RSS 主工作台,订阅与目录分区、来源详情与分类清楚展示;保存的来源可主动预览最多五篇文章。
|
|
4
|
+
|
|
5
|
+
RSS workbench with subscriptions, discovery, categories and source details; manually preview up to five articles from saved feeds.
|
|
6
|
+
|
|
7
|
+
## 使用 / Use
|
|
8
|
+
|
|
9
|
+
更新插件并重启 DSH,在原生左侧栏打开工作台;没有固定时可在“更多”中找到。原有“设置”入口继续可用。侧栏排序和固定由 DSH 管理。
|
|
10
|
+
|
|
11
|
+
Update the plugin, restart DSH and open its native sidebar panel (or More if it is not pinned). The existing Settings entry remains available. DSH owns sidebar order and pinning.
|
|
12
|
+
|
|
13
|
+
界面跟随宿主中英文和深浅色,保留用户内容原文。窄窗口中先显示列表,打开详情后可返回;编辑器按可用宽度分栏或顺序排列。危险操作需要明确确认,未保存内容不会因切页而静默丢失。
|
|
14
|
+
|
|
15
|
+
The UI follows host language and theme without translating user content. Narrow master/detail views have a Back action; editors adapt their columns to available space. Destructive actions require confirmation and edits are protected.
|
|
16
|
+
|
|
17
|
+
## 源码 / Source
|
|
18
|
+
|
|
19
|
+
编辑 templates/client.js、templates/panels/*.js 与 templates/workbench-ui.js,运行 pnpm run build。lib/client.js 是确定性产物,请随源码提交。公共组件的规范源位于 dsh-suite/ui/workbench-ui.js,Suite 的同步脚本显式分发副本;单个仓库可独立构建和安装。
|
|
20
|
+
|
|
21
|
+
Edit the template sources and run pnpm run build. Commit generated lib/client.js with source changes. The vendored UI has no Suite runtime dependency.
|
|
22
|
+
|
|
23
|
+
## 验收范围 / Validation boundary
|
|
24
|
+
|
|
25
|
+
2026-10-25:Windows、官方 DSH 0.2.1-rc.3 Web,后台 Chrome/Playwright 实际点击和输入;覆盖中英文、浅深色、1536px 与 390px、状态读回与控制台。Browser plugin 不可用,因此使用本地 Playwright。
|
|
26
|
+
|
|
27
|
+
邮件使用受控 AgentMail HTTP 服务,日历使用受控 CalDAV 协议传输;插件处理器、存储、校验和原生宿主均实际运行。未使用真实邮箱、真实日历账号或付费模型。RSS 访问 Node.js 官方公开来源。PPT 实际生成并渲染 PPTX、下载 ZIP;两个视频插件实际运行官方引擎并播放 MP4。
|
|
28
|
+
|
|
29
|
+
这些是 Web 共用界面的验收结果,不代表已验收 Electron 专属菜单、系统权限、真实账号、全部外部来源或所有可选模型/引擎。Docker、Slack、钉钉继续按用户约定搁置。
|
package/lib/client.js
CHANGED
|
@@ -1,126 +1,276 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Generated from templates/client.js, templates/panels/ and templates/workbench-ui.js. Edit the sources.
|
|
2
|
+
(function () {
|
|
3
|
+
/* DSH workbench UI v1. MIT. Canonical source: dsh-suite/ui/workbench-ui.js.
|
|
4
|
+
* Vendored into each plugin: no Suite or extra runtime dependency is required.
|
|
5
|
+
* Styles are scoped to .dshw and never replace the host chrome or theme.
|
|
6
|
+
*/
|
|
7
|
+
function createWorkbenchUI(React) {
|
|
8
|
+
const h = React.createElement;
|
|
9
|
+
const { useEffect, useId, useRef } = React;
|
|
10
|
+
const cx = (...values) => values.filter(Boolean).join(' ');
|
|
11
|
+
const paths = {
|
|
12
|
+
book: ['M4 4h6a3 3 0 0 1 2 2v15a4 4 0 0 0-4-2H4z', 'M20 4h-6a3 3 0 0 0-2 2v15a4 4 0 0 1 4-2h4z'],
|
|
13
|
+
calendar: ['M5 5h14a2 2 0 0 1 2 2v13H3V7a2 2 0 0 1 2-2Z', 'M7 3v4M17 3v4M3 10h18'],
|
|
14
|
+
mail: ['M3 5h18v14H3z', 'm3 6 9 7 9-7'],
|
|
15
|
+
rss: ['M4 11a9 9 0 0 1 9 9M4 4a16 16 0 0 1 16 16', 'M4 19h.01'],
|
|
16
|
+
slides: ['M3 4h18v13H3z', 'M12 17v4M8 21h8'],
|
|
17
|
+
shield: ['m12 3 8 3v6c0 4-4 7-8 9-4-2-8-5-8-9V6z', 'm8 12 3 3 5-6'],
|
|
18
|
+
layers: ['M5 3h16v16H5z', 'M2 7v15h15'],
|
|
19
|
+
play: ['m9 6 9 6-9 6Z'],
|
|
20
|
+
video: ['M3 5h12v14H3z', 'm15 10 6-4v12l-6-4'],
|
|
21
|
+
search: ['M10.5 18a7.5 7.5 0 1 0 0-15 7.5 7.5 0 0 0 0 15Z', 'm16 16 5 5'],
|
|
22
|
+
plus: ['M12 5v14M5 12h14'],
|
|
23
|
+
refresh: ['M20 7a9 9 0 1 0 1 8', 'M20 2v5h-5'],
|
|
24
|
+
settings: ['m9 3-.7 2.4-2 .9L4 5.8 2.5 8.4l1.7 1.8v2.3L2.5 14 4 17l2.5-.4 1.9 1L9 20h3l.8-2.4 2-1 2.5.4 1.5-2.7-1.7-1.9v-2.2l1.7-1.8-1.5-2.6-2.5.4-2-1L12 3Z', 'M14 11.5a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0Z'],
|
|
25
|
+
close: ['m6 6 12 12M18 6 6 18'],
|
|
26
|
+
left: ['m14 5-7 7 7 7'],
|
|
27
|
+
right: ['m10 5 7 7-7 7'],
|
|
28
|
+
down: ['m5 9 7 7 7-7'],
|
|
29
|
+
filter: ['M4 5h16M7 12h10M10 19h4'],
|
|
30
|
+
download: ['M12 3v12m-5-5 5 5 5-5', 'M4 16v5h16v-5'],
|
|
31
|
+
upload: ['M12 16V4m-5 5 5-5 5 5', 'M4 16v5h16v-5'],
|
|
32
|
+
copy: ['M8 8h13v13H8z', 'M16 8V3H3v13h5'],
|
|
33
|
+
external: ['M14 3h7v7m0-7L10 14', 'M10 3H3v18h18v-7'],
|
|
34
|
+
file: ['M4 3h10l6 6v12H4z', 'M14 3v6h6M8 13h8M8 17h6'],
|
|
35
|
+
folder: ['M3 5h7l2 3h9v12H3z'],
|
|
36
|
+
edit: ['m15 4 5 5-10 10-6 1 1-6Z', 'm13 6 5 5'],
|
|
37
|
+
trash: ['M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7M14 10v7'],
|
|
38
|
+
more: ['M5 12h.01M12 12h.01M19 12h.01'],
|
|
39
|
+
info: ['M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z', 'M12 11v6M12 7h.01'],
|
|
40
|
+
check: ['m5 12 4 4L19 6'],
|
|
41
|
+
quote: ['M4 12h6v7H3v-7c0-4 2-6 5-7M15 12h6v7h-7v-7c0-4 2-6 5-7'],
|
|
42
|
+
star: ['m12 3 2.8 5.7 6.2.9-4.5 4.4 1.1 6.2-5.6-3-5.6 3 1.1-6.2L3 9.6l6.2-.9Z'],
|
|
43
|
+
reply: ['m9 5-7 7 7 7v-5h6c3 0 5 2 6 5 0-8-4-11-12-11Z'],
|
|
44
|
+
link: ['m10 13 4-4M8 16l-2 2a4 4 0 0 1-6-6l4-4a4 4 0 0 1 6 0', 'm14 8 2-2a4 4 0 0 1 6 6l-4 4a4 4 0 0 1-6 0'],
|
|
45
|
+
};
|
|
46
|
+
function Icon({ name = 'file', size = 20, ...props }) {
|
|
47
|
+
return h('svg', { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.75, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true, focusable: false, ...props },
|
|
48
|
+
(paths[name] || paths.file).map((d, index) => h('path', { key: index, d })));
|
|
49
|
+
}
|
|
50
|
+
function Button({ children, icon, variant, className, ...props }) {
|
|
51
|
+
return h('button', { type: 'button', ...props, className: cx('w-button', variant && 'w-' + variant, className) }, icon ? h(Icon, { name: icon, size: 18 }) : null, children);
|
|
52
|
+
}
|
|
53
|
+
function IconButton({ label, icon, ...props }) {
|
|
54
|
+
return h(Button, { ...props, icon, className: cx('w-icon-button', props.className), 'aria-label': label, title: label });
|
|
55
|
+
}
|
|
56
|
+
function Page({ title, subtitle, actions, children, workbench = false, className, rootRef, ...props }) {
|
|
57
|
+
return h('section', { ...props, ref: rootRef, className: cx('dshw', 'w-page', className), 'data-main': workbench ? 'true' : 'false' },
|
|
58
|
+
h('style', null, styles),
|
|
59
|
+
h('header', { className: 'w-page-header' }, h('div', { className: 'w-heading' }, h('h1', null, title), subtitle ? h('p', { className: 'w-subtitle' }, subtitle) : null), actions ? h('div', { className: 'w-header-actions' }, actions) : null),
|
|
60
|
+
h('div', { className: 'w-body' }, children));
|
|
61
|
+
}
|
|
62
|
+
function Tabs({ items, value, onChange, label, children }) {
|
|
63
|
+
const id = useId();
|
|
64
|
+
const active = items.some(item => item.id === value) ? value : items[0]?.id;
|
|
65
|
+
function keys(event, index) {
|
|
66
|
+
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : (index + (event.key === 'ArrowRight' ? 1 : -1) + items.length) % items.length;
|
|
69
|
+
const node = event.currentTarget.parentElement?.querySelectorAll('[role=tab]')[next];
|
|
70
|
+
if (node) { onChange(items[next].id); node.focus(); }
|
|
71
|
+
}
|
|
72
|
+
return h('div', { className: 'w-tabs-layout' },
|
|
73
|
+
h('div', { className: 'w-tabs', role: 'tablist', 'aria-label': label }, items.map((item, index) => h('button', {
|
|
74
|
+
type: 'button', role: 'tab', key: item.id, id: id + '-' + item.id, 'aria-controls': id + '-panel', 'aria-selected': active === item.id, tabIndex: active === item.id ? 0 : -1,
|
|
75
|
+
onClick: () => onChange(item.id), onKeyDown: event => keys(event, index),
|
|
76
|
+
}, item.label, item.count !== undefined ? h('span', { className: 'w-count' }, item.count) : null))),
|
|
77
|
+
h('div', { className: 'w-tab-panel', id: id + '-panel', role: 'tabpanel', 'aria-labelledby': id + '-' + active }, children));
|
|
78
|
+
}
|
|
79
|
+
function Status({ children, tone = 'info', className }) {
|
|
80
|
+
if (!children) return null;
|
|
81
|
+
return h('div', { className: cx('w-status', 'w-status-' + tone, className), role: tone === 'error' ? 'alert' : 'status', 'aria-live': tone === 'error' ? 'assertive' : 'polite' }, h(Icon, { name: tone === 'success' ? 'check' : 'info', size: 18 }), h('div', null, children));
|
|
82
|
+
}
|
|
83
|
+
function Empty({ title, children, action, icon = 'file' }) {
|
|
84
|
+
return h('div', { className: 'w-empty' }, h(Icon, { name: icon, size: 32 }), h('h2', null, title), children ? h('div', { className: 'w-muted' }, children) : null, action ? h('div', { className: 'w-toolbar' }, action) : null);
|
|
85
|
+
}
|
|
86
|
+
function Split({ list, detail, selected, onBack, backLabel, className, listWidth = 360 }) {
|
|
87
|
+
const ref = useRef(null);
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (!selected || !ref.current || ref.current.clientWidth > 760) return;
|
|
90
|
+
const heading = ref.current.querySelector('.w-detail h2');
|
|
91
|
+
if (heading) { heading.tabIndex = -1; heading.focus({ preventScroll: true }); }
|
|
92
|
+
}, [selected]);
|
|
93
|
+
return h('div', { ref, className: cx('w-split', className), 'data-selected': selected ? 'true' : 'false', style: { '--w-list-width': listWidth + 'px' } },
|
|
94
|
+
h('div', { className: 'w-master' }, list),
|
|
95
|
+
h('div', { className: 'w-detail' }, selected ? h(Button, { className: 'w-back', icon: 'left', variant: 'ghost', onClick: onBack }, backLabel) : null, detail));
|
|
96
|
+
}
|
|
97
|
+
function Row({ title, meta, description, active, onClick, children, status, className, disabled }) {
|
|
98
|
+
return h('div', { className: cx('w-row', active && 'w-selected', className) },
|
|
99
|
+
children ? h('div', { className: 'w-row-select' }, children) : null,
|
|
100
|
+
h('button', { type: 'button', className: 'w-row-button', onClick, disabled, 'aria-pressed': !!active },
|
|
101
|
+
h('span', { className: 'w-row-head' }, h('strong', null, title), status ? h('span', { className: 'w-tag' }, status) : null),
|
|
102
|
+
meta ? h('span', { className: 'w-row-meta' }, meta) : null,
|
|
103
|
+
description ? h('span', { className: 'w-row-description' }, description) : null));
|
|
104
|
+
}
|
|
105
|
+
function Search({ label, value, onChange, ...props }) {
|
|
106
|
+
const id = useId();
|
|
107
|
+
return h('div', { className: 'w-search' }, h(Icon, { name: 'search', size: 18 }), h('input', { type: 'search', id, 'aria-label': label, placeholder: label, value, onChange, ...props }));
|
|
108
|
+
}
|
|
109
|
+
function Switch({ label, checked, onChange, disabled }) {
|
|
110
|
+
return h('label', { className: 'w-switch-label' }, h('span', null, label), h('input', { type: 'checkbox', role: 'switch', checked, disabled, onChange, 'aria-label': label }), h('span', { className: 'w-switch-track', 'aria-hidden': true }));
|
|
111
|
+
}
|
|
112
|
+
function Dialog({ title, children, onClose, closeLabel, className, busy = false }) {
|
|
113
|
+
const id = useId(), panel = useRef(null);
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
const previous = document.activeElement;
|
|
116
|
+
panel.current?.querySelector('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex="0"]')?.focus();
|
|
117
|
+
return () => { if (previous?.isConnected) previous.focus({ preventScroll: true }); };
|
|
118
|
+
}, []);
|
|
119
|
+
function keydown(event) {
|
|
120
|
+
if (event.key === 'Escape' && !event.isComposing) { event.preventDefault(); event.stopPropagation(); if (!busy) onClose(); return; }
|
|
121
|
+
if (event.key !== 'Tab') return;
|
|
122
|
+
const controls = [...panel.current.querySelectorAll('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), a[href], [tabindex="0"]')].filter(node => node.getClientRects().length);
|
|
123
|
+
const first = controls[0], last = controls.at(-1);
|
|
124
|
+
if (!controls.length) { event.preventDefault(); panel.current.focus(); }
|
|
125
|
+
else if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
|
|
126
|
+
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
|
|
127
|
+
}
|
|
128
|
+
return h('div', { className: 'w-overlay', onClick: event => { if (!busy && event.target === event.currentTarget) onClose(); } },
|
|
129
|
+
h('section', { ref: panel, className: cx('w-dialog', className), role: 'dialog', 'aria-modal': true, 'aria-labelledby': id, tabIndex: -1, onKeyDown: keydown },
|
|
130
|
+
h('header', { className: 'w-dialog-header' }, h('h2', { id }, title), h(IconButton, { icon: 'close', label: closeLabel, disabled: busy, onClick: onClose })), h('div', { className: 'w-dialog-body' }, children)));
|
|
131
|
+
}
|
|
132
|
+
function register(ctx, { id, label, icon, component, inject, order = 50 }) {
|
|
133
|
+
// The two slots are optional. Existing settings and manual tools still load
|
|
134
|
+
// in hosts without a workbench, while RC.3 owns navigation/pinning itself.
|
|
135
|
+
ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: id, inject: () => ({ ...(inject?.() || {}), workbench: true }) }, component));
|
|
136
|
+
ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ name: 'sidebar.panellist', id, order, label }, function WorkbenchIcon({ size }) { return h(Icon, { name: icon, size }); }));
|
|
137
|
+
}
|
|
138
|
+
const styles = `
|
|
139
|
+
.dshw{--w-bg:var(--dsw-alias-bg-layer-1,#fff);--w-soft:var(--dsw-alias-bg-layer-2,#f7f8fa);--w-text:var(--dsw-alias-label-primary,#18202c);--w-muted:var(--dsw-alias-label-secondary,#667085);--w-border:var(--dsw-alias-border-l3,#e7e9ef);--w-accent:var(--dsh-plugin-workbench-accent,#4f6bdb);--w-selected:color-mix(in srgb,var(--w-accent) 10%,var(--w-bg));--w-primary:var(--w-text);--w-on-primary:var(--w-bg);container-type:inline-size;container-name:dsh-workbench;color:var(--w-text);background:var(--w-bg);font:14px/1.65 var(--dsh-font-family-content,system-ui,-apple-system,"Segoe UI","Microsoft YaHei",sans-serif);min-width:0;max-width:100%;box-sizing:border-box;text-align:start;color-scheme:inherit}
|
|
140
|
+
.dshw *,.dshw *:before,.dshw *:after{box-sizing:border-box}.dshw h1,.dshw h2,.dshw h3,.dshw h4,.dshw p{margin:0}.dshw h1{font-size:30px;line-height:1.25;letter-spacing:-.025em;font-weight:650}.dshw h2{font-size:24px;line-height:1.35;font-weight:650;letter-spacing:-.015em;overflow-wrap:anywhere}.dshw h3,.dshw h4{font-size:16px;line-height:1.4;font-weight:600}.dshw a{color:var(--w-accent);text-underline-offset:3px;overflow-wrap:anywhere}.dshw button,.dshw input,.dshw select,.dshw textarea{font:inherit;color:inherit;max-width:100%}.dshw input:not([type=checkbox]):not([type=radio]):not([type=file]),.dshw select,.dshw textarea{background:var(--w-bg);border:1px solid var(--w-border);border-radius:8px;padding:8px 11px;min-width:0;line-height:1.5}.dshw input:not([type=checkbox]):not([type=radio]):not([type=file]),.dshw select{min-height:38px}.dshw textarea{resize:vertical}.dshw input[type=checkbox],.dshw input[type=radio]{width:16px;height:16px;flex-shrink:0;accent-color:var(--w-accent)}.dshw :focus-visible{outline:2px solid var(--w-accent);outline-offset:3px}.dshw button:disabled,.dshw input:disabled,.dshw select:disabled,.dshw textarea:disabled{opacity:.5;cursor:not-allowed}.dshw label{font-size:13px}.dshw fieldset{min-width:0;border:1px solid var(--w-border);border-radius:8px}.dshw summary{cursor:pointer;font-weight:550;list-style:none;display:flex;align-items:center;gap:8px}.dshw summary::-webkit-details-marker{display:none}.dshw summary:after{content:'';display:block;margin-inline-start:auto;width:7px;height:7px;border-inline-end:1.75px solid currentColor;border-bottom:1.75px solid currentColor;transform:rotate(45deg);flex-shrink:0}.dshw details[open]>summary:after{transform:rotate(225deg)}.dshw details>summary+*{margin-top:12px}.dshw pre{white-space:pre-wrap;overflow-wrap:anywhere;font:12px/1.7 ui-monospace,SFMono-Regular,Consolas,monospace;max-width:100%;background:var(--w-soft);border-radius:6px;padding:14px}.dshw img,.dshw video,.dshw iframe{max-width:100%}
|
|
141
|
+
.w-page{display:flex;flex-direction:column;position:relative;padding:0;min-height:540px;gap:0}.w-page[data-main=true]{height:100%;min-height:0;padding:calc(24px + var(--dsh-frame-top-clearance,0px)) 28px 0 max(28px,var(--dsh-frame-leading-clearance,0px));overflow:auto}.w-page-header{display:flex;justify-content:space-between;gap:24px;align-items:flex-start;padding-bottom:22px;flex-shrink:0}.w-heading{min-width:0}.w-subtitle{margin-top:8px!important;font-size:14px;color:var(--w-muted);line-height:1.6}.w-header-actions,.w-toolbar,.w-actions{display:flex;align-items:center;flex-wrap:wrap;gap:8px;min-width:0}.w-header-actions{justify-content:flex-end}.w-body,.w-tabs-layout{display:flex;flex-direction:column;flex:1;min-height:0;min-width:0}.w-body{overflow:auto}.w-tab-panel{flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;overflow:auto}.w-tabs{display:flex;align-items:stretch;gap:20px;border-bottom:1px solid var(--w-border);flex-shrink:0;overflow-x:auto;scrollbar-width:thin}.dshw .w-tabs>button{border:0;border-bottom:2px solid transparent;background:transparent;padding:11px 8px 13px;white-space:nowrap;font-size:14px;font-weight:550;cursor:pointer;color:var(--w-muted);display:flex;align-items:center;gap:7px}.dshw .w-tabs>button[aria-selected=true]{border-bottom-color:var(--w-accent);color:var(--w-accent)}.w-count,.w-tag{font-size:12px;line-height:1.5;display:inline-flex;align-items:center;flex-shrink:0;background:var(--w-soft);border-radius:5px;padding:1px 6px;color:var(--w-muted)}.w-tabs [aria-selected=true] .w-count,.w-selected .w-tag{color:var(--w-accent);background:var(--w-selected)}
|
|
142
|
+
.dshw .w-button{display:inline-flex;justify-content:center;align-items:center;gap:7px;background:var(--w-bg);border:1px solid var(--w-border);border-radius:8px;padding:8px 12px;min-height:36px;line-height:1.4;font-size:14px;font-weight:500;cursor:pointer;text-decoration:none;white-space:normal}.dshw .w-button:not(:disabled):hover{background:var(--w-soft)}.dshw .w-primary{background:var(--w-primary);border-color:var(--w-primary);color:var(--w-on-primary)}.dshw .w-primary:not(:disabled):hover{background:var(--w-primary);opacity:.9}.dshw .w-ghost{background:transparent;border-color:transparent}.dshw .w-danger{color:var(--dsw-alias-state-error-primary,#b43c3c)}.dshw .w-icon-button{width:36px;min-width:36px;padding:8px}.dshw .w-button svg{flex-shrink:0}.w-muted{color:var(--w-muted);font-size:12px;line-height:1.6}.w-section{padding:24px 0;border-bottom:1px solid var(--w-border);min-width:0}.w-section>h3{margin-bottom:12px}.w-section>p+p{margin-top:8px}.w-fields{display:grid;gap:16px;min-width:0}.w-field{display:grid;gap:6px;min-width:0}.w-field>input,.w-field>select,.w-field>textarea{width:100%}.w-status{display:flex;align-items:flex-start;gap:8px;padding:11px 13px;margin:12px 0;border:1px solid var(--w-border);border-radius:8px;background:var(--w-soft);font-size:13px;overflow-wrap:anywhere;flex-shrink:0}.w-status>svg{flex-shrink:0;margin-top:2px}.w-status-error{color:var(--dsw-alias-state-error-primary,#ad3636)}.w-status-success{color:var(--dsw-alias-state-success-primary,#24794a)}.w-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:14px;max-width:580px;padding:48px 24px;margin:auto;min-height:280px;overflow-wrap:anywhere}.w-empty>svg{color:var(--w-muted)}.w-empty h2{font-size:20px}.w-empty .w-muted{font-size:14px}.w-empty .w-toolbar{justify-content:center}
|
|
143
|
+
.w-split{display:grid;grid-template-columns:minmax(240px,var(--w-list-width)) minmax(0,1fr);min-height:460px;flex:1;min-width:0}.w-master{min-width:0;border-inline-end:1px solid var(--w-border);overflow:auto;padding-bottom:24px}.w-detail{min-width:0;padding:26px 28px;overflow:auto;overflow-wrap:anywhere;display:flex;flex-direction:column}.w-master-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:20px 16px}.w-search{display:flex;align-items:center;gap:8px;border:1px solid var(--w-border);border-radius:8px;padding:0 10px;min-width:0;flex:1;background:var(--w-bg)}.w-search>svg{color:var(--w-muted);flex-shrink:0}.dshw .w-search>input{border:0!important;background:transparent!important;padding:8px 0!important;width:100%;outline-offset:0}.w-row{display:flex;gap:0;min-width:0;position:relative;border-bottom:1px solid var(--w-border);border-inline-start:3px solid transparent}.w-row-select{display:flex;align-items:flex-start;padding:21px 0 0 14px}.dshw .w-row-button{display:flex;flex-direction:column;align-items:stretch;gap:7px;text-align:start;width:100%;padding:18px 18px 18px 16px;border:0;background:transparent;color:inherit;min-width:0;cursor:pointer;line-height:1.5}.dshw .w-row-button:hover{background:var(--w-soft)}.w-row-head{display:flex;align-items:flex-start;gap:10px;justify-content:space-between;min-width:0}.w-row-head>strong{font-size:14px;line-height:1.5;font-weight:600;overflow-wrap:anywhere}.w-row-meta,.w-row-description{color:var(--w-muted);font-size:12px;line-height:1.6;overflow-wrap:anywhere}.w-row-description{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.w-selected{background:var(--w-selected);border-inline-start-color:var(--w-accent)}.w-back{display:none!important}.w-detail-title{margin-bottom:12px!important}.w-detail-meta{color:var(--w-muted);font-size:13px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}.w-detail-footer{padding:24px 0;display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:auto;border-top:1px solid var(--w-border)}.w-detail-footer .w-muted{flex:1;min-width:160px}.w-definition{display:grid;grid-template-columns:minmax(80px,120px) minmax(0,1fr);gap:8px 16px;font-size:14px;margin:20px 0}.w-definition dt{color:var(--w-muted)}.w-definition dd{margin:0;min-width:0;overflow-wrap:anywhere}
|
|
144
|
+
.w-switch-label{display:inline-flex;align-items:center;gap:8px;font-size:13px;position:relative;cursor:pointer}.w-switch-label>input{position:absolute;opacity:0;inset-inline-end:0;width:36px!important;height:22px!important;margin:0;cursor:pointer}.w-switch-track{width:36px;height:22px;border-radius:12px;display:block;background:var(--w-border);border:1px solid var(--w-border);pointer-events:none;padding:2px;transition:background .15s}.w-switch-track:before{content:'';display:block;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 2px #0002;transition:transform .15s}.w-switch-label>input:checked+.w-switch-track{background:var(--w-accent);border-color:var(--w-accent)}.w-switch-label>input:checked+.w-switch-track:before{transform:translateX(14px)}.w-switch-label>input:focus-visible+.w-switch-track{outline:2px solid var(--w-accent);outline-offset:3px}.w-switch-label:has(>input:disabled){opacity:.5;cursor:not-allowed}
|
|
145
|
+
.w-overlay{position:fixed;inset:0;background:#0005;display:flex;justify-content:flex-end;z-index:1000;padding:12px;overflow:auto}.w-dialog{width:min(620px,100%);max-height:100%;display:flex;flex-direction:column;min-height:0;background:var(--w-bg);color:var(--w-text);border:1px solid var(--w-border);border-radius:12px;box-shadow:0 18px 70px #0003}.w-dialog-header{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:20px 24px;border-bottom:1px solid var(--w-border);flex-shrink:0}.w-dialog-header>h2{font-size:20px}.w-dialog-body{overflow:auto;padding:20px 24px;min-height:0}.w-editor-grid{display:grid;grid-template-columns:210px minmax(0,1fr) 300px;min-height:500px;flex:1}.w-editor-list{padding:20px 12px;border-inline-end:1px solid var(--w-border);min-width:0}.w-editor-canvas{background:var(--w-soft);padding:20px;min-width:0;display:flex;flex-direction:column;gap:14px}.w-editor-inspector{padding:24px;border-inline-start:1px solid var(--w-border);min-width:0;overflow:auto}.w-editor-inspector h3{margin-bottom:18px}.w-preview-empty{display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;gap:12px;aspect-ratio:16/9;min-height:180px;background:var(--w-soft);border:1px solid var(--w-border);border-radius:8px;padding:24px;color:var(--w-muted)}.w-content-pad{padding:24px 0}.w-stack{display:flex;flex-direction:column;gap:16px;min-width:0}.w-scroll-x{overflow:auto;max-width:100%}
|
|
146
|
+
@container dsh-workbench (max-width:1000px){.w-editor-grid{grid-template-columns:160px minmax(0,1fr)}.w-editor-inspector{grid-column:1/-1;border-inline-start:0;border-top:1px solid var(--w-border)}.w-header-actions{max-width:60%}.w-split{grid-template-columns:minmax(240px,32%) minmax(0,1fr)}}
|
|
147
|
+
@container dsh-workbench (max-width:760px){.w-page-header{gap:14px;flex-wrap:wrap;padding-bottom:18px}.w-heading{flex:1;min-width:170px}.dshw h1{font-size:24px}.w-header-actions{max-width:100%;justify-content:flex-start}.w-subtitle{font-size:13px}.w-tabs{gap:12px}.dshw .w-tabs>button{padding:10px 4px 12px}.w-split{display:block;min-height:360px}.w-split[data-selected=false]>.w-detail{display:none}.w-split[data-selected=true]>.w-master{display:none}.w-master{border-inline-end:0}.w-detail{padding:20px 0}.w-back{display:inline-flex!important;align-self:flex-start;margin:0 0 16px!important}.w-detail h2{font-size:22px}.w-master-toolbar{padding:16px 0}.dshw .w-row-button{padding:16px 10px}.w-detail-footer{position:static}.w-detail-footer .w-muted{flex-basis:100%}.w-editor-grid{display:flex;flex-direction:column}.w-editor-list{border-inline-end:0;border-bottom:1px solid var(--w-border);padding:16px 0}.w-editor-canvas{padding:16px 0;background:var(--w-bg)}.w-editor-inspector{padding:20px 0}.w-dialog-header,.w-dialog-body{padding:16px}.w-button{min-height:40px}.w-definition{grid-template-columns:80px minmax(0,1fr)}}
|
|
148
|
+
@media(max-width:700px){.w-page[data-main=true]{padding:calc(16px + var(--dsh-frame-top-clearance,0px)) 16px 0 max(16px,var(--dsh-frame-leading-clearance,0px))}.w-overlay{padding:8px}.w-dialog{width:100%}}
|
|
149
|
+
@media(prefers-reduced-motion:reduce){.dshw *,.dshw *:before{scroll-behavior:auto!important;transition:none!important;animation:none!important}}
|
|
150
|
+
`;
|
|
151
|
+
return { Icon, Button, IconButton, Page, Tabs, Status, Empty, Split, Row, Search, Switch, Dialog, register, styles };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Editable browser entry. Build with node scripts/build-client.mjs.
|
|
2
155
|
window.__ModuleLoader__.load({ id: 'dsh-rss', factory: require => {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
var __FR_STEPS = [{"title":"先加一个源","body":"粘贴 RSS/Atom 地址,插件会先抓一次验证可用,再存进列表。"},{"title":"增量比全量好用","body":"抓取时选「增量」,只返回上次之后的新条目,适合定时巡检。"},{"title":"跨源搜索","body":"在对话里直接问关键词最近有没有更新,比逐个源翻快得多。","tryIt":"搜一下最近一周提到 DeepSeek 的订阅条目"}];
|
|
156
|
+
const React = require('react');
|
|
157
|
+
const W = createWorkbenchUI(React);
|
|
158
|
+
function rssText(zh, en) { return language === 'en' ? en : zh; }
|
|
7
159
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
'.fr-body{color:var(--dsw-text-weak,#4b5563);font-size:12.5px;line-height:1.65}',
|
|
18
|
-
'.fr-try{margin-top:8px;padding:7px 9px;border-radius:8px;background:rgba(59,130,246,.09);font-size:12px;display:flex;gap:8px}',
|
|
19
|
-
'.fr-try code{font-family:ui-monospace,monospace;white-space:pre-wrap;word-break:break-word}',
|
|
20
|
-
'.fr-bar{display:flex;gap:8px;align-items:center;margin-top:12px}',
|
|
21
|
-
'.fr-progress{display:flex;gap:4px;margin-right:auto}',
|
|
22
|
-
'.fr-seg{width:16px;height:3px;border-radius:2px;background:var(--dsw-border,rgba(0,0,0,.12))}',
|
|
23
|
-
'.fr-seg.on{background:var(--dsw-accent,#3b82f6)}',
|
|
24
|
-
'.fr-btn{border:1px solid var(--dsw-border,rgba(0,0,0,.14));background:transparent;color:inherit;border-radius:8px;padding:4px 10px;font-size:12px;cursor:pointer}',
|
|
25
|
-
'.fr-btn:hover{background:rgba(0,0,0,.04)}',
|
|
26
|
-
'.fr-btn.primary{background:var(--dsw-accent,#3b82f6);border-color:transparent;color:#fff}',
|
|
27
|
-
'.fr-btn.ghost{border-color:transparent;color:var(--dsw-text-weak,#6b7280)}',
|
|
28
|
-
].join('\n');
|
|
29
|
-
function __frEnsureCss() {
|
|
30
|
-
if (typeof document === 'undefined' || document.getElementById(__FR_STYLE_ID)) return;
|
|
31
|
-
var el = document.createElement('style');
|
|
32
|
-
el.id = __FR_STYLE_ID;
|
|
33
|
-
el.textContent = __FR_CSS;
|
|
34
|
-
document.head.appendChild(el);
|
|
35
|
-
}
|
|
36
|
-
function __frRead() { try { return localStorage.getItem(__FR_KEY) === 'done'; } catch (e) { return false; } }
|
|
37
|
-
function __frWrite() { try { localStorage.setItem(__FR_KEY, 'done'); } catch (e) {} }
|
|
38
|
-
function __FirstRunCard() {
|
|
39
|
-
var doneState = React.useState(__frRead);
|
|
40
|
-
var done = doneState[0], setDone = doneState[1];
|
|
41
|
-
var idxState = React.useState(0);
|
|
42
|
-
var index = idxState[0], setIndex = idxState[1];
|
|
43
|
-
React.useEffect(function () { __frEnsureCss(); }, []);
|
|
44
|
-
if (done) return null;
|
|
45
|
-
var step = __FR_STEPS[index];
|
|
46
|
-
var last = index >= __FR_STEPS.length - 1;
|
|
47
|
-
var close = function () { __frWrite(); setDone(true); };
|
|
48
|
-
var kids = [
|
|
49
|
-
React.createElement('div', { className: 'fr-head', key: 'h' },
|
|
50
|
-
React.createElement('span', { className: 'fr-dot' }),
|
|
51
|
-
React.createElement('span', { className: 'fr-title' }, __FR_TITLE),
|
|
52
|
-
React.createElement('span', { className: 'fr-count' }, (index + 1) + ' / ' + __FR_STEPS.length)),
|
|
53
|
-
React.createElement('div', { className: 'fr-step', key: 't' }, step.title),
|
|
54
|
-
React.createElement('div', { className: 'fr-body', key: 'b' }, step.body),
|
|
55
|
-
];
|
|
56
|
-
if (step.tryIt) kids.push(React.createElement('div', { className: 'fr-try', key: 'y' },
|
|
57
|
-
React.createElement('span', null, '可以直接说'),
|
|
58
|
-
React.createElement('code', null, '「' + step.tryIt + '」')));
|
|
59
|
-
var buttons = [React.createElement('div', { className: 'fr-progress', key: 'p' }, __FR_STEPS.map(function (_, i) {
|
|
60
|
-
return React.createElement('span', { key: i, className: 'fr-seg' + (i <= index ? ' on' : '') });
|
|
61
|
-
}))];
|
|
62
|
-
buttons.push(React.createElement('button', { className: 'fr-btn ghost', type: 'button', onClick: close, key: 's' }, '跳过'));
|
|
63
|
-
if (index > 0) buttons.push(React.createElement('button', { className: 'fr-btn', type: 'button', key: 'prev', onClick: function () { setIndex(index - 1); } }, '上一步'));
|
|
64
|
-
buttons.push(React.createElement('button', { className: 'fr-btn primary', type: 'button', key: 'next', onClick: function () { if (last) close(); else setIndex(index + 1); } }, last ? '开始使用' : '下一步'));
|
|
65
|
-
kids.push(React.createElement('div', { className: 'fr-bar', key: 'bar' }, buttons));
|
|
66
|
-
return React.createElement('div', { className: 'fr-card' }, kids);
|
|
160
|
+
function RssArticlePreview({ value }) {
|
|
161
|
+
return h('section', { className: 'w-section rss-articles' },
|
|
162
|
+
h('h3', null, t('recentArticles')),
|
|
163
|
+
!value ? h('p', { className: 'w-muted' }, rssText('点击“检查并预览”后获取近期条目,不会因打开此页而访问来源。', 'Choose Check and preview to fetch recent entries. Opening this page does not contact the source.')) :
|
|
164
|
+
h(React.Fragment, null,
|
|
165
|
+
h('p', { className: 'w-muted' }, new Date(value.checkedAt).toLocaleString(language === 'en' ? 'en-US' : 'zh-CN')),
|
|
166
|
+
value.entries.length ? h('ul', { className: 'rss-article-list' }, value.entries.map((entry, index) => h('li', { key: index },
|
|
167
|
+
entry.link ? h('a', { href: entry.link, target: '_blank', rel: 'noopener noreferrer' }, entry.title || t('noTitle')) : h('strong', null, entry.title || t('noTitle')),
|
|
168
|
+
entry.pubDate && !Number.isNaN(Date.parse(entry.pubDate)) ? h('p', { className: 'w-muted' }, new Date(entry.pubDate).toLocaleDateString(language === 'en' ? 'en-US' : 'zh-CN')) : null))) : h('p', { className: 'w-muted' }, t('noRecentArticles'))));
|
|
67
169
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
170
|
+
|
|
171
|
+
function RssFeedBrowser({ feeds, filtered, categories, query, setQuery, category, setCategory, writable, busy, run, open }) {
|
|
172
|
+
const [activeId, setActiveId] = useState(null), [previews, setPreviews] = useState({});
|
|
173
|
+
const active = filtered.find(feed => feed.id === activeId) || filtered[0];
|
|
174
|
+
if (!feeds.length) return h(W.Empty, { title: t('empty'), icon: 'rss', action: h(W.Button, { onClick: event => open('feed', null, event), disabled: !writable || !!busy }, t('add')) }, t('emptyHint'));
|
|
175
|
+
return h(W.Split, { selected: activeId && filtered.some(feed => feed.id === activeId) ? activeId : null, onBack: () => setActiveId(null), backLabel: rssText('返回订阅列表', 'Back to feeds'), listWidth: 380,
|
|
176
|
+
list: h('div', null,
|
|
177
|
+
h('div', { className: 'w-master-toolbar' }, h(W.Search, { label: t('search'), value: query, onChange: event => { setQuery(event.target.value); setActiveId(null); } }), h(W.IconButton, { icon: 'refresh', label: t('refresh'), disabled: !!busy, onClick: () => run(null) })),
|
|
178
|
+
h('div', { className: 'rss-list-filter' }, h('select', { 'aria-label': t('category'), value: category, onChange: event => { setCategory(event.target.value); setActiveId(null); } }, h('option', { value: '*' }, t('all')), categories.map(value => h('option', { key: value, value }, value || t('uncategorized'))))),
|
|
179
|
+
filtered.map(feed => h(W.Row, { key: feed.id, title: feed.name || feed.url, active: feed.id === active?.id, meta: feed.url, description: feed.category || t('uncategorized'), status: feed.check ? t(feed.check.ok ? 'checked' : 'failed') : t('pending'), onClick: () => setActiveId(feed.id) })),
|
|
180
|
+
!filtered.length ? h(W.Empty, { title: t('noMatch'), action: h(W.Button, { onClick: () => { setQuery(''); setCategory('*'); } }, t('clear')) }) : null),
|
|
181
|
+
detail: active ? h('article', { className: 'rss-feed-detail' },
|
|
182
|
+
h('h2', { className: 'w-detail-title' }, active.name || active.url), h('p', { className: 'w-detail-meta rss-url' }, active.url),
|
|
183
|
+
h('div', { className: 'rss-detail-actions' }, h('span', { className: 'w-tag' }, active.category || t('uncategorized')), h('div', { className: 'w-toolbar' },
|
|
184
|
+
h(W.Button, { variant: 'primary', disabled: !!busy, onClick: () => run('previewFeed', { id: active.id }, value => setPreviews(old => ({ ...old, [active.id]: value.feedPreview }))) }, busy === 'previewFeed' ? t('saving') : rssText('检查并预览', 'Check and preview')),
|
|
185
|
+
h(W.Button, { icon: 'edit', variant: 'ghost', disabled: !writable || !!busy, onClick: event => open('feed', active, event) }, t('edit')),
|
|
186
|
+
h('details', { className: 'rss-more' }, h('summary', { 'aria-label': rssText('更多订阅操作', 'More feed actions') }, h(W.Icon, { name: 'more', size: 20 })), h(W.Button, { variant: 'danger', disabled: !writable || !!busy, onClick: event => open('remove', active, event) }, t('remove'))))),
|
|
187
|
+
h(RssArticlePreview, { value: previews[active.id] }),
|
|
188
|
+
h('details', { className: 'w-section' }, h('summary', null, rssText('来源检查', 'Source check')),
|
|
189
|
+
active.check ? h(React.Fragment, null, h('p', { className: 'w-muted' }, new Date(active.check.checkedAt).toLocaleString(language === 'en' ? 'en-US' : 'zh-CN')),
|
|
190
|
+
h(W.Status, { tone: active.check.ok ? 'success' : 'error' }, active.check.ok ? t('checked') + ' · ' + active.check.entryCount + ' ' + t('entries') : t('failed') + ' · ' + feedError(active.check.error))) : h('p', { className: 'w-muted' }, t('pending')),
|
|
191
|
+
h('p', { className: 'w-muted' }, t('statusHint')))) : h(W.Empty, { title: t('noMatch'), icon: 'rss' }) });
|
|
72
192
|
}
|
|
73
|
-
// ---- end first-run guide ----
|
|
74
193
|
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
empty: ['还没有订阅', 'No subscriptions yet'], emptyHint: ['从推荐目录选几个感兴趣的来源,也可以输入 RSS/Atom 地址或导入 OPML。', 'Choose sources from the recommended catalog, enter an RSS/Atom URL, or import OPML.'], noMatch: ['没有匹配项,试试清空筛选。', 'No matches. Try clearing the filters.'], clear: ['清空筛选', 'Clear filters'],
|
|
95
|
-
readonly: ['当前配置为只读。你可以查看、检查和导出订阅。', 'These settings are read-only. You can view, check and export feeds.'],
|
|
96
|
-
pending: ['本次运行尚未检查', 'Not checked during this run'], entries: ['篇文章', 'articles'], checked: ['检查通过', 'Check passed'], failed: ['检查失败', 'Check failed'],
|
|
97
|
-
statusHint: ['检查记录仅保留在本次 DSH 运行中;导入不会自动抓取所有订阅。', 'Check history lasts for this DSH run. Import does not fetch every feed.'],
|
|
98
|
-
deleteTitle: ['移除这个订阅?', 'Remove this feed?'], deleteHint: ['只移除订阅,不会删除原网站的文章。', 'This removes the subscription; the original articles remain on the website.'],
|
|
99
|
-
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.'],
|
|
100
|
-
update: ['用导入文件的名称和分类更新重复订阅', 'Update duplicate names and categories from this file'], preserve: ['默认保留现有订阅的名称和分类。', 'Existing names and categories are preserved by default.'],
|
|
101
|
-
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.'],
|
|
102
|
-
keep: ['保留', 'Keep'], change: ['更新为', 'Change to'],
|
|
103
|
-
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'],
|
|
104
|
-
loading: ['正在读取订阅…', 'Loading subscriptions…'], retry: ['重新读取', 'Retry'], loadError: ['无法读取订阅。请确认 DSH 仍在运行,然后重试。', 'Cannot load feeds. Check that DSH is running, then retry.'],
|
|
105
|
-
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.'],
|
|
106
|
-
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.'],
|
|
107
|
-
};
|
|
108
|
-
const t = key => (words[key] || [key, key])[language === 'en' ? 1 : 0];
|
|
109
|
-
const localized = value => value?.[language === 'en' ? 1 : 0] || '';
|
|
194
|
+
function RssCatalog({ catalog, sources, query, setQuery, category, setCategory, contentLanguage, setContentLanguage, busy, writable, state, run, previews, setPreviews, setNotice }) {
|
|
195
|
+
const [activeId, setActiveId] = useState(null);
|
|
196
|
+
const active = sources.find(source => source.id === activeId) || sources[0];
|
|
197
|
+
const categoryOf = source => localized(catalog.categories.find(category => category.id === source.category)?.name);
|
|
198
|
+
return h(W.Split, { selected: activeId && sources.some(source => source.id === activeId) ? activeId : null, onBack: () => setActiveId(null), backLabel: rssText('返回来源目录', 'Back to source catalog'), listWidth: 380,
|
|
199
|
+
list: h('div', null,
|
|
200
|
+
h('div', { className: 'w-master-toolbar' }, h(W.Search, { label: t('sourceSearch'), value: query, onChange: event => { setQuery(event.target.value); setActiveId(null); } })),
|
|
201
|
+
h('div', { className: 'rss-list-filter rss-catalog-filters' },
|
|
202
|
+
h('label', { className: 'w-field' }, t('category'), h('select', { value: category, onChange: event => { setCategory(event.target.value); setActiveId(null); } }, h('option', { value: '*' }, t('all')), catalog.categories.map(category => h('option', { key: category.id, value: category.id }, localized(category.name))))),
|
|
203
|
+
h('label', { className: 'w-field' }, t('sourceLanguage'), h('select', { value: contentLanguage, onChange: event => { setContentLanguage(event.target.value); setActiveId(null); } }, h('option', { value: '*' }, t('anyLanguage')), h('option', { value: 'zh' }, t('chinese')), h('option', { value: 'en' }, t('english'))))),
|
|
204
|
+
sources.map(source => h(W.Row, { key: source.id, title: source.name, active: source.id === active?.id, meta: categoryOf(source) + ' · ' + t(source.language === 'zh' ? 'chinese' : 'english'), description: localized(source.description), status: source.subscribed ? t('subscribed') : undefined, onClick: () => setActiveId(source.id) })),
|
|
205
|
+
!sources.length ? h(W.Empty, { title: t('noMatch') }) : null),
|
|
206
|
+
detail: active ? h('article', null,
|
|
207
|
+
h('h2', { className: 'w-detail-title' }, active.name), h('p', { className: 'w-detail-meta' }, categoryOf(active) + ' · ' + t(active.language === 'zh' ? 'chinese' : 'english')),
|
|
208
|
+
h('section', { className: 'w-section' }, h('p', null, localized(active.description)), h('p', { className: 'w-muted rss-url' }, active.url), h('a', { href: active.website, target: '_blank', rel: 'noopener noreferrer' }, t('website')),
|
|
209
|
+
h('div', { className: 'w-toolbar rss-catalog-actions' }, h(W.Button, { disabled: !!busy, onClick: () => run('previewSource', { sourceId: active.id }, value => setPreviews(old => ({ ...old, [active.id]: value.sourcePreview }))) }, busy === 'previewSource' ? t('saving') : t('previewArticles')),
|
|
210
|
+
h(W.Button, { variant: 'primary', disabled: !writable || !!busy || active.subscribed, onClick: () => run('add', { url: active.url, name: active.name, category: categoryOf(active), revision: state.revision }, () => setNotice(t('saved') + ' · ' + active.name)) }, active.subscribed ? t('subscribed') : t('subscribe')))),
|
|
211
|
+
h(RssArticlePreview, { value: previews[active.id] }), h('section', { className: 'w-section' }, h('p', { className: 'w-muted' }, t('catalogHint')), h('p', { className: 'w-muted' }, t('networkHint')), h('p', { className: 'w-muted' }, t('rsshubHint')))) : h(W.Empty, { title: t('noMatch'), icon: 'rss' }) });
|
|
212
|
+
}
|
|
110
213
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (/YAML|feedsYaml/.test(message)) return 'The feed configuration is invalid. Fix the feedsYaml list in settings before retrying.';
|
|
116
|
-
if (/OPML|outline|xmlUrl/.test(message)) return 'The OPML content is invalid. Export it again with xmlUrl attributes on feed outlines.';
|
|
117
|
-
if (/地址|URL|url|http|https|协议/.test(message)) return 'Enter a valid HTTP or HTTPS feed URL. Local and private-network addresses are not allowed.';
|
|
118
|
-
if (/写入|只读/.test(message)) return 'Could not save the feed configuration. Check write access and reload the list before retrying; your input is preserved.';
|
|
119
|
-
if (/未找到|找不到|没有找到/.test(message)) return 'No matching feed was found. Refresh the subscription list.';
|
|
120
|
-
return 'The feed could not be read. Check the URL, network and proxy settings, then retry.';
|
|
121
|
-
}
|
|
214
|
+
const RSS_WORKBENCH_CSS = `
|
|
215
|
+
.rss-workbench .rss-list-filter{padding:0 16px 16px;display:grid;gap:12px}.rss-workbench .rss-list-filter select{width:100%}.rss-workbench .rss-catalog-filters{grid-template-columns:1fr 1fr}.rss-workbench .rss-url{overflow-wrap:anywhere}.rss-workbench .rss-detail-actions{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;margin-top:18px}.rss-workbench .rss-more{position:relative;align-self:center}.rss-workbench .rss-more>summary{list-style:none;padding:8px;min-width:36px;min-height:36px;border-radius:6px}.rss-workbench .rss-more>summary:after{display:none}.rss-workbench .rss-more[open]>.w-button{position:absolute;inset-inline-end:0;top:100%;z-index:4;min-width:100px;box-shadow:0 6px 24px #0002}.rss-workbench .rss-articles>p{margin-top:12px}.rss-workbench .rss-article-list{padding:0;list-style:none;margin:12px 0 0}.rss-workbench .rss-article-list li{padding:18px 0;border-bottom:1px solid var(--w-border)}.rss-workbench .rss-article-list li:last-child{border:0}.rss-workbench .rss-article-list a{font-size:14px;font-weight:550;text-decoration:none}.rss-workbench .rss-article-list a:hover{text-decoration:underline}.rss-workbench .rss-article-list p{margin-top:6px}.rss-workbench .rss-catalog-actions{margin-top:20px}.rss-workbench .rss-transfer{width:min(740px,100%);padding:24px 0}.rss-workbench .rss-transfer form{display:grid;gap:16px}.rss-workbench .rss-transfer label{display:grid;gap:6px}.rss-workbench .rss-transfer textarea{min-height:150px}.rss-workbench .rss-transfer .dshr-checkbox{display:flex;align-items:flex-start;gap:8px}.rss-workbench .rss-transfer .dshr-checkbox input{margin-top:3px}.rss-workbench .rss-transfer .dshr-buttons{display:flex;gap:8px;flex-wrap:wrap}.rss-workbench .rss-transfer button,.rss-workbench .dshr-dialog button{min-height:36px;padding:8px 12px;border:1px solid var(--w-border);border-radius:8px;background:var(--w-bg);font-size:14px;cursor:pointer}.rss-workbench .rss-transfer button.primary,.rss-workbench .dshr-dialog button.primary{background:var(--w-primary);color:var(--w-on-primary);border-color:var(--w-primary)}.rss-workbench .dshr-dialog{background:var(--w-bg);color:var(--w-text);border:1px solid var(--w-border);box-shadow:0 18px 70px #0003;border-radius:12px;max-height:calc(100dvh - 32px);width:min(620px,calc(100vw - 32px));padding:24px}.rss-workbench .dshr-dialog label{display:grid;gap:6px;margin:16px 0}.rss-workbench .dshr-dialog input:not([type=checkbox]),.rss-workbench .dshr-dialog textarea{width:100%}.rss-workbench .dshr-dialog form{min-width:0}.rss-workbench .dshr-dialog .dshr-url{overflow-wrap:anywhere}.rss-workbench .dshr-preview{border:1px solid var(--w-border);border-radius:8px;background:var(--w-soft);padding:16px}.rss-workbench .dshr-dialog .dshr-buttons{margin-top:20px}.rss-workbench .dshr-dialog h3{font-size:20px}.rss-workbench.dshr{max-width:none;margin:0;font-size:14px}.rss-workbench .dshr-alert,.rss-workbench .dshr-notice{background:var(--w-soft);border:1px solid var(--w-border);color:inherit}.rss-workbench .dshr-muted{color:var(--w-muted)}
|
|
216
|
+
@container dsh-workbench (max-width:760px){.rss-workbench .rss-list-filter{padding-inline:0}.rss-workbench .rss-catalog-filters{grid-template-columns:1fr 1fr}.rss-workbench .rss-detail-actions>.w-toolbar{width:100%}}
|
|
217
|
+
`;
|
|
122
218
|
|
|
123
|
-
|
|
219
|
+
const { useState, useEffect, useRef, useId } = React;
|
|
220
|
+
const h = React.createElement;
|
|
221
|
+
const ROUTE = '_dsh/dsh-rss/subscriptions';
|
|
222
|
+
let language = 'zh';
|
|
223
|
+
const listeners = new Set();
|
|
224
|
+
const words = {
|
|
225
|
+
title: ['RSS 订阅', 'RSS subscriptions'], intro: ['在这里管理订阅,也可以在对话里让 AI 读取和搜索文章。', 'Manage feeds here, then ask AI to read or search articles in a conversation.'],
|
|
226
|
+
recommended: ['推荐订阅源', 'Recommended feeds'], manual: ['输入地址', 'Enter a URL'],
|
|
227
|
+
catalogHint: ['先选感兴趣的分类,再预览或订阅。不会默认添加,也不会自动访问所有网站。', 'Choose a topic, then preview or subscribe. Feeds are not added by default and websites are not contacted automatically.'],
|
|
228
|
+
networkHint: ['可用性取决于你当前的网络,国内外网站可能不同。预览失败可以重试,或改选其他来源。', 'Availability depends on your current network and can differ by website. Retry a failed preview or choose another source.'],
|
|
229
|
+
sourceSearch: ['搜索来源或关键词', 'Search sources or keywords'], sourceLanguage: ['内容语言', 'Content language'], anyLanguage: ['全部语言', 'All languages'], chinese: ['中文', 'Chinese'], english: ['英文', 'English'],
|
|
230
|
+
native: ['网站原生 RSS / Atom', 'Publisher RSS / Atom'], website: ['来源网站', 'Source website'], previewArticles: ['预览并检查', 'Preview and check'], subscribe: ['订阅', 'Subscribe'], subscribed: ['已订阅', 'Subscribed'],
|
|
231
|
+
recentArticles: ['近期文章(最多 5 篇)', 'Recent articles (up to 5)'], noRecentArticles: ['订阅格式有效,但此时没有文章。有些来源只在发布日提供条目。', 'The feed is valid but currently empty. Some sources only include entries on publication days.'], noTitle: ['无标题', 'Untitled'],
|
|
232
|
+
customCategory: ['可选现有分类,也可填写自定义分类。已有分类原样保留。', 'Choose an existing category or type your own. Existing categories are preserved.'],
|
|
233
|
+
rsshubHint: ['其他网站:可在“输入地址”添加原生订阅,或粘贴你选择的 RSSHub 实例生成的地址。', 'For other sites, use “Enter a URL” with a native feed or a URL generated by your chosen RSSHub instance.'],
|
|
234
|
+
add: ['添加订阅', 'Add feed'], import: ['导入 OPML', 'Import OPML'], export: ['导出 OPML', 'Export OPML'], refresh: ['刷新列表', 'Refresh list'],
|
|
235
|
+
search: ['搜索名称或地址', 'Search name or URL'], category: ['分类', 'Category'], all: ['全部分类', 'All categories'], uncategorized: ['未分类', 'Uncategorized'],
|
|
236
|
+
name: ['显示名称', 'Display name'], nameHint: ['留空时使用订阅源的标题', 'Leave blank to use the feed title'], url: ['订阅源地址', 'Feed URL'], categoryHint: ['例如:科技、阅读', 'For example: Tech, Reading'],
|
|
237
|
+
save: ['保存订阅', 'Save feed'], saving: ['正在处理…', 'Working…'], cancel: ['取消', 'Cancel'], close: ['关闭', 'Close'], edit: ['编辑', 'Edit'], remove: ['移除', 'Remove'], check: ['检查可用性', 'Check feed'],
|
|
238
|
+
empty: ['还没有订阅', 'No subscriptions yet'], emptyHint: ['从推荐目录选几个感兴趣的来源,也可以输入 RSS/Atom 地址或导入 OPML。', 'Choose sources from the recommended catalog, enter an RSS/Atom URL, or import OPML.'], noMatch: ['没有匹配项,试试清空筛选。', 'No matches. Try clearing the filters.'], clear: ['清空筛选', 'Clear filters'],
|
|
239
|
+
readonly: ['当前配置为只读。你可以查看、检查和导出订阅。', 'These settings are read-only. You can view, check and export feeds.'],
|
|
240
|
+
pending: ['本次运行尚未检查', 'Not checked during this run'], entries: ['篇文章', 'articles'], checked: ['检查通过', 'Check passed'], failed: ['检查失败', 'Check failed'],
|
|
241
|
+
statusHint: ['检查记录仅保留在本次 DSH 运行中;导入不会自动抓取所有订阅。', 'Check history lasts for this DSH run. Import does not fetch every feed.'],
|
|
242
|
+
deleteTitle: ['移除这个订阅?', 'Remove this feed?'], deleteHint: ['只移除订阅,不会删除原网站的文章。', 'This removes the subscription; the original articles remain on the website.'],
|
|
243
|
+
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.'],
|
|
244
|
+
update: ['用导入文件的名称和分类更新重复订阅', 'Update duplicate names and categories from this file'], preserve: ['默认保留现有订阅的名称和分类。', 'Existing names and categories are preserved by default.'],
|
|
245
|
+
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.'],
|
|
246
|
+
keep: ['保留', 'Keep'], change: ['更新为', 'Change to'],
|
|
247
|
+
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'],
|
|
248
|
+
loading: ['正在读取订阅…', 'Loading subscriptions…'], retry: ['重新读取', 'Retry'], loadError: ['无法读取订阅。请确认 DSH 仍在运行,然后重试。', 'Cannot load feeds. Check that DSH is running, then retry.'],
|
|
249
|
+
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.'],
|
|
250
|
+
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.'],
|
|
251
|
+
};
|
|
252
|
+
const t = key => (words[key] || [key, key])[language === 'en' ? 1 : 0];
|
|
253
|
+
const localized = value => value?.[language === 'en' ? 1 : 0] || '';
|
|
254
|
+
function feedError(message, code) {
|
|
255
|
+
if (language !== 'en' || !/[\u3400-\u9fff]/.test(message || ''))
|
|
256
|
+
return message;
|
|
257
|
+
if (['conflict', 'duplicate', 'readonly', 'bodyLarge', 'session'].includes(code))
|
|
258
|
+
return t(code);
|
|
259
|
+
if (/HTTP/.test(message))
|
|
260
|
+
return 'Feed request failed. Check the URL, connection and proxy settings, then retry.';
|
|
261
|
+
if (/YAML|feedsYaml/.test(message))
|
|
262
|
+
return 'The feed configuration is invalid. Fix the feedsYaml list in settings before retrying.';
|
|
263
|
+
if (/OPML|outline|xmlUrl/.test(message))
|
|
264
|
+
return 'The OPML content is invalid. Export it again with xmlUrl attributes on feed outlines.';
|
|
265
|
+
if (/地址|URL|url|http|https|协议/.test(message))
|
|
266
|
+
return 'Enter a valid HTTP or HTTPS feed URL. Local and private-network addresses are not allowed.';
|
|
267
|
+
if (/写入|只读/.test(message))
|
|
268
|
+
return 'Could not save the feed configuration. Check write access and reload the list before retrying; your input is preserved.';
|
|
269
|
+
if (/未找到|找不到|没有找到/.test(message))
|
|
270
|
+
return 'No matching feed was found. Refresh the subscription list.';
|
|
271
|
+
return 'The feed could not be read. Check the URL, network and proxy settings, then retry.';
|
|
272
|
+
}
|
|
273
|
+
const CSS = `
|
|
124
274
|
.dshr{color:var(--dsw-alias-label-primary,#202737);font-size:14px;line-height:1.55;max-width:920px;min-width:0;overflow-wrap:anywhere}
|
|
125
275
|
.dshr *,.dshr-dialog *{box-sizing:border-box}.dshr h2{font-size:22px;margin:0 0 6px}.dshr p{margin:0 0 12px}
|
|
126
276
|
.dshr-muted{color:var(--dsw-alias-label-tertiary,#657083);font-size:13px}.dshr-toolbar{display:flex;flex-wrap:wrap;gap:8px;margin:16px 0}
|
|
@@ -151,198 +301,199 @@ function __withFirstRun(Component) {
|
|
|
151
301
|
[role=dialog]:has(.dshr)>nav>div:has(>button){display:flex;flex-direction:row!important;flex-wrap:wrap;gap:4px}
|
|
152
302
|
}
|
|
153
303
|
`;
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
return h('dialog', { ref, className: 'dshr-dialog' + (wide ? ' catalog' : ''), 'aria-labelledby': id,
|
|
172
|
-
onKeyDownCapture: event => {
|
|
173
|
-
if (event.nativeEvent.isComposing) return;
|
|
174
|
-
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); if (!busy) onClose(); }
|
|
175
|
-
if (event.key === 'Tab') {
|
|
176
|
-
event.stopPropagation();
|
|
177
|
-
const targets = [...event.currentTarget.querySelectorAll('input:not([disabled]),textarea:not([disabled]),select:not([disabled]),button:not([disabled]),a[href],summary')].filter(node => node.getClientRects().length);
|
|
178
|
-
const first = targets[0], last = targets[targets.length - 1];
|
|
179
|
-
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
|
|
180
|
-
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
|
|
304
|
+
async function request(action, args, signal) {
|
|
305
|
+
const response = await fetch(new URL(ROUTE, document.baseURI), action ? {
|
|
306
|
+
method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, ...args }), signal,
|
|
307
|
+
} : { credentials: 'same-origin', signal });
|
|
308
|
+
let value;
|
|
309
|
+
try {
|
|
310
|
+
value = await response.json();
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
throw new Error(response.status === 401 ? t('session') : t('loadError'));
|
|
314
|
+
}
|
|
315
|
+
if (!response.ok) {
|
|
316
|
+
const error = new Error(['conflict', 'duplicate', 'readonly'].includes(value.code) ? t(value.code) : feedError(value.error, value.code) || t('loadError'));
|
|
317
|
+
error.snapshot = value.snapshot;
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
return value;
|
|
181
321
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
322
|
+
function Dialog({ title, busy, onClose, children, wide }) {
|
|
323
|
+
const ref = useRef(null);
|
|
324
|
+
const id = useId();
|
|
325
|
+
useEffect(() => { ref.current.showModal(); return () => ref.current?.close(); }, []);
|
|
326
|
+
return h('dialog', { ref, className: 'dshr-dialog' + (wide ? ' catalog' : ''), 'aria-labelledby': id,
|
|
327
|
+
onKeyDownCapture: event => {
|
|
328
|
+
if (event.nativeEvent.isComposing)
|
|
329
|
+
return;
|
|
330
|
+
if (event.key === 'Escape') {
|
|
331
|
+
event.preventDefault();
|
|
332
|
+
event.stopPropagation();
|
|
333
|
+
if (!busy)
|
|
334
|
+
onClose();
|
|
335
|
+
}
|
|
336
|
+
if (event.key === 'Tab') {
|
|
337
|
+
event.stopPropagation();
|
|
338
|
+
const targets = [...event.currentTarget.querySelectorAll('input:not([disabled]),textarea:not([disabled]),select:not([disabled]),button:not([disabled]),a[href],summary')].filter(node => node.getClientRects().length);
|
|
339
|
+
const first = targets[0], last = targets[targets.length - 1];
|
|
340
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
341
|
+
event.preventDefault();
|
|
342
|
+
last?.focus();
|
|
343
|
+
}
|
|
344
|
+
else if (!event.shiftKey && document.activeElement === last) {
|
|
345
|
+
event.preventDefault();
|
|
346
|
+
first?.focus();
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
onCancel: event => { event.preventDefault(); event.stopPropagation(); if (!busy)
|
|
351
|
+
onClose(); } }, h('h3', { id }, title), children);
|
|
352
|
+
}
|
|
353
|
+
function Panel({workbench=false}={}) {
|
|
354
|
+
const [activeTab,setActiveTab] = useState('feeds');
|
|
355
|
+
function exportFeeds(){return run('export',{},value=>{const url=URL.createObjectURL(new Blob([value.opml],{type:'text/x-opml;charset=utf-8'}));const link=document.createElement('a');link.href=url;link.download='rss-subscriptions.opml';document.body.appendChild(link);link.click();link.remove();setTimeout(()=>URL.revokeObjectURL(url),10000);setNotice(t('downloaded'));});}
|
|
356
|
+
const [, setLanguage] = useState(language);
|
|
357
|
+
const [state, setState] = useState(null), [loading, setLoading] = useState(true), [busy, setBusy] = useState('');
|
|
358
|
+
const [error, setError] = useState(''), [notice, setNotice] = useState(''), [query, setQuery] = useState(''), [category, setCategory] = useState('*');
|
|
359
|
+
const [modal, setModal] = useState(null), [draft, setDraft] = useState({ url: '', name: '', category: '' });
|
|
360
|
+
const [opml, setOpml] = useState(''), [updateExisting, setUpdateExisting] = useState(false), [preview, setPreview] = useState(null);
|
|
361
|
+
const [catalogQuery, setCatalogQuery] = useState(''), [catalogCategory, setCatalogCategory] = useState('*'), [catalogLanguage, setCatalogLanguage] = useState('*');
|
|
362
|
+
const [sourcePreviews, setSourcePreviews] = useState({}), [selectedSource, setSelectedSource] = useState('');
|
|
363
|
+
const categoryListId = useId();
|
|
364
|
+
const controller = useRef(null), trigger = useRef(null);
|
|
365
|
+
useEffect(() => { listeners.add(setLanguage); return () => listeners.delete(setLanguage); }, []);
|
|
366
|
+
useEffect(() => { void run(null); return () => controller.current?.abort(); }, []);
|
|
367
|
+
async function run(action, args = {}, onSuccess) {
|
|
368
|
+
if (controller.current)
|
|
369
|
+
return;
|
|
370
|
+
const abort = new AbortController();
|
|
371
|
+
controller.current = abort;
|
|
372
|
+
setBusy(action === 'check' && args.id ? args.id : action || 'load');
|
|
373
|
+
setError('');
|
|
374
|
+
setNotice('');
|
|
375
|
+
try {
|
|
376
|
+
const value = await request(action, args, abort.signal);
|
|
377
|
+
abort.signal.throwIfAborted();
|
|
378
|
+
if (value.snapshot)
|
|
379
|
+
setState(value.snapshot);
|
|
380
|
+
if (onSuccess)
|
|
381
|
+
onSuccess(value);
|
|
382
|
+
return value;
|
|
383
|
+
}
|
|
384
|
+
catch (failure) {
|
|
385
|
+
if (!abort.signal.aborted) {
|
|
386
|
+
if (failure.snapshot)
|
|
387
|
+
setState(failure.snapshot);
|
|
388
|
+
setError(failure.message || t('loadError'));
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
finally {
|
|
392
|
+
if (!abort.signal.aborted) {
|
|
393
|
+
setBusy('');
|
|
394
|
+
setLoading(false);
|
|
395
|
+
}
|
|
396
|
+
controller.current = null;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
function close() { if(!modal && activeTab === 'transfer') setActiveTab('feeds'); setModal(null); setError(''); setPreview(null); queueMicrotask(() => trigger.current?.focus()); }
|
|
400
|
+
function open(type, feed, event) {
|
|
401
|
+
trigger.current = event.currentTarget;
|
|
402
|
+
setError('');
|
|
403
|
+
setNotice('');
|
|
404
|
+
setPreview(null);
|
|
405
|
+
if (type === 'feed')
|
|
406
|
+
setDraft(feed ? { url: feed.url, name: feed.name, category: feed.category } : { url: '', name: '', category: '' });
|
|
407
|
+
if (type === 'import') {
|
|
408
|
+
setOpml('');
|
|
409
|
+
setUpdateExisting(false);
|
|
410
|
+
}
|
|
411
|
+
if (type === 'catalog') {
|
|
412
|
+
setCatalogQuery('');
|
|
413
|
+
setCatalogCategory('*');
|
|
414
|
+
setCatalogLanguage('*');
|
|
415
|
+
setSelectedSource('');
|
|
416
|
+
}
|
|
417
|
+
setModal({ type, feed });
|
|
418
|
+
}
|
|
419
|
+
async function chooseFile(event) {
|
|
420
|
+
const file = event.target.files?.[0];
|
|
421
|
+
event.target.value = '';
|
|
422
|
+
if (!file)
|
|
423
|
+
return;
|
|
424
|
+
if (file.size > 1024 * 1024) {
|
|
425
|
+
setError(t('bodyLarge'));
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
try {
|
|
429
|
+
setOpml(await file.text());
|
|
430
|
+
setPreview(null);
|
|
431
|
+
setError('');
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
setError(t('fileError'));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
const field = (key, hint) => h('label', { key }, t(key), h('input', {
|
|
438
|
+
name: key, type: key === 'url' ? 'url' : 'text', value: draft[key], placeholder: hint, required: key === 'url', disabled: !!busy,
|
|
439
|
+
list: key === 'category' ? categoryListId : undefined,
|
|
440
|
+
autoFocus: key === 'url', onChange: event => setDraft({ ...draft, [key]: event.target.value }),
|
|
441
|
+
}));
|
|
442
|
+
const buttons = submit => h('div', { className: 'dshr-buttons' }, h('button', { type: 'button', disabled: !!busy, onClick: close }, t('cancel')), submit);
|
|
443
|
+
const alert = error ? h('div', { role: 'alert', className: 'dshr-alert' }, error) : null;
|
|
444
|
+
const feeds = state?.feeds || [], writable = state?.writable === true;
|
|
445
|
+
const categories = [...new Set(feeds.map(feed => feed.category))].sort();
|
|
446
|
+
const catalog = state?.catalog || { categories: [], sources: [] };
|
|
447
|
+
const sourceCategory = source => localized(catalog.categories.find(category => category.id === source.category)?.name);
|
|
448
|
+
const sourceOptions = catalog.sources.filter(source => (catalogCategory === '*' || source.category === catalogCategory) &&
|
|
449
|
+
(catalogLanguage === '*' || source.language === catalogLanguage) &&
|
|
450
|
+
(source.name + ' ' + source.url + ' ' + (source.description || []).join(' ')).toLowerCase().includes(catalogQuery.trim().toLowerCase()));
|
|
451
|
+
const filtered = feeds.filter(feed => (category === '*' || feed.category === category) && (feed.name + ' ' + feed.url).toLowerCase().includes(query.trim().toLowerCase()));
|
|
452
|
+
return h(W.Page,{workbench,title:t('title'),subtitle:rssText('选择可信来源,先预览再订阅。','Choose trusted sources. Preview before subscribing.'),className:'dshr rss-workbench',actions:h(React.Fragment,null,
|
|
453
|
+
h(W.Button,{icon:'plus',variant:'primary',disabled:!writable || !!busy,onClick:event=>open('feed',null,event)},t('add')),
|
|
454
|
+
h(W.Button,{icon:'rss',disabled:!state || !!busy,onClick:()=>setActiveTab('catalog')},t('recommended')))},
|
|
455
|
+
h('style',null,RSS_WORKBENCH_CSS),
|
|
456
|
+
state && !writable ? h(W.Status,null,t('readonly')) : null,
|
|
457
|
+
!modal ? h(W.Status,{tone:'error'},error) : null,h(W.Status,{tone:'success'},notice),
|
|
458
|
+
h(W.Tabs,{label:rssText('RSS 视图','RSS views'),value:activeTab,onChange:setActiveTab,items:[{id:'feeds',label:rssText('我的订阅','My feeds')},{id:'catalog',label:t('recommended')},{id:'transfer',label:rssText('导入与导出','Import and export')}]},
|
|
459
|
+
loading ? h(W.Empty,{title:t('loading'),icon:'rss'}) : !state ? h(W.Empty,{title:t('loadError'),action:h(W.Button,{disabled:!!busy,onClick:()=>run(null)},t('retry'))}) :
|
|
460
|
+
activeTab === 'feeds' ? h(RssFeedBrowser,{feeds,filtered,categories,query,setQuery,category,setCategory,writable,busy,run,open}) :
|
|
461
|
+
activeTab === 'catalog' ? h(RssCatalog,{catalog,sources:sourceOptions,query:catalogQuery,setQuery:setCatalogQuery,category:catalogCategory,setCategory:setCatalogCategory,contentLanguage:catalogLanguage,setContentLanguage:setCatalogLanguage,busy,writable,state,run,previews:sourcePreviews,setPreviews:setSourcePreviews,setNotice}) :
|
|
462
|
+
h('div',{className:'rss-transfer'},h('div',{className:'w-toolbar'},h('h3',null,rssText('订阅备份与迁移','Back up and move subscriptions')),h(W.Button,{icon:'download',disabled:!state || !!busy,onClick:exportFeeds},t('export'))),h('p',{className:'w-muted'},t('preserve')),h('form', { onSubmit: event => { event.preventDefault(); void run('preview', { opml, updateExisting }, value => setPreview(value.preview)); } }, 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 })), h('label', null, t('opml'), h('textarea', { required: true, autoFocus: true, value: opml, disabled: !!busy, onChange: event => { setOpml(event.target.value); setPreview(null); } })), h('label', { className: 'dshr-checkbox' }, h('input', { type: 'checkbox', checked: updateExisting, disabled: !!busy, onChange: event => { setUpdateExisting(event.target.checked); setPreview(null); } }), t('update')), h('p', { className: 'dshr-muted' }, t('preserve')), preview ? h('div', { className: 'dshr-preview', role: 'status' }, h('strong', null, `${t('new')} ${preview.addedCount} · ${t('duplicates')} ${preview.existedCount} · ${t('invalid')} ${preview.skippedCount}`), !preview.addedCount && !preview.existedCount ? h('p', null, t('previewEmpty')) : null, 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'))), (preview.duplicates || []).slice(0, 20).map((item, index) => h('li', { key: 'duplicate-' + index }, t('duplicates'), ':', item.before.name || item.url, ' · ', item.before.category || t('uncategorized'), updateExisting ? ' → ' + (item.after.name || item.url) + ' · ' + (item.after.category || t('uncategorized')) : ' · ' + t('keep'))), preview.skipped.slice(0, 20).map((item, index) => h('li', { key: index }, item.title, ' · ', item.url, language === 'en' ? ': ' : ':', feedError(item.reason)))), preview.added.length > 20 || preview.skipped.length > 20 || (preview.duplicates || []).length > 20 ? h('p', { className: 'dshr-muted' }, t('previewLimit')) : null) : null, h('div', { className: 'dshr-buttons' }, h('button', { type: 'button', disabled: !!busy, onClick: close }, t('cancel')), h('button', { type: 'submit', disabled: !!busy || !opml.trim() }, t(busy === 'preview' ? 'saving' : 'preview')), 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 => {
|
|
463
|
+
close();
|
|
464
|
+
setNotice(`${t('imported')} · ${t('new')} ${value.result.addedCount} · ${t('duplicates')} ${value.result.existedCount} · ${t('invalid')} ${value.result.skippedCount}`);
|
|
465
|
+
}) }, t(busy === 'import' ? 'saving' : 'confirm')) : null)))),
|
|
466
|
+
modal ? h(Dialog,{title:modal.type === 'remove' ? t('deleteTitle') : modal.feed ? t('edit') : t('add'),busy:!!busy,onClose:close},alert,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')); }); } }, field('url', 'https://example.com/feed.xml'), field('name', t('nameHint')), field('category', t('categoryHint')), h('datalist', { id: categoryListId }, [...new Set([...categories, ...catalog.categories.map(category => localized(category.name))])].filter(Boolean).map(value => h('option', { key: value, value }))), h('p', { className: 'dshr-muted' }, t('customCategory')), error ? h('button', { type: 'button', disabled: !!busy, onClick: () => run(null) }, t('refresh')) : null, buttons(h('button', { type: 'submit', className: 'primary', disabled: !!busy }, t(busy ? 'saving' : 'save')))) : 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')), 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'))))) : null);
|
|
467
|
+
}
|
|
468
|
+
function apply(ctx) {
|
|
469
|
+
W.register(ctx,{id:'dsh-rss.workbench',label:()=>t('title'),icon:'rss',component:Panel,order:56});
|
|
470
|
+
|
|
471
|
+
ctx.effect(() => {
|
|
472
|
+
const service = ctx.get ? ctx.get('locale') : ctx.locale;
|
|
473
|
+
const update = () => { language = String(service?.getSnapshot?.().active || 'zh').startsWith('en') ? 'en' : 'zh'; for (const notify of listeners)
|
|
474
|
+
notify(language); };
|
|
475
|
+
update();
|
|
476
|
+
return service?.subscribe?.(update) || (() => { });
|
|
477
|
+
}, 'dsh-rss: locale');
|
|
478
|
+
ctx.effect(() => {
|
|
479
|
+
const style = document.createElement('style');
|
|
480
|
+
style.dataset.pluginCss = 'dsh-rss/client';
|
|
481
|
+
style.textContent = CSS;
|
|
482
|
+
document.head.appendChild(style);
|
|
483
|
+
return () => style.remove();
|
|
484
|
+
}, 'dsh-rss: styles');
|
|
485
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'dsh-rss', order: 55, label: () => {
|
|
486
|
+
let lang = language;
|
|
487
|
+
try {
|
|
488
|
+
const service = ctx.get ? ctx.get('locale') : ctx.locale;
|
|
489
|
+
if (service?.getSnapshot)
|
|
490
|
+
lang = String(service.getSnapshot().active || '').startsWith('en') ? 'en' : 'zh';
|
|
491
|
+
}
|
|
492
|
+
catch { }
|
|
493
|
+
return words.title[lang === 'en' ? 1 : 0];
|
|
494
|
+
}, inject: () => ({}) }, Panel));
|
|
495
|
+
}
|
|
496
|
+
return { apply, inject: ['slots', 'locale'] };
|
|
497
|
+
} });
|
|
498
|
+
|
|
499
|
+
})();
|
package/lib/web.js
CHANGED
|
@@ -111,12 +111,15 @@ export function createRssSettingsBackend(config, scope, options = {}) {
|
|
|
111
111
|
return { opml: buildOpml(subscriptionSnapshot(scope).feeds) };
|
|
112
112
|
if (action === 'preview')
|
|
113
113
|
return { preview: preview(args) };
|
|
114
|
-
if (action === 'previewSource') {
|
|
115
|
-
const source = recommendedSources.find(source => source.id === text(args, 'sourceId'));
|
|
116
|
-
if (!source)
|
|
114
|
+
if (action === 'previewSource' || action === 'previewFeed') {
|
|
115
|
+
const source = action === 'previewSource' ? recommendedSources.find(source => source.id === text(args, 'sourceId')) : undefined;
|
|
116
|
+
if (action === 'previewSource' && !source)
|
|
117
117
|
throw new SubscriptionError('这个推荐源不存在,请刷新目录。');
|
|
118
|
-
const
|
|
119
|
-
|
|
118
|
+
const feed = action === 'previewFeed' ? find(subscriptionSnapshot(scope).feeds, text(args, 'id')) : undefined;
|
|
119
|
+
// Resolve the saved/catalog ID, never a URL supplied alongside it. Preview
|
|
120
|
+
// shares the existing SSRF, redirect, size, timeout and cancellation gates.
|
|
121
|
+
const { parsed, finalUrl } = await check(source?.url ?? feed.url, signal);
|
|
122
|
+
return { [action === 'previewSource' ? 'sourcePreview' : 'feedPreview']: { ...(source ? { sourceId: source.id } : { feedId: feedId(feed.url) }), checkedAt: new Date().toISOString(), title: parsed.feed.title,
|
|
120
123
|
entryCount: parsed.entries.length, entries: parsed.entries.slice(0, 5).map(entry => ({
|
|
121
124
|
title: entry.title.slice(0, 400), link: articleLink(entry.link, finalUrl), pubDate: entry.pubDate.slice(0, 64),
|
|
122
125
|
})) } };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-rss",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "订阅和阅读 RSS / Atom,在 DSH 中查看与管理更新。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"CHANGELOG.md"
|
|
24
24
|
],
|
|
25
25
|
"scripts": {
|
|
26
|
-
"build": "tsc -p tsconfig.json",
|
|
26
|
+
"build": "tsc -p tsconfig.json && node scripts/build-client.mjs",
|
|
27
27
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
28
28
|
"test": "pnpm run build && node --test \"test/*.test.mjs\"",
|
|
29
29
|
"prepublishOnly": "pnpm run build"
|
|
@@ -31,7 +31,9 @@
|
|
|
31
31
|
"dsh": {
|
|
32
32
|
"client": {
|
|
33
33
|
"inject": [
|
|
34
|
-
"@deepseek-ai/dsh-client-ui-settings"
|
|
34
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
35
|
+
"@deepseek-ai/dsh-client-ui-layout",
|
|
36
|
+
"@deepseek-ai/dsh-client-ui-sidebar"
|
|
35
37
|
],
|
|
36
38
|
"platform": "web"
|
|
37
39
|
},
|