dsh-cite 0.5.0 → 0.6.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 CHANGED
@@ -4,6 +4,13 @@
4
4
 
5
5
  [历史英文记录](docs/CHANGELOG.en.md)
6
6
 
7
+ ## 0.6.0(2026-10-25)
8
+
9
+ - 引文主工作台,文献列表、元数据、来源与引文预览分栏;搜索与导入后的选中状态一致。
10
+ - 提供 DSH 原生侧栏入口,保留设置页;统一中英、深浅色、窄屏、键盘焦点与真实空/失败状态。
11
+ - 浏览器源码移至 templates/client.js、templates/panels/ 与共享组件,独立构建为 lib/client.js;移除重复的首次引导。
12
+ - 官方 DSH 0.2.1-rc.3 Web 逐项实际操作验收,账户模拟和未验收范围见 [工作台说明](docs/WORKBENCH.md)。
13
+
7
14
  ## 0.5.0(2026-10-17)
8
15
 
9
16
  - 设置面板顶部新增「首次使用」分步引导:一次只讲一步,支持上一步/下一步/跳过,看完记在本机不再出现。
package/README.en.md CHANGED
@@ -10,6 +10,8 @@ Organize DOI lists or BibTeX into a reviewable local library, then export refere
10
10
 
11
11
  Feedback and contributions are welcome: report [issues](https://github.com/STARDUSTLC666/dsh-cite/issues) or submit [pull requests](https://github.com/STARDUSTLC666/dsh-cite/pulls).
12
12
 
13
+ Open the new native sidebar workbench after updating and restarting DSH. Settings remains available. See the [workbench guide and validation boundaries](docs/WORKBENCH.md).
14
+
13
15
  ## What it does
14
16
 
15
17
  - Paste DOI lists or upload `.bib` files. Preview locally before choosing entries to save, with duplicate and missing-field warnings.
package/README.md CHANGED
@@ -10,6 +10,8 @@
10
10
 
11
11
  欢迎使用,遇到问题或有改进建议,请提交 [issues](https://github.com/STARDUSTLC666/dsh-cite/issues) 和 [PR](https://github.com/STARDUSTLC666/dsh-cite/pulls)。
12
12
 
13
+ 更新并重启 DSH 后,可从原生侧栏打开新版工作台,原设置入口继续保留。详见[工作台说明与验收范围](docs/WORKBENCH.md)。
14
+
13
15
  ## 功能
14
16
 
15
17
  - 批量粘贴 DOI 或上传 `.bib`,本地预览后选择保存;提示精确重复、可能重复和缺失字段。
@@ -0,0 +1,29 @@
1
+ # dsh-cite 工作台 / Workbench
2
+
3
+ 引文主工作台,文献列表、元数据、来源与引文预览分栏;搜索与导入后的选中状态一致。
4
+
5
+ Reference workbench with list/detail metadata, sources and matching citation previews after search or import.
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,69 +1,314 @@
1
- window.__ModuleLoader__.load({ id: 'dsh-cite', factory: require => {
2
- const React = require('react'), { useState, useEffect, useRef } = React, h = React.createElement, route = 'api/dsh-cite/library';
3
- let activeLang = 'zh', cache = { kind: 'doi', text: '', label: '', preview: null, chosen: [], selected: [], style: 'gb-t-7714', dirty: false, job: null };
4
- const css = `.dcw{font:14px/1.65 system-ui,sans-serif;color:var(--fg,#203b42);min-width:0;padding:8px}.dcw *{box-sizing:border-box}.dcw h2{font-size:23px;margin:0}.dcw h3{font-size:17px;margin:0 0 10px}.dcw p{margin:8px 0}.dcw .muted{color:var(--fg-muted,#677881);font-size:13px}.dcw .bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:12px 0}.dcw button,.dcw .button{font:inherit;color:inherit;border:1px solid var(--border,#c5d5d4);border-radius:8px;padding:8px 12px;background:var(--bg-surface,#fff);text-decoration:none;cursor:pointer;white-space:normal}.dcw button.primary{background:#27695b;border-color:#27695b;color:white}.dcw button:disabled{opacity:.45;cursor:default}.dcw label{display:block;margin:10px 0 4px}.dcw input:not([type=checkbox]),.dcw textarea,.dcw select{font:inherit;color:inherit;width:100%;border:1px solid var(--border,#bfd1d0);border-radius:8px;background:var(--bg,#fff);padding:9px}.dcw input[type=checkbox]{accent-color:#27695b;width:18px;height:18px;flex-shrink:0}.dcw textarea{resize:vertical;min-height:140px}.dcw .grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.dcw .card{border:1px solid var(--border,#c5d5d4);border-radius:12px;padding:14px;margin:12px 0;min-width:0;background:var(--bg-surface,#fff)}.dcw .notice{border-radius:8px;padding:10px;background:#eef5ed;color:#2a604a;overflow-wrap:anywhere}.dcw .error{background:#fff0ea;color:#943b27}.dcw .entry{padding:12px 0;border-top:1px solid var(--border,#d3dedd);overflow-wrap:anywhere}.dcw .entry:first-child{border-top:0}.dcw .choose{display:flex;align-items:flex-start;gap:10px;margin:0;cursor:pointer}.dcw .choose input{margin-top:4px}.dcw .choose>span{min-width:0}.dcw .tag{display:inline-block;margin:4px 5px 0 0;padding:2px 6px;border-radius:5px;font-size:12px;background:var(--bg-muted,#edf2f1)}.dcw .warn{color:#a65318}.dcw pre{font:13px/1.6 system-ui,sans-serif;white-space:pre-wrap;overflow-wrap:anywhere;max-height:220px;overflow:auto;margin:8px 0}.dcw details{margin-top:10px}.dcw summary{cursor:pointer}.dcw dl{display:grid;grid-template-columns:auto 1fr;gap:5px 14px}.dcw dt{font-weight:600}.dcw dd{margin:0;overflow-wrap:anywhere}.dcw progress{width:100%;height:12px}.dcw :focus-visible{outline:3px solid #79cdb4;outline-offset:2px}[role=dialog]:has(.dcw){width:min(1200px,calc(100vw - 40px))!important;max-width:1200px!important}@media(max-width:800px){.dcw{padding:3px}.dcw .grid{grid-template-columns:1fr}[role=dialog]:has(.dcw){width:100%!important;max-width:100%!important;flex-direction:column!important}[role=dialog]:has(.dcw)>nav+div{min-width:0;width:100%}[role=dialog]:has(.dcw)>nav+div>div:has(.dcw){padding:12px!important;min-width:0}[role=dialog]:has(.dcw) nav{width:100%!important;flex-basis:auto!important;border-right:0;max-height:170px;overflow:auto}[role=dialog]:has(.dcw) nav>div:has(>button){display:flex;flex-direction:row!important;flex-wrap:wrap;gap:4px}}`;
5
- async function api(body) {
6
- const r = await fetch(new URL(route, document.baseURI), { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json', 'x-dsh-cite': '1' }, body: JSON.stringify(body) });
7
- let data; try { data = await r.json() } catch { throw new Error(activeLang === 'en' ? 'Could not read the library response. Check whether DSH restarted, then retry.' : '无法读取文献库响应,请检查 DSH 是否已重启。') }
8
- if (!r.ok || !data.ok) { const e = new Error(activeLang === 'en' ? data.messageEn || 'The operation could not be completed. Check the input or refresh and retry.' : data.message || '操作未完成'); e.status = r.status; throw e; } return data.value;
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);
9
85
  }
10
- function download(kind, style, lang, ids) { const u = new URL(route, document.baseURI); u.searchParams.set('kind', kind); if (style) u.searchParams.set('style', style); if (lang) u.searchParams.set('lang', lang); if (ids?.length) u.searchParams.set('ids', ids.join(',')); return u.href; }
11
- const authors = work => work.authors.map(a => a.name || [a.family, a.given].filter(Boolean).join(' ')).join(', ');
12
- const valueText = v => Array.isArray(v) ? v.map(a => typeof a === 'object' ? a.name || [a.family, a.given].filter(Boolean).join(' ') : String(a)).join(', ') : String(v ?? '');
13
- function Workbench({ locale }) {
14
- const [lang, setLang] = useState('zh'), [kind, setKind] = useState(cache.kind), [text, setText] = useState(cache.text), [label, setLabel] = useState(cache.label);
15
- const [preview, setPreview] = useState(cache.preview), [chosen, setChosen] = useState(cache.chosen), [selected, setSelected] = useState(cache.selected), [style, setStyle] = useState(cache.style), [dirty, setDirty] = useState(cache.dirty);
16
- const [library, setLibrary] = useState({ revision: 0, entries: [], sources: [] }), [job, setJob] = useState(cache.job), [busy, setBusy] = useState(false), [error, setError] = useState(''), [notice, setNotice] = useState(''), [conflict, setConflict] = useState(false), [search, setSearch] = useState(''), [limit, setLimit] = useState(50), [activeId, setActiveId] = useState(''), [citation, setCitation] = useState('');
17
- const busyRef = useRef(false), fileRef = useRef(null), dirtyRef = useRef(dirty); dirtyRef.current = dirty;
18
- const t = (zh, en) => lang === 'en' ? en : zh, running = job?.state === 'running', locked = busy || running;
19
- const warning = code => ({ missing_title: t('缺少标题', 'Missing title'), missing_authors: t('缺少作者', 'Missing authors'), missing_year: t('缺少年份', 'Missing year'), missing_doi: t('没有 DOI,无法在线补全', 'No DOI for online lookup'), long_citation_key: t('引用键过长,导出将使用短键', 'Long citation key; export uses a shorter key') }[code] || code);
20
- const fieldName = key => ({ title: t('标题', 'Title'), authors: t('作者', 'Authors'), year: t('年份', 'Year'), containerTitle: t('期刊 / 文集', 'Journal / collection'), publisher: t('出版社', 'Publisher'), type: t('文献类型', 'Type'), volume: t('卷', 'Volume'), issue: t('期', 'Issue'), page: t('页码', 'Pages'), language: t('语言', 'Language'), url: 'URL', isbn: 'ISBN' }[key] || key);
21
- useEffect(() => { const update = () => { activeLang = String(locale?.getSnapshot?.().active || '').startsWith('en') ? 'en' : 'zh'; setLang(activeLang); }; update(); return locale?.subscribe?.(update); }, [locale]);
22
- useEffect(() => { cache = { kind, text, label, preview, chosen, selected, style, dirty, job }; }, [kind, text, label, preview, chosen, selected, style, dirty, job]);
23
- useEffect(() => { const warn = e => { if (dirtyRef.current) { e.preventDefault(); e.returnValue = ''; } }; window.addEventListener('beforeunload', warn); return () => window.removeEventListener('beforeunload', warn); }, []);
24
- async function refresh() { const state = await api({ action: 'list' }); setLibrary(state); setSelected(old => old.filter(id => state.entries.some(e => e.id === id))); setJob(old => state.jobs.find(j => j.id === old?.id) || state.jobs.find(j => j.state === 'running') || state.jobs.at(-1) || null); }
25
- useEffect(() => { refresh().catch(e => setError(e.message)); }, []);
86
+ function Split({ list, detail, selected, onBack, backLabel, className, listWidth = 360 }) {
87
+ const ref = useRef(null);
26
88
  useEffect(() => {
27
- if (!running) return; let live = true, polling = false;
28
- const timer = setInterval(async () => { if (polling) return; polling = true; try { const next = await api({ action: 'job', job: job.id }); if (!live) return; if (next.state !== 'running') { await refresh(); if (live) setJob(next); } else setJob(next); } catch (e) { if (live) { setError(e.message); setJob(null); } } finally { polling = false; } }, 1000);
29
- return () => { live = false; clearInterval(timer); };
30
- }, [job?.id, running]);
31
- const referenceId = activeId && library.entries.some(e => e.id === activeId) ? activeId : library.entries[0]?.id;
32
- useEffect(() => { let live = true; setCitation(''); if (referenceId) api({ action: 'export', kind: 'references', ids: [referenceId], style, lang }).then(v => { if (live) setCitation(v.content) }).catch(e => { if (live) setCitation(e.message) }); return () => { live = false; }; }, [referenceId, style, lang, library.revision]);
33
- async function perform(fn) { if (busyRef.current) return; busyRef.current = true; setBusy(true); setError(''); setNotice(''); try { await fn(); } catch (e) { setError(e.message); if (e.status === 409) setConflict(true); } finally { busyRef.current = false; setBusy(false); } }
34
- function changeInput(set, value) { set(value); setDirty(true); setPreview(null); setChosen([]); setConflict(false); }
35
- async function parse() { const p = await api({ action: 'preview', kind, text, label }); setPreview(p); setChosen(p.rows.filter(r => r.selectedDefault).map(r => r.index)); setConflict(false); }
36
- async function save() { const oldIds = new Set(library.entries.map(e => e.id)), state = await api({ action: 'import', preview: preview.id, selection: chosen }); setLibrary(state); setSelected(state.entries.filter(e => !oldIds.has(e.id)).map(e => e.id)); setPreview(null); setChosen([]); setDirty(false); setConflict(false); setNotice(t('已保存 ' + chosen.length + ' 条。原始输入也已保留在完整备份中。', 'Saved entries: ' + chosen.length + '. Original input is preserved in the full backup.')); }
37
- async function upload() { const file = fileRef.current?.files?.[0]; if (!file) return; await perform(async () => { if (!/\.bib$/i.test(file.name)) throw new Error(t('请选择 .bib 文件。', 'Choose a .bib file.')); if (file.size > 256 * 1024) throw new Error(t('文件超过 256 KiB,请拆分导入。', 'File exceeds 256 KiB. Split it before importing.')); const content = await file.text(); setKind('bibtex'); setText(content); setLabel(file.name); setPreview(null); setChosen([]); setDirty(true); setConflict(false); setNotice(t('文件已读取,点击“本地预览”检查后再保存。', 'File loaded. Choose Local preview before saving.')); }); fileRef.current.value = ''; }
38
- function toggle(values, value, set) { set(values.includes(value) ? values.filter(v => v !== value) : [...values, value]); }
39
- const filtered = library.entries.filter(e => [e.work.title, authors(e.work), e.work.doi, e.work.year].join(' ').toLowerCase().includes(search.toLowerCase())), visible = filtered.slice(0, limit), selectedIds = selected.length ? selected : undefined;
40
- const downloadLink = (format, zh, en) => library.entries.length ? h('a', { className: 'button', href: download(format, style, lang, format === 'backup' ? undefined : selectedIds), download: '' }, t(zh, en)) : h('button', { disabled: true }, t(zh, en));
41
- return h('section', { className: 'dcw', 'aria-label': t('文献工作台', 'Citation workbench') }, h('style', null, css),
42
- h('h2', null, 'Cite'), h('p', { className: 'muted' }, t('先在本地整理文献;只有点击在线补全才会查询 Crossref。不会捏造缺失信息。', 'Organize references locally. Crossref is contacted only when you request online enrichment. Missing information is never invented.')),
43
- error && h('div', { className: 'notice error', role: 'alert' }, error), notice && h('div', { className: 'notice', role: 'status' }, notice),
44
- conflict && h('div', { className: 'notice error' }, h('p', null, t('库已在别处更新。当前输入仍保留,请重新预览再保存。', 'The library changed elsewhere. Your input is retained; preview again before saving.')), h('button', { disabled: locked || !text.trim(), onClick: () => perform(async () => { await refresh(); await parse(); }) }, t('重新预览当前输入', 'Preview current input again'))),
45
- h('div', { className: 'grid' }, h('div', null,
46
- h('div', { className: 'card' }, h('h3', null, t('1. 导入与本地预览', '1. Import and local preview')),
47
- h('label', { htmlFor: 'cite-kind' }, t('输入类型', 'Input type')), h('select', { id: 'cite-kind', value: kind, disabled: locked, onChange: e => changeInput(setKind, e.target.value) }, h('option', { value: 'doi' }, t('DOI 列表(每行一条)', 'DOI list (one per line)')), h('option', { value: 'bibtex' }, 'BibTeX / BibLaTeX')),
48
- h('label', { htmlFor: 'cite-input' }, t('DOI / BibTeX 输入', 'DOI / BibTeX input')), h('textarea', { id: 'cite-input', rows: 8, value: text, disabled: locked, spellCheck: false, onChange: e => changeInput(setText, e.target.value), placeholder: kind === 'doi' ? '10.1038/nature12345\nhttps://doi.org/10.1145/3368089.3409749' : '@article{key,\n title={Your title},\n author={Family, Given},\n year={2026}\n}' }),
49
- h('label', { htmlFor: 'cite-source' }, t('来源名称(可选)', 'Source name (optional)')), h('input', { id: 'cite-source', value: label, maxLength: 160, disabled: locked, onChange: e => changeInput(setLabel, e.target.value) }),
50
- h('input', { ref: fileRef, type: 'file', accept: '.bib', style: { display: 'none' }, onChange: upload }), h('div', { className: 'bar' }, h('button', { disabled: locked, onClick: () => fileRef.current.click() }, t('上传 .bib', 'Upload .bib')), h('button', { className: 'primary', disabled: locked || !text.trim(), onClick: () => perform(parse) }, t('本地预览', 'Local preview'))),
51
- h('p', { className: 'muted' }, t('最多 256 KiB / 100 条;库最多 500 条。预览、去重和格式切换都不联网。上传仅读取文件,不改写原文件。', 'Up to 256 KiB / 100 entries per import; 500 in the library. Preview, deduplication and formatting are offline. Upload reads your file without changing it.')), dirty && h('p', { className: 'muted' }, t('当前输入尚未保存到文献库。关闭设置后输入仍保留。', 'Current input has not been saved. It is retained when you close settings.'))),
52
- preview && h('div', { className: 'card' }, h('h3', null, t('预览:选择要保存的文献', 'Preview: choose entries to save')), h('p', { className: 'muted' }, preview.label + ' · ' + t('已选 ', 'Selected ') + chosen.length + '/' + preview.rows.length),
53
- preview.rows.map(r => h('div', { className: 'entry', key: r.index }, h('label', { className: 'choose' }, h('input', { type: 'checkbox', checked: chosen.includes(r.index), disabled: locked || !!r.error || !!r.duplicate, 'aria-label': t('保存预览条目 ', 'Save preview entry ') + (r.index + 1), onChange: () => toggle(chosen, r.index, setChosen) }), h('span', null, h('strong', null, (r.index + 1) + '. ' + (r.work?.title || r.work?.doi || r.key)), r.work && h('div', { className: 'muted' }, [authors(r.work), r.work.year || '', r.work.doi].filter(Boolean).join(' · ')), r.duplicate && h('span', { className: 'tag warn' }, r.duplicate === 'library' ? t('DOI 已在库中,跳过', 'DOI already saved; skipped') : t('同批 DOI 重复,跳过', 'Repeated DOI in this batch; skipped')), r.candidate && h('span', { className: 'tag warn' }, t('可能重复,请核对后决定', 'Possible duplicate; review before selecting')), r.warnings.map(w => h('span', { key: w, className: 'tag warn' }, warning(w))), r.error && h('p', { className: 'notice error' }, t(r.error, r.errorEn || 'Invalid reference. Check the original input.')))))),
54
- h('div', { className: 'bar' }, h('button', { className: 'primary', disabled: locked || conflict || !chosen.length, onClick: () => perform(save) }, t('保存所选 ', 'Save selected ') + chosen.length), h('button', { disabled: locked, onClick: () => { setPreview(null); setChosen([]); } }, t('返回输入', 'Back to input'))))),
55
- h('div', null, h('div', { className: 'card' }, h('h3', null, t('2. 文献库', '2. Library')), h('div', { className: 'bar' }, h('span', null, library.entries.length + t(' 条 · 修订 ', ' entries · revision ') + library.revision), h('button', { disabled: locked, onClick: () => perform(refresh) }, t('刷新文献库', 'Refresh library'))),
56
- library.entries.length ? h(React.Fragment, null, h('label', { htmlFor: 'cite-search' }, t('搜索已保存文献', 'Search saved entries')), h('input', { id: 'cite-search', value: search, onChange: e => { setSearch(e.target.value); setLimit(50); } }),
57
- h('div', { className: 'bar' }, h('button', { disabled: locked || !visible.length, onClick: () => setSelected(visible.slice(0, 100).map(e => e.id)) }, t('选择当前可见项(最多 100)', 'Select visible (up to 100)')), h('button', { disabled: locked || !selected.length, onClick: () => setSelected([]) }, t('清空选择', 'Clear selection')), h('span', { className: 'muted' }, t('已选 ', 'Selected ') + selected.length)),
58
- visible.map(e => h('div', { className: 'entry', key: e.id }, h('label', { className: 'choose' }, h('input', { type: 'checkbox', checked: selected.includes(e.id), disabled: locked || selected.length >= 100 && !selected.includes(e.id), 'aria-label': t('选择文献 ', 'Select entry ') + (e.work.title || e.work.doi || e.id), onChange: () => toggle(selected, e.id, setSelected) }), h('span', null, h('strong', null, e.work.title || e.work.doi || t('无标题', 'Untitled')), h('div', { className: 'muted' }, [authors(e.work), e.work.year || '', e.work.doi].filter(Boolean).join(' · ')), e.warnings.map(w => h('span', { className: 'tag warn', key: w }, warning(w))))),
59
- h('details', null, h('summary', null, t('元数据与来源', 'Metadata and sources')), h('dl', null, ['containerTitle','publisher','volume','issue','page','url','isbn'].filter(key => e.work[key]).flatMap(key => [h('dt', { key: key + '-dt' }, fieldName(key)), h('dd', { key: key + '-dd' }, e.work[key])])), h('p', { className: 'muted' }, t('导入来源:', 'Imported from: ') + e.sourceIds.map(id => library.sources.find(s => s.id === id)?.label || id).join(', ')), e.crossref && h('p', { className: 'muted' }, 'Crossref · ' + new Date(e.crossref.fetchedAt).toLocaleString()), e.crossref?.conflicts?.length > 0 && h('div', { className: 'notice error' }, h('strong', null, t('有差异,已保留本地值', 'Differences found; local values retained')), e.crossref.conflicts.map(c => h('div', { key: c.field }, h('strong', null, fieldName(c.field)), h('p', null, t('本地:', 'Local: ') + valueText(c.local)), h('p', null, 'Crossref: ' + valueText(c.crossref))))), h('button', { onClick: () => setActiveId(e.id) }, t('预览这条引文', 'Preview this reference'))))),
60
- h('p', { className: 'muted' }, t('显示 ', 'Showing ') + visible.length + '/' + filtered.length), visible.length < filtered.length && h('button', { onClick: () => setLimit(n => n + 50) }, t('再显示 50 条', 'Show 50 more'))) : h('p', { className: 'muted' }, t('文献库为空。先预览输入,再保存选择的文献。', 'Your library is empty. Preview input, then save the entries you choose.')),
61
- h('div', { className: 'bar' }, h('button', { className: 'primary', disabled: locked || !selected.length, onClick: () => perform(async () => { setJob(await api({ action: 'enrich', ids: selected, revision: library.revision })); }) }, t('从 Crossref 在线补全所选项', 'Fill selected entries from Crossref (online)'))), h('p', { className: 'muted' }, t('每次最多 100 条,必须有 DOI。只补空字段;冲突会保留本地值并列出差异。失败可以重新选择后重试。', 'Up to 100 entries with a DOI per job. Only empty fields are filled. Conflicts retain local values and show differences. Select failed entries to retry.'))),
62
- job && h('div', { className: job.state === 'failed' ? 'card notice error' : 'card', role: job.state === 'failed' ? 'alert' : 'status' }, h('h3', null, t('补全进度', 'Enrichment progress')), h('progress', { max: job.total, value: job.completed }), h('p', null, ({ running: t('查询中', 'Looking up'), done: t('已完成', 'Completed'), failed: t('未保存本次补全', 'Enrichment was not saved'), cancelled: t('已取消,原文献保留', 'Cancelled; saved entries preserved') }[job.state]) + ' · ' + job.completed + '/' + job.total), job.state === 'done' && h('p', null, t('已检查并保存 ', 'Entries checked and saved: ') + job.saved + t(' 条;失败 ', '; failed: ') + job.results.filter(r => !r.ok).length), job.message && h('p', { className: 'notice error' }, t(job.message, job.messageEn || 'The job could not be completed; saved entries are preserved.')), running && h('button', { disabled: busy, onClick: () => perform(async () => { await api({ action: 'cancel', job: job.id }); }) }, t('取消本次补全', 'Cancel enrichment')), h('details', null, h('summary', null, t('查看每条结果', 'Show individual results')), job.results.map(r => h('div', { className: 'entry', key: r.id }, h('strong', null, r.doi || t('没有 DOI', 'No DOI')), h('p', null, r.ok ? t('补全字段:', 'Fields filled: ') + (r.filled.length ? r.filled.map(fieldName).join(', ') : t('无需补全', 'none needed')) : t(r.error, r.errorEn || 'Lookup failed. Check the DOI or retry.')), r.conflicts?.length > 0 && h('p', { className: 'warn' }, t('有 ', 'Differences: ') + r.conflicts.length + t(' 项差异;请查看文献详情。', '; see entry details.')))))),
63
- h('div', { className: 'card' }, h('h3', null, t('3. 引文与下载', '3. References and downloads')), h('label', { htmlFor: 'cite-style' }, t('引文格式(基本格式)', 'Citation style (basic)')), h('select', { id: 'cite-style', value: style, onChange: e => setStyle(e.target.value) }, ['gb-t-7714','apa','mla','chicago'].map((value, i) => h('option', { value, key: value }, ['GB/T 7714','APA','MLA','Chicago'][i]))), citation && h('pre', { 'aria-label': t('引文预览', 'Reference preview') }, citation), h('p', { className: 'muted' }, t('沿用原插件的四种基本格式;提交论文前请核对学校或期刊要求。上方可预览单条引文。', 'Uses the existing four basic styles. Check your institution or journal requirements before submission. Use an entry above to preview its reference.')),
64
- h('p', null, selected.length ? t('下载范围:所选 ', 'Download scope: selected ') + selected.length : t('下载范围:全部文献', 'Download scope: entire library')), h('div', { className: 'bar' }, downloadLink('bibtex', '下载 BibTeX', 'Download BibTeX'), downloadLink('references', '下载参考文献文本', 'Download reference text'), downloadLink('backup', '完整备份(含原始来源)', 'Full backup (with original sources)')),
65
- h('p', { className: 'muted' }, t('完整备份始终包含全库及原始输入,不受选择范围影响。导出 BibTeX 使用互不重复的引用键;保留的原始来源用于核对未映射字段。', 'Full backup always includes the entire library and original input. BibTeX exports use unique citation keys. Original sources preserve fields that are not mapped into this workbench.'))))));
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));
66
96
  }
67
- function apply(ctx) { let locale; try { locale = ctx.get?.('locale') || ctx.locale; } catch {} ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'dsh-cite', order: 64, label: () => 'Cite', inject: () => ({ locale }) }, Workbench)); }
68
- return { inject: ['slots', 'locale'], apply };
69
- } });
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.
155
+ window.__ModuleLoader__.load({ id: 'dsh-cite', factory: require => {
156
+ const React = require('react'), { useState, useEffect, useRef } = React, h = React.createElement, route = 'api/dsh-cite/library';
157
+ const W = createWorkbenchUI(React);
158
+ function CiteLibrary({ library, filtered, visible, selected, setSelected, search, setSearch, limit, setLimit, activeId, setActiveId, citation, style, setStyle, t, fieldName, warning, locked, refresh, perform, enrich, jobProgress, onImport }) {
159
+ const [opened, setOpened] = useState(null);
160
+ const active = filtered.find(entry => entry.id === activeId) || filtered[0];
161
+ const selectedEntries = library.entries.filter(entry => selected.includes(entry.id));
162
+ function choose(id, checked) { setSelected(checked ? [...selected, id] : selected.filter(value => value !== id)); }
163
+ if (!library.entries.length) return h(W.Empty, { title: t('文献库为空', 'Your library is empty'), icon: 'book', action: h(W.Button, { variant: 'primary', onClick: onImport }, t('导入文献', 'Import references')) }, t('粘贴 DOI 或 BibTeX,先本地预览,再保存需要的条目。', 'Paste DOI or BibTeX input, preview locally, then save the entries you need.'));
164
+ return h(W.Split, { listWidth: 390, selected: opened && filtered.some(entry => entry.id === opened) ? opened : null, onBack: () => setOpened(null), backLabel: t('返回文献列表', 'Back to references'),
165
+ list: h('div', null,
166
+ h('div', { className: 'w-master-toolbar' }, h(W.Search, { label: t('搜索已保存文献', 'Search saved references'), value: search, onChange: event => { setSearch(event.target.value); setLimit(50); setOpened(null); } }), h(W.IconButton, { icon: 'refresh', label: t('刷新文献库', 'Refresh library'), disabled: locked, onClick: () => perform(refresh) })),
167
+ h('div', { className: 'cite-selection' }, h('span', { className: 'w-muted' }, library.entries.length + t(' 条文献', ' references') + ' · ' + t('已选 ', 'Selected ') + selected.length),
168
+ h('div', { className: 'w-toolbar' }, h(W.Button, { variant: 'ghost', disabled: locked || !visible.length, onClick: () => setSelected(visible.slice(0, 100).map(entry => entry.id)) }, t('选择当前可见项', 'Select visible')), h(W.Button, { variant: 'ghost', disabled: locked || !selected.length, onClick: () => setSelected([]) }, t('清空选择', 'Clear selection')))),
169
+ visible.map(entry => h(W.Row, { key: entry.id, title: entry.work.title || entry.work.doi || t('无标题', 'Untitled'), active: entry.id === active?.id, meta: [authors(entry.work), entry.work.year || ''].filter(Boolean).join(' · '), description: entry.work.doi || entry.work.containerTitle || undefined, onClick: () => { setActiveId(entry.id); setOpened(entry.id); } }, h('input', { type: 'checkbox', checked: selected.includes(entry.id), disabled: locked || selected.length >= 100 && !selected.includes(entry.id), 'aria-label': t('选择文献 ', 'Select reference ') + (entry.work.title || entry.work.doi || entry.id), onChange: event => choose(entry.id, event.target.checked) }))),
170
+ !filtered.length ? h(W.Empty, { title: t('没有匹配文献', 'No matching references'), action: h(W.Button, { onClick: () => setSearch('') }, t('清空搜索', 'Clear search')) }) : null,
171
+ visible.length < filtered.length ? h(W.Button, { className: 'cite-more', onClick: () => setLimit(limit + 50) }, t('再显示 50 条', 'Show 50 more')) : null,
172
+ selected.length ? h('section', { className: 'cite-selected-actions' }, h(W.Button, { disabled: locked || !selectedEntries.some(entry => entry.work.doi), onClick: () => enrich(selected) }, t('从 Crossref 在线补全所选项', 'Enrich selected from Crossref')), h('p', { className: 'w-muted' }, t('最多 100 条,仅补空字段;联网查询 DOI,有差异时保留本地值。', 'Up to 100 entries. Queries DOI metadata online, fills empty fields and preserves local values on conflicts.'))) : null),
173
+ detail: active ? h('article', { className: 'cite-detail' },
174
+ h('h2', { className: 'w-detail-title' }, active.work.title || active.work.doi || t('无标题', 'Untitled')),
175
+ h('p', { className: 'w-detail-meta' }, t('元数据来自已保存来源', 'Metadata from saved sources')),
176
+ h('dl', { className: 'w-definition' },
177
+ h('dt', null, t('作者', 'Authors')), h('dd', null, authors(active.work) || t('未提供', 'Not provided')),
178
+ h('dt', null, t('年份', 'Year')), h('dd', null, active.work.year || t('未提供', 'Not provided')),
179
+ h('dt', null, t('期刊 / 文集', 'Journal / collection')), h('dd', null, active.work.containerTitle || t('未提供', 'Not provided')),
180
+ h('dt', null, 'DOI'), h('dd', null, active.work.doi || t('未提供', 'Not provided')),
181
+ h('dt', null, t('来源', 'Sources')), h('dd', null, active.sourceIds.map(id => library.sources.find(source => source.id === id)?.label || id).join(', '))),
182
+ active.warnings.length ? h('p', { className: 'cite-warnings' }, active.warnings.map(warning).join(' · ')) : null,
183
+ h('section', { className: 'w-section' }, h('h3', null, t('补全元数据', 'Enrich metadata')), h('div', { className: 'w-toolbar' }, h(W.Button, { disabled: locked || !active.work.doi, onClick: () => enrich([active.id]) }, t('从 Crossref 在线补全', 'Enrich from Crossref'))), h('p', { className: 'w-muted' }, active.work.doi ? t('点击后才会联网。只补空字段,有差异时保留本地值。', 'Contacts Crossref only on request. Fills empty fields and preserves local values on conflicts.') : t('当前文献未提供 DOI,无法在线补全。', 'This reference has no DOI and cannot be enriched online.'))),
184
+ h('section', { className: 'w-section' }, h('h3', null, t('引文预览', 'Reference preview')), h('label', { className: 'cite-style' }, t('引用格式', 'Citation style'), h('select', { value: style, onChange: event => setStyle(event.target.value) }, ['gb-t-7714', 'apa', 'mla', 'chicago'].map((value, index) => h('option', { key: value, value }, ['GB/T 7714', 'APA', 'MLA', 'Chicago'][index])))), citation ? h('pre', { 'aria-label': t('引文预览', 'Reference preview') }, citation) : h('p', { className: 'w-muted' }, t('正在生成引文…', 'Formatting reference…'))),
185
+ h('details', { className: 'w-section' }, h('summary', null, t('来源与差异', 'Sources and differences')),
186
+ h('dl', { className: 'w-definition' }, ['publisher', 'volume', 'issue', 'page', 'url', 'isbn'].filter(key => active.work[key]).flatMap(key => [h('dt', { key: key + '-label' }, fieldName(key)), h('dd', { key }, active.work[key])])),
187
+ active.crossref ? h('p', { className: 'w-muted' }, 'Crossref · ' + new Date(active.crossref.fetchedAt).toLocaleString()) : null,
188
+ active.crossref?.conflicts?.length ? h(W.Status, null, h('strong', null, t('有差异,已保留本地值', 'Differences found; local values retained')), active.crossref.conflicts.map(conflict => h('div', { key: conflict.field }, h('strong', null, fieldName(conflict.field)), h('p', null, t('本地:', 'Local: ') + valueText(conflict.local)), h('p', null, 'Crossref: ' + valueText(conflict.crossref))))) : null),
189
+ jobProgress,
190
+ h('p', { className: 'w-muted cite-footer-note' }, t('沿用四种基本引文格式;提交前请核对学校或期刊要求。', 'Uses four basic citation styles. Check institution or journal requirements before submission.'))) : h(W.Empty, { title: t('选择一条文献', 'Select a reference'), icon: 'book' }) });
191
+ }
192
+
193
+ const CITE_WORKBENCH_CSS = `
194
+ .cite-workbench.dcw{--fg:var(--w-text);--fg-muted:var(--w-muted);--border:var(--w-border);--bg:var(--w-bg);--bg-surface:var(--w-bg);--bg-muted:var(--w-soft);padding:0}.cite-workbench.dcw[data-main=true]{padding:calc(24px + var(--dsh-frame-top-clearance,0px)) 28px 0 max(28px,var(--dsh-frame-leading-clearance,0px))}.cite-workbench .cite-selection{padding:0 16px 16px}.cite-workbench .cite-selection .w-button{font-size:12px;min-height:30px;padding:5px 6px}.cite-workbench .cite-selected-actions{padding:18px 16px;border-top:1px solid var(--w-border)}.cite-workbench .cite-selected-actions p{margin-top:10px}.cite-workbench .cite-detail h2{font-size:24px;line-height:1.35}.cite-workbench .cite-detail .w-definition{grid-template-columns:100px minmax(0,1fr);margin:24px 0}.cite-workbench .cite-detail dt{color:var(--w-muted);font-weight:400}.cite-workbench .cite-detail .w-section h3{margin-bottom:14px}.cite-workbench .cite-style{display:flex;align-items:center;gap:14px;margin:14px 0;flex-wrap:wrap}.cite-workbench .cite-style select{width:auto;min-width:180px}.cite-workbench .cite-detail pre{background:var(--w-soft);font:13px/1.7 ui-monospace,Consolas,monospace;border-radius:8px;padding:16px;max-height:none}.cite-workbench .cite-detail .w-section>.w-muted{margin-top:12px}.cite-workbench .cite-warnings{font-size:12px;color:var(--dsw-alias-state-warn-primary,#946400)}.cite-workbench .cite-footer-note{margin:24px 0}.cite-workbench .cite-form-view{max-width:800px;width:100%;padding:24px 0}.cite-workbench .card{border:0;border-bottom:1px solid var(--w-border);border-radius:0;padding:20px 0;margin:0;background:transparent}.cite-workbench.dcw button.primary{background:var(--w-primary);color:var(--w-on-primary);border-color:var(--w-primary)}.cite-workbench .notice{background:var(--w-soft);color:var(--w-text);border:1px solid var(--w-border)}.cite-workbench .notice.error{color:var(--dsw-alias-state-error-primary,#ad3636)}.cite-workbench input[type=checkbox]{accent-color:var(--w-accent);width:16px;height:16px}.cite-workbench :focus-visible{outline:2px solid var(--w-accent);outline-offset:3px}.cite-workbench .cite-more{margin:16px}.cite-workbench .cite-form-view .bar button,.cite-workbench .cite-form-view .button{font-size:14px;min-height:36px}.cite-workbench .cite-form-view h3{font-size:18px}.cite-workbench .cite-detail .bar{gap:8px}.cite-workbench.dcw .w-row-button{border:0;border-radius:0;padding:18px 16px;background:transparent}.cite-workbench .cite-detail .w-status{display:block}
195
+ @container dsh-workbench (max-width:760px){.cite-workbench .cite-selection{padding-inline:0}.cite-workbench .cite-selected-actions{padding-inline:0}.cite-workbench .cite-detail .w-definition{grid-template-columns:80px minmax(0,1fr)}}
196
+ @media(max-width:700px){.cite-workbench.dcw[data-main=true]{padding:calc(16px + var(--dsh-frame-top-clearance,0px)) 16px 0 max(16px,var(--dsh-frame-leading-clearance,0px))}}
197
+ `;
198
+
199
+ let activeLang = 'zh', cache = { kind: 'doi', text: '', label: '', preview: null, chosen: [], selected: [], style: 'gb-t-7714', dirty: false, job: null };
200
+ const css = `.dcw{font:14px/1.65 system-ui,sans-serif;color:var(--fg,#203b42);min-width:0;padding:8px}.dcw *{box-sizing:border-box}.dcw h2{font-size:23px;margin:0}.dcw h3{font-size:17px;margin:0 0 10px}.dcw p{margin:8px 0}.dcw .muted{color:var(--fg-muted,#677881);font-size:13px}.dcw .bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:12px 0}.dcw button,.dcw .button{font:inherit;color:inherit;border:1px solid var(--border,#c5d5d4);border-radius:8px;padding:8px 12px;background:var(--bg-surface,#fff);text-decoration:none;cursor:pointer;white-space:normal}.dcw button.primary{background:#27695b;border-color:#27695b;color:white}.dcw button:disabled{opacity:.45;cursor:default}.dcw label{display:block;margin:10px 0 4px}.dcw input:not([type=checkbox]),.dcw textarea,.dcw select{font:inherit;color:inherit;width:100%;border:1px solid var(--border,#bfd1d0);border-radius:8px;background:var(--bg,#fff);padding:9px}.dcw input[type=checkbox]{accent-color:#27695b;width:18px;height:18px;flex-shrink:0}.dcw textarea{resize:vertical;min-height:140px}.dcw .grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.dcw .card{border:1px solid var(--border,#c5d5d4);border-radius:12px;padding:14px;margin:12px 0;min-width:0;background:var(--bg-surface,#fff)}.dcw .notice{border-radius:8px;padding:10px;background:#eef5ed;color:#2a604a;overflow-wrap:anywhere}.dcw .error{background:#fff0ea;color:#943b27}.dcw .entry{padding:12px 0;border-top:1px solid var(--border,#d3dedd);overflow-wrap:anywhere}.dcw .entry:first-child{border-top:0}.dcw .choose{display:flex;align-items:flex-start;gap:10px;margin:0;cursor:pointer}.dcw .choose input{margin-top:4px}.dcw .choose>span{min-width:0}.dcw .tag{display:inline-block;margin:4px 5px 0 0;padding:2px 6px;border-radius:5px;font-size:12px;background:var(--bg-muted,#edf2f1)}.dcw .warn{color:#a65318}.dcw pre{font:13px/1.6 system-ui,sans-serif;white-space:pre-wrap;overflow-wrap:anywhere;max-height:220px;overflow:auto;margin:8px 0}.dcw details{margin-top:10px}.dcw summary{cursor:pointer}.dcw dl{display:grid;grid-template-columns:auto 1fr;gap:5px 14px}.dcw dt{font-weight:600}.dcw dd{margin:0;overflow-wrap:anywhere}.dcw progress{width:100%;height:12px}.dcw :focus-visible{outline:3px solid #79cdb4;outline-offset:2px}[role=dialog]:has(.dcw){width:min(1200px,calc(100vw - 40px))!important;max-width:1200px!important}@media(max-width:800px){.dcw{padding:3px}.dcw .grid{grid-template-columns:1fr}[role=dialog]:has(.dcw){width:100%!important;max-width:100%!important;flex-direction:column!important}[role=dialog]:has(.dcw)>nav+div{min-width:0;width:100%}[role=dialog]:has(.dcw)>nav+div>div:has(.dcw){padding:12px!important;min-width:0}[role=dialog]:has(.dcw) nav{width:100%!important;flex-basis:auto!important;border-right:0;max-height:170px;overflow:auto}[role=dialog]:has(.dcw) nav>div:has(>button){display:flex;flex-direction:row!important;flex-wrap:wrap;gap:4px}}`;
201
+ async function api(body) {
202
+ const r = await fetch(new URL(route, document.baseURI), { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json', 'x-dsh-cite': '1' }, body: JSON.stringify(body) });
203
+ let data;
204
+ try {
205
+ data = await r.json();
206
+ }
207
+ catch {
208
+ throw new Error(activeLang === 'en' ? 'Could not read the library response. Check whether DSH restarted, then retry.' : '无法读取文献库响应,请检查 DSH 是否已重启。');
209
+ }
210
+ if (!r.ok || !data.ok) {
211
+ const e = new Error(activeLang === 'en' ? data.messageEn || 'The operation could not be completed. Check the input or refresh and retry.' : data.message || '操作未完成');
212
+ e.status = r.status;
213
+ throw e;
214
+ }
215
+ return data.value;
216
+ }
217
+ function download(kind, style, lang, ids) { const u = new URL(route, document.baseURI); u.searchParams.set('kind', kind); if (style)
218
+ u.searchParams.set('style', style); if (lang)
219
+ u.searchParams.set('lang', lang); if (ids?.length)
220
+ u.searchParams.set('ids', ids.join(',')); return u.href; }
221
+ const authors = work => work.authors.map(a => a.name || [a.family, a.given].filter(Boolean).join(' ')).join(', ');
222
+ const valueText = v => Array.isArray(v) ? v.map(a => typeof a === 'object' ? a.name || [a.family, a.given].filter(Boolean).join(' ') : String(a)).join(', ') : String(v ?? '');
223
+ function Workbench({locale,workbench=false}={}) {
224
+ const [activeTab,setActiveTab]=useState('library'),[citationError,setCitationError]=useState(''); const controlId=React.useId();
225
+ const [lang, setLang] = useState('zh'), [kind, setKind] = useState(cache.kind), [text, setText] = useState(cache.text), [label, setLabel] = useState(cache.label);
226
+ const [preview, setPreview] = useState(cache.preview), [chosen, setChosen] = useState(cache.chosen), [selected, setSelected] = useState(cache.selected), [style, setStyle] = useState(cache.style), [dirty, setDirty] = useState(cache.dirty);
227
+ const [library, setLibrary] = useState({ revision: 0, entries: [], sources: [] }), [job, setJob] = useState(cache.job), [busy, setBusy] = useState(false), [error, setError] = useState(''), [notice, setNotice] = useState(''), [conflict, setConflict] = useState(false), [search, setSearch] = useState(''), [limit, setLimit] = useState(50), [activeId, setActiveId] = useState(''), [citation, setCitation] = useState('');
228
+ const busyRef = useRef(false), fileRef = useRef(null), dirtyRef = useRef(dirty);
229
+ dirtyRef.current = dirty;
230
+ const t = (zh, en) => lang === 'en' ? en : zh, running = job?.state === 'running', locked = busy || running;
231
+ const warning = code => ({ missing_title: t('缺少标题', 'Missing title'), missing_authors: t('缺少作者', 'Missing authors'), missing_year: t('缺少年份', 'Missing year'), missing_doi: t('没有 DOI,无法在线补全', 'No DOI for online lookup'), long_citation_key: t('引用键过长,导出将使用短键', 'Long citation key; export uses a shorter key') }[code] || code);
232
+ const fieldName = key => ({ title: t('标题', 'Title'), authors: t('作者', 'Authors'), year: t('年份', 'Year'), containerTitle: t('期刊 / 文集', 'Journal / collection'), publisher: t('出版社', 'Publisher'), type: t('文献类型', 'Type'), volume: t('卷', 'Volume'), issue: t('期', 'Issue'), page: t('页码', 'Pages'), language: t('语言', 'Language'), url: 'URL', isbn: 'ISBN' }[key] || key);
233
+ useEffect(() => { const update = () => { activeLang = String(locale?.getSnapshot?.().active || '').startsWith('en') ? 'en' : 'zh'; setLang(activeLang); }; update(); return locale?.subscribe?.(update); }, [locale]);
234
+ useEffect(() => { cache = { kind, text, label, preview, chosen, selected, style, dirty, job }; }, [kind, text, label, preview, chosen, selected, style, dirty, job]);
235
+ useEffect(() => { const warn = e => { if (dirtyRef.current) {
236
+ e.preventDefault();
237
+ e.returnValue = '';
238
+ } }; window.addEventListener('beforeunload', warn); return () => window.removeEventListener('beforeunload', warn); }, []);
239
+ async function refresh() { const state = await api({ action: 'list' }); setLibrary(state); setSelected(old => old.filter(id => state.entries.some(e => e.id === id))); setJob(old => state.jobs.find(j => j.id === old?.id) || state.jobs.find(j => j.state === 'running') || state.jobs.at(-1) || null); }
240
+ useEffect(() => { refresh().catch(e => setError(e.message)); }, []);
241
+ useEffect(() => {
242
+ if (!running)
243
+ return;
244
+ let live = true, polling = false;
245
+ const timer = setInterval(async () => { if (polling)
246
+ return; polling = true; try {
247
+ const next = await api({ action: 'job', job: job.id });
248
+ if (!live)
249
+ return;
250
+ if (next.state !== 'running') {
251
+ await refresh();
252
+ if (live)
253
+ setJob(next);
254
+ }
255
+ else
256
+ setJob(next);
257
+ }
258
+ catch (e) {
259
+ if (live) {
260
+ setError(e.message);
261
+ setJob(null);
262
+ }
263
+ }
264
+ finally {
265
+ polling = false;
266
+ } }, 1000);
267
+ return () => { live = false; clearInterval(timer); };
268
+ }, [job?.id, running]);
269
+ const referenceEntries=library.entries.filter(entry=>[entry.work.title,authors(entry.work),entry.work.doi,entry.work.year].join(' ').toLowerCase().includes(search.toLowerCase()));
270
+ const referenceId=activeId && referenceEntries.some(entry=>entry.id===activeId)?activeId:referenceEntries[0]?.id;
271
+ useEffect(() => { let live = true; setCitation(''); setCitationError(''); if (referenceId)
272
+ api({ action: 'export', kind: 'references', ids: [referenceId], style, lang }).then(v => { if (live)
273
+ setCitation(v.content); }).catch(e => { if (live)
274
+ setCitationError(e.message); }); return () => { live = false; }; }, [referenceId, style, lang, library.revision]);
275
+ async function perform(fn) { if (busyRef.current)
276
+ return; busyRef.current = true; setBusy(true); setError(''); setNotice(''); try {
277
+ await fn();
278
+ }
279
+ catch (e) {
280
+ setError(e.message);
281
+ if (e.status === 409)
282
+ setConflict(true);
283
+ }
284
+ finally {
285
+ busyRef.current = false;
286
+ setBusy(false);
287
+ } }
288
+ function changeInput(set, value) { set(value); setDirty(true); setPreview(null); setChosen([]); setConflict(false); }
289
+ async function parse() { const p = await api({ action: 'preview', kind, text, label }); setPreview(p); setChosen(p.rows.filter(r => r.selectedDefault).map(r => r.index)); setConflict(false); }
290
+ async function save() { const oldIds = new Set(library.entries.map(e => e.id)), state = await api({ action: 'import', preview: preview.id, selection: chosen }); setLibrary(state); const imported=state.entries.filter(e=>!oldIds.has(e.id)).map(e=>e.id);setSelected(imported);setActiveId(imported[0]||'');setSearch('');setLimit(50); setPreview(null); setChosen([]); setDirty(false); setConflict(false); setActiveTab('library'); setNotice(t('已保存 ' + chosen.length + ' 条。原始输入也已保留在完整备份中。', 'Saved entries: ' + chosen.length + '. Original input is preserved in the full backup.')); }
291
+ async function upload() { const file = fileRef.current?.files?.[0]; if (!file)
292
+ return; await perform(async () => { if (!/\.bib$/i.test(file.name))
293
+ throw new Error(t('请选择 .bib 文件。', 'Choose a .bib file.')); if (file.size > 256 * 1024)
294
+ throw new Error(t('文件超过 256 KiB,请拆分导入。', 'File exceeds 256 KiB. Split it before importing.')); const content = await file.text(); setKind('bibtex'); setText(content); setLabel(file.name); setPreview(null); setChosen([]); setDirty(true); setConflict(false); setNotice(t('文件已读取,点击“本地预览”检查后再保存。', 'File loaded. Choose Local preview before saving.')); }); fileRef.current.value = ''; }
295
+ function toggle(values, value, set) { set(values.includes(value) ? values.filter(v => v !== value) : [...values, value]); }
296
+ const filtered = library.entries.filter(e => [e.work.title, authors(e.work), e.work.doi, e.work.year].join(' ').toLowerCase().includes(search.toLowerCase())), visible = filtered.slice(0, limit), selectedIds = selected.length ? selected : undefined;
297
+ const downloadLink = (format, zh, en) => library.entries.length ? h('a', { className: 'button', href: download(format, style, lang, format === 'backup' ? undefined : selectedIds), download: '' }, t(zh, en)) : h('button', { disabled: true }, t(zh, en));
298
+ return h(W.Page,{workbench,title:t('引文库','Reference library'),subtitle:t('整理文献、核对来源并导出引文。','Organize references, inspect sources and export citations.'),className:'dcw cite-workbench',actions:h(React.Fragment,null,
299
+ h(W.Button,{variant:'primary',icon:'plus',onClick:()=>setActiveTab('import')},t('导入文献','Import references')),h(W.Button,{icon:'download',onClick:()=>setActiveTab('export')},t('下载与备份','Downloads and backup')))},
300
+ h('style',null,css),h('style',null,CITE_WORKBENCH_CSS),h(W.Status,{tone:'error'},error),h(W.Status,{tone:'error'},citationError),h(W.Status,{tone:'success'},notice),conflict && h('div', { className: 'notice error' }, h('p', null, t('库已在别处更新。当前输入仍保留,请重新预览再保存。', 'The library changed elsewhere. Your input is retained; preview again before saving.')), h('button', { disabled: locked || !text.trim(), onClick: () => perform(async () => { await refresh(); await parse(); }) }, t('重新预览当前输入', 'Preview current input again'))),
301
+ h(W.Tabs,{label:t('引文工作台视图','Reference workbench views'),value:activeTab,onChange:setActiveTab,items:[{id:'library',label:t('文献库','Library')},{id:'import',label:t('导入与预览','Import and preview')},{id:'export',label:t('引文与下载','Citations and downloads')}]},
302
+ activeTab === 'import' ? h('div',{className:'cite-form-view'},h('div', null, h('div', { className: 'card' }, h('h3', null, t('导入与本地预览', 'Import and local preview')), h('label', { htmlFor: controlId+'-kind' }, t('输入类型', 'Input type')), h('select', { id: controlId+'-kind', value: kind, disabled: locked, onChange: e => changeInput(setKind, e.target.value) }, h('option', { value: 'doi' }, t('DOI 列表(每行一条)', 'DOI list (one per line)')), h('option', { value: 'bibtex' }, 'BibTeX / BibLaTeX')), h('label', { htmlFor: controlId+'-input' }, t('DOI / BibTeX 输入', 'DOI / BibTeX input')), h('textarea', { id: controlId+'-input', rows: 8, value: text, disabled: locked, spellCheck: false, onChange: e => changeInput(setText, e.target.value), placeholder: kind === 'doi' ? '10.1038/nature12345\nhttps://doi.org/10.1145/3368089.3409749' : '@article{key,\n title={Your title},\n author={Family, Given},\n year={2026}\n}' }), h('label', { htmlFor: controlId+'-source' }, t('来源名称(可选)', 'Source name (optional)')), h('input', { id: controlId+'-source', value: label, maxLength: 160, disabled: locked, onChange: e => changeInput(setLabel, e.target.value) }), h('input', { ref: fileRef, type: 'file', accept: '.bib', style: { display: 'none' }, onChange: upload }), h('div', { className: 'bar' }, h('button', { disabled: locked, onClick: () => fileRef.current.click() }, t('上传 .bib', 'Upload .bib')), h('button', { className: 'primary', disabled: locked || !text.trim(), onClick: () => perform(parse) }, t('本地预览', 'Local preview'))), h('p', { className: 'muted' }, t('最多 256 KiB / 100 条;库最多 500 条。预览、去重和格式切换都不联网。上传仅读取文件,不改写原文件。', 'Up to 256 KiB / 100 entries per import; 500 in the library. Preview, deduplication and formatting are offline. Upload reads your file without changing it.')), dirty && h('p', { className: 'muted' }, t('当前输入尚未保存到文献库。关闭设置后输入仍保留。', 'Current input has not been saved. It is retained when you close settings.'))), preview && h('div', { className: 'card' }, h('h3', null, t('预览:选择要保存的文献', 'Preview: choose entries to save')), h('p', { className: 'muted' }, preview.label + ' · ' + t('已选 ', 'Selected ') + chosen.length + '/' + preview.rows.length), preview.rows.map(r => h('div', { className: 'entry', key: r.index }, h('label', { className: 'choose' }, h('input', { type: 'checkbox', checked: chosen.includes(r.index), disabled: locked || !!r.error || !!r.duplicate, 'aria-label': t('保存预览条目 ', 'Save preview entry ') + (r.index + 1), onChange: () => toggle(chosen, r.index, setChosen) }), h('span', null, h('strong', null, (r.index + 1) + '. ' + (r.work?.title || r.work?.doi || r.key)), r.work && h('div', { className: 'muted' }, [authors(r.work), r.work.year || '', r.work.doi].filter(Boolean).join(' · ')), r.duplicate && h('span', { className: 'tag warn' }, r.duplicate === 'library' ? t('DOI 已在库中,跳过', 'DOI already saved; skipped') : t('同批 DOI 重复,跳过', 'Repeated DOI in this batch; skipped')), r.candidate && h('span', { className: 'tag warn' }, t('可能重复,请核对后决定', 'Possible duplicate; review before selecting')), r.warnings.map(w => h('span', { key: w, className: 'tag warn' }, warning(w))), r.error && h('p', { className: 'notice error' }, t(r.error, r.errorEn || 'Invalid reference. Check the original input.')))))), h('div', { className: 'bar' }, h('button', { className: 'primary', disabled: locked || conflict || !chosen.length, onClick: () => perform(save) }, t('保存所选 ', 'Save selected ') + chosen.length), h('button', { disabled: locked, onClick: () => { setPreview(null); setChosen([]); } }, t('返回输入', 'Back to input')))))) : activeTab === 'export' ? h('div',{className:'cite-form-view'},h('div', { className: 'card' }, h('h3', null, t('引文与下载', 'References and downloads')), h('label', { htmlFor: controlId+'-style' }, t('引文格式(基本格式)', 'Citation style (basic)')), h('select', { id: controlId+'-style', value: style, onChange: e => setStyle(e.target.value) }, ['gb-t-7714', 'apa', 'mla', 'chicago'].map((value, i) => h('option', { value, key: value }, ['GB/T 7714', 'APA', 'MLA', 'Chicago'][i]))), citation && h('pre', { 'aria-label': t('引文预览', 'Reference preview') }, citation), h('p', { className: 'muted' }, t('沿用原插件的四种基本格式;提交论文前请核对学校或期刊要求。上方可预览单条引文。', 'Uses the existing four basic styles. Check your institution or journal requirements before submission. Use an entry above to preview its reference.')), h('p', null, selected.length ? t('下载范围:所选 ', 'Download scope: selected ') + selected.length : t('下载范围:全部文献', 'Download scope: entire library')), h('div', { className: 'bar' }, downloadLink('bibtex', '下载 BibTeX', 'Download BibTeX'), downloadLink('references', '下载参考文献文本', 'Download reference text'), downloadLink('backup', '完整备份(含原始来源)', 'Full backup (with original sources)')), h('p', { className: 'muted' }, t('完整备份始终包含全库及原始输入,不受选择范围影响。导出 BibTeX 使用互不重复的引用键;保留的原始来源用于核对未映射字段。', 'Full backup always includes the entire library and original input. BibTeX exports use unique citation keys. Original sources preserve fields that are not mapped into this workbench.'))),job && h('div', { className: job.state === 'failed' ? 'card notice error' : 'card', role: job.state === 'failed' ? 'alert' : 'status' }, h('h3', null, t('补全进度', 'Enrichment progress')), h('progress', { max: job.total, value: job.completed }), h('p', null, ({ running: t('查询中', 'Looking up'), done: t('已完成', 'Completed'), failed: t('未保存本次补全', 'Enrichment was not saved'), cancelled: t('已取消,原文献保留', 'Cancelled; saved entries preserved') }[job.state]) + ' · ' + job.completed + '/' + job.total), job.state === 'done' && h('p', null, t('已检查并保存 ', 'Entries checked and saved: ') + job.saved + t(' 条;失败 ', '; failed: ') + job.results.filter(r => !r.ok).length), job.message && h('p', { className: 'notice error' }, t(job.message, job.messageEn || 'The job could not be completed; saved entries are preserved.')), running && h('button', { disabled: busy, onClick: () => perform(async () => { await api({ action: 'cancel', job: job.id }); }) }, t('取消本次补全', 'Cancel enrichment')), h('details', null, h('summary', null, t('查看每条结果', 'Show individual results')), job.results.map(r => h('div', { className: 'entry', key: r.id }, h('strong', null, r.doi || t('没有 DOI', 'No DOI')), h('p', null, r.ok ? t('补全字段:', 'Fields filled: ') + (r.filled.length ? r.filled.map(fieldName).join(', ') : t('无需补全', 'none needed')) : t(r.error, r.errorEn || 'Lookup failed. Check the DOI or retry.')), r.conflicts?.length > 0 && h('p', { className: 'warn' }, t('有 ', 'Differences: ') + r.conflicts.length + t(' 项差异;请查看文献详情。', '; see entry details.'))))))) :
303
+ h(CiteLibrary,{library,filtered,visible,selected,setSelected,search,setSearch,limit,setLimit,activeId,setActiveId,citation,style,setStyle,t,fieldName,warning,locked,refresh,perform,enrich:ids=>perform(async()=>setJob(await api({action:'enrich',ids,revision:library.revision}))),jobProgress:job && h('div', { className: job.state === 'failed' ? 'card notice error' : 'card', role: job.state === 'failed' ? 'alert' : 'status' }, h('h3', null, t('补全进度', 'Enrichment progress')), h('progress', { max: job.total, value: job.completed }), h('p', null, ({ running: t('查询中', 'Looking up'), done: t('已完成', 'Completed'), failed: t('未保存本次补全', 'Enrichment was not saved'), cancelled: t('已取消,原文献保留', 'Cancelled; saved entries preserved') }[job.state]) + ' · ' + job.completed + '/' + job.total), job.state === 'done' && h('p', null, t('已检查并保存 ', 'Entries checked and saved: ') + job.saved + t(' 条;失败 ', '; failed: ') + job.results.filter(r => !r.ok).length), job.message && h('p', { className: 'notice error' }, t(job.message, job.messageEn || 'The job could not be completed; saved entries are preserved.')), running && h('button', { disabled: busy, onClick: () => perform(async () => { await api({ action: 'cancel', job: job.id }); }) }, t('取消本次补全', 'Cancel enrichment')), h('details', null, h('summary', null, t('查看每条结果', 'Show individual results')), job.results.map(r => h('div', { className: 'entry', key: r.id }, h('strong', null, r.doi || t('没有 DOI', 'No DOI')), h('p', null, r.ok ? t('补全字段:', 'Fields filled: ') + (r.filled.length ? r.filled.map(fieldName).join(', ') : t('无需补全', 'none needed')) : t(r.error, r.errorEn || 'Lookup failed. Check the DOI or retry.')), r.conflicts?.length > 0 && h('p', { className: 'warn' }, t('有 ', 'Differences: ') + r.conflicts.length + t(' 项差异;请查看文献详情。', '; see entry details.')))))),onImport:()=>setActiveTab('import')})));
304
+ }
305
+ function apply(ctx) { let locale; try {
306
+ locale = ctx.get?.('locale') || ctx.locale;
307
+ }
308
+ catch { } const label=()=>String(locale?.getSnapshot?.().active || activeLang).startsWith('en')?'Reference library':'引文库';
309
+ W.register(ctx,{id:'dsh-cite.workbench',label,icon:'book',component:Workbench,inject:()=>({locale}),order:64});
310
+ ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'dsh-cite', order: 64, label, inject: () => ({ locale }) }, Workbench)); }
311
+ return { inject: ['slots', 'locale'], apply };
312
+ } });
313
+
314
+ })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cite",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "批量导入 DOI / BibTeX,预览去重、补全文献元数据并导出引文。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -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
  },