dsh-cite 0.3.5 → 0.4.1

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/lib/client.js ADDED
@@ -0,0 +1,69 @@
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;
9
+ }
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)); }, []);
26
+ 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.'))))));
66
+ }
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
+ } });
package/lib/crossref.js CHANGED
@@ -80,7 +80,8 @@ export async function lookupDoi(doi, cfg, fetchImpl, signal) {
80
80
  }
81
81
  catch (error) {
82
82
  signal?.throwIfAborted();
83
- throw new Error('Crossref 请求失败:' + (error instanceof Error ? error.message : String(error)));
83
+ const code = error instanceof Error && typeof error.cause?.code === 'string' && /^[A-Z0-9_]+$/.test(error.cause.code) ? ' (' + error.cause.code + ')' : '';
84
+ throw new Error('Crossref 请求失败:' + (error instanceof Error ? error.message : String(error)) + code);
84
85
  }
85
86
  if (!response.ok) {
86
87
  if (response.status === 404)
package/lib/format.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 参考文献格式化:GB/T 7714-2015 / APA 7 / MLA 9 / Chicago note,以及 BibTeX。
2
+ * 基本参考文献格式:GB/T 7714 / APA / MLA / Chicago note,以及 BibTeX。
3
3
  *
4
4
  * @module dsh-cite/format
5
5
  */
package/lib/format.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 参考文献格式化:GB/T 7714-2015 / APA 7 / MLA 9 / Chicago note,以及 BibTeX。
2
+ * 基本参考文献格式:GB/T 7714 / APA / MLA / Chicago note,以及 BibTeX。
3
3
  *
4
4
  * @module dsh-cite/format
5
5
  */
@@ -25,24 +25,27 @@ function initials(given) {
25
25
  .join(' ');
26
26
  }
27
27
  function apaName(author) {
28
+ if (author.name !== '')
29
+ return author.name;
28
30
  const family = author.family !== '' ? author.family : (author.name !== '' ? author.name : author.given);
29
31
  const initialsText = author.given !== '' ? initials(author.given) : '';
30
32
  return initialsText !== '' ? family + ', ' + initialsText : family;
31
33
  }
32
34
  function mlaName(author, first) {
33
- const name = fullName(author);
34
- const parts = name.trim().split(/\s+/);
35
- if (parts.length < 2)
36
- return name;
37
- if (first)
38
- return parts[0] + ', ' + parts.slice(1).join(' ');
39
- return parts.slice(1).join(' ') + ' ' + parts[0];
35
+ // Literal names (including institutions) are not personal names to invert.
36
+ if (author.name !== '')
37
+ return author.name;
38
+ if (author.family !== '' && author.given !== '')
39
+ return first
40
+ ? author.family + ', ' + author.given
41
+ : author.given + ' ' + author.family;
42
+ return author.family || author.given;
40
43
  }
41
- function gbtAuthors(authors) {
44
+ function gbtAuthors(authors, lang) {
42
45
  if (authors.length === 0)
43
- return '佚名';
46
+ return lang === 'en' ? 'Anonymous' : '佚名';
44
47
  const names = authors.map((author) => fullName(author));
45
- return names.length <= 3 ? names.join(', ') : names.slice(0, 3).join(', ') + ', 等';
48
+ return names.length <= 3 ? names.join(', ') : names.slice(0, 3).join(', ') + (lang === 'en' ? ', et al.' : ', 等');
46
49
  }
47
50
  function apaAuthors(authors) {
48
51
  if (authors.length === 0)
@@ -68,9 +71,10 @@ function mlaAuthors(authors) {
68
71
  function chicagoAuthors(authors) {
69
72
  if (authors.length === 0)
70
73
  return 'Anonymous';
74
+ const noteName = (author) => author.name || (author.given && author.family ? author.given + ' ' + author.family : fullName(author));
71
75
  if (authors.length <= 3)
72
- return authors.map((author) => fullName(author)).join(', ');
73
- return fullName(authors[0]) + ', et al.';
76
+ return authors.map(noteName).join(', ');
77
+ return noteName(authors[0]) + ', et al.';
74
78
  }
75
79
  function gbtTypeLabel(type, work) {
76
80
  switch (type) {
@@ -107,9 +111,10 @@ function cleanupTitle(title) {
107
111
  function titleOf(work) {
108
112
  return cleanupTitle(work.title !== '' ? work.title : '(无标题)');
109
113
  }
110
- /** 生成 GB/T 7714-2015 参考文献条目。 */
114
+ /** 生成基本 GB/T 7714 参考文献条目。 */
111
115
  function buildGbt(work, lang) {
112
- const authors = gbtAuthors(work.authors);
116
+ const authors = gbtAuthors(work.authors, lang);
117
+ const authorLead = authors + (authors.endsWith('.') ? ' ' : '. ');
113
118
  const title = titleOf(work);
114
119
  const label = gbtTypeLabel(work.type, work);
115
120
  if (work.type === 'journal-article' && work.containerTitle !== '') {
@@ -117,23 +122,23 @@ function buildGbt(work, lang) {
117
122
  const vip = volumeIssuePages(work);
118
123
  if (vip !== '')
119
124
  tail += ', ' + vip;
120
- return authors + '. ' + title + label + '. ' + tail + '.';
125
+ return authorLead + title + label + '. ' + tail + '.';
121
126
  }
122
127
  if (work.type === 'book' || work.type === 'monograph' || work.type === 'edited-book') {
123
128
  let tail = work.publisher !== '' ? work.publisher + ', ' : '';
124
129
  tail += yearText(work);
125
130
  if (work.isbn !== '')
126
131
  tail += '. ISBN ' + work.isbn;
127
- return authors + '. ' + title + '[M]. ' + tail + '.';
132
+ return authorLead + title + '[M]. ' + tail + '.';
128
133
  }
129
134
  if (work.containerTitle !== '' && work.type === 'proceedings-article') {
130
- return authors + '. ' + title + '[C]//' + work.containerTitle + '. ' + yearText(work) + '.';
135
+ return authorLead + title + '[C]//' + work.containerTitle + '. ' + yearText(work) + '.';
131
136
  }
132
137
  const urlPart = work.url !== '' ? ' ' + work.url + '.' : '';
133
138
  const accessed = new Date().toISOString().slice(0, 10);
134
- return authors + '. ' + title + label + '. (' + yearText(work) + ')[' + (lang === 'zh' ? '引用日期 ' : 'cited ') + accessed + '].' + urlPart;
139
+ return authorLead + title + label + '. (' + yearText(work) + ')[' + (lang === 'zh' ? '引用日期 ' : 'cited ') + accessed + '].' + urlPart;
135
140
  }
136
- /** 生成 APA 7 参考文献条目。 */
141
+ /** 生成基本 APA 参考文献条目。 */
137
142
  function buildApa(work) {
138
143
  const authors = apaAuthors(work.authors);
139
144
  const title = titleOf(work);
@@ -156,11 +161,11 @@ function buildApa(work) {
156
161
  }
157
162
  return authors + ' (' + year + '). ' + title + '.' + (work.url !== '' ? ' ' + work.url : '');
158
163
  }
159
- /** 生成 MLA 9 参考文献条目。 */
164
+ /** 生成基本 MLA 参考文献条目。 */
160
165
  function buildMla(work) {
161
166
  const authors = mlaAuthors(work.authors);
162
167
  const title = titleOf(work);
163
- let text = authors + '. "' + title + '." ';
168
+ let text = authors + (authors.endsWith('.') ? ' "' : '. "') + title + '." ';
164
169
  if (work.containerTitle !== '')
165
170
  text += work.containerTitle + ', ';
166
171
  if (work.volume !== '')
@@ -196,6 +201,8 @@ function buildChicago(work) {
196
201
  }
197
202
  /** 按样式生成引文。 */
198
203
  export function buildCitation(work, style, lang = 'zh') {
204
+ if (!work.title && lang === 'en')
205
+ work = { ...work, title: '(Untitled)' };
199
206
  switch (style) {
200
207
  case 'gb-t-7714': return buildGbt(work, lang);
201
208
  case 'apa': return buildApa(work);
package/lib/index.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * dsh-cite —— 参考文献工具插件(node 半身,配置走 cordis.patch.yml)。
3
3
  *
4
- * 插件导出 apply(ctx, config):注册五个面向模型的工具(cite_lookup / cite_format /
4
+ * 插件导出 apply(ctx, config):注册原有五个面向模型的工具(cite_lookup / cite_format /
5
5
  * cite_bibtex / cite_check / cite_health),通过 Crossref API 查询元数据并生成
6
- * GB/T 7714 / APA / MLA / Chicago 引文。零运行时依赖。
6
+ * GB/T 7714 / APA / MLA / Chicago 基本引文,并提供本地批量文献工作台。
7
7
  *
8
8
  * @module dsh-cite
9
9
  */
@@ -17,9 +17,14 @@ export interface CitePluginContext {
17
17
  register(definition: CiteToolDefinition): () => void;
18
18
  };
19
19
  on?(event: string, listener: () => void): () => void;
20
+ inject?(services: string[], callback: (host: any) => void): unknown;
20
21
  }
21
22
  export declare function apply(ctx: CitePluginContext, config?: CiteConfig | null): void;
22
23
  export * from './config.js';
23
24
  export * from './crossref.js';
24
25
  export * from './format.js';
25
26
  export * from './tools.js';
27
+ export * from './citation-model.js';
28
+ export * from './citation-parser.js';
29
+ export * from './citation-store.js';
30
+ export * from './citation-workbench.js';
package/lib/index.js CHANGED
@@ -1,14 +1,15 @@
1
1
  /**
2
2
  * dsh-cite —— 参考文献工具插件(node 半身,配置走 cordis.patch.yml)。
3
3
  *
4
- * 插件导出 apply(ctx, config):注册五个面向模型的工具(cite_lookup / cite_format /
4
+ * 插件导出 apply(ctx, config):注册原有五个面向模型的工具(cite_lookup / cite_format /
5
5
  * cite_bibtex / cite_check / cite_health),通过 Crossref API 查询元数据并生成
6
- * GB/T 7714 / APA / MLA / Chicago 引文。零运行时依赖。
6
+ * GB/T 7714 / APA / MLA / Chicago 基本引文,并提供本地批量文献工作台。
7
7
  *
8
8
  * @module dsh-cite
9
9
  */
10
10
  import { resolveConfig } from './config.js';
11
11
  import { buildCiteTools } from './tools.js';
12
+ import { CitationWorkbench, buildBatchTool } from './citation-workbench.js';
12
13
  /** cordis 服务注入:apply 里要用 ctx.tools。 */
13
14
  export const name = 'cite';
14
15
  export const inject = ['tools'];
@@ -22,17 +23,26 @@ export function apply(ctx, config) {
22
23
  cfg = resolveConfig(null);
23
24
  }
24
25
  const disposers = [];
25
- for (const definition of buildCiteTools(cfg)) {
26
- disposers.push(ctx.tools.register(definition));
26
+ const workbench = new CitationWorkbench(cfg);
27
+ const dispose = () => { workbench.dispose(); for (const fn of disposers.splice(0).reverse())
28
+ fn?.(); };
29
+ try {
30
+ for (const definition of [...buildCiteTools(cfg), buildBatchTool(workbench)])
31
+ disposers.push(ctx.tools.register(definition));
32
+ ctx.inject?.(['connection'], host => { const remove = host.connection?.fetch?.register({ path: workbench.route, methods: ['GET', 'POST'], requestBody: 'buffered', fetch: (request) => workbench.fetch(request) }); if (typeof remove === 'function')
33
+ disposers.push(remove); });
34
+ ctx.on?.('dispose', dispose);
27
35
  }
28
- if (typeof ctx.on === 'function') {
29
- ctx.on('dispose', () => {
30
- for (const dispose of disposers)
31
- dispose();
32
- });
36
+ catch (e) {
37
+ dispose();
38
+ throw e;
33
39
  }
34
40
  }
35
41
  export * from './config.js';
36
42
  export * from './crossref.js';
37
43
  export * from './format.js';
38
44
  export * from './tools.js';
45
+ export * from './citation-model.js';
46
+ export * from './citation-parser.js';
47
+ export * from './citation-store.js';
48
+ export * from './citation-workbench.js';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-cite",
3
- "version": "0.3.5",
4
- "description": "DSH 参考文献插件:Crossref 元数据查询(DOI 精确解析)与 GB/T 7714 / APA / MLA / Chicago 格式化,含去重校验与自检。",
3
+ "version": "0.4.1",
4
+ "description": "批量导入 DOI / BibTeX,预览去重、补全文献元数据并导出引文。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
@@ -10,6 +10,7 @@
10
10
  "types": "./lib/index.d.ts",
11
11
  "default": "./lib/index.js"
12
12
  },
13
+ "./client": "./lib/client.js",
13
14
  "./cordis.patch.yml": "./cordis.patch.yml",
14
15
  "./package.json": "./package.json"
15
16
  },
@@ -17,15 +18,18 @@
17
18
  "lib",
18
19
  "cordis.patch.yml",
19
20
  "README.md",
20
- "README.en.md"
21
+ "README.en.md",
22
+ "docs",
23
+ "CHANGELOG.md"
21
24
  ],
22
25
  "scripts": {
23
- "build": "tsc -p tsconfig.json",
26
+ "build": "tsc -p tsconfig.json && node scripts/build-client.mjs",
24
27
  "typecheck": "tsc -p tsconfig.json --noEmit",
25
28
  "test": "pnpm run build && node --test \"test/*.test.mjs\"",
26
29
  "prepublishOnly": "pnpm run build"
27
30
  },
28
31
  "dsh": {
32
+ "client": { "inject": ["@deepseek-ai/dsh-client-ui-settings"], "platform": "web" },
29
33
  "bundle": {
30
34
  "patch": "./cordis.patch.yml"
31
35
  }
@@ -48,6 +52,7 @@
48
52
  },
49
53
  "devDependencies": {
50
54
  "@types/node": "^24.0.0",
55
+ "@types/proper-lockfile": "4.1.4",
51
56
  "typescript": "^5.6.0"
52
57
  },
53
58
  "repository": {
@@ -59,5 +64,10 @@
59
64
  },
60
65
  "homepage": "https://github.com/STARDUSTLC666/dsh-cite#readme",
61
66
  "author": "stardustlc",
62
- "packageManager": "pnpm@11.7.0"
67
+ "packageManager": "pnpm@11.7.0",
68
+ "dependencies": {
69
+ "@citation-js/core": "0.9.0",
70
+ "@citation-js/plugin-bibtex": "0.9.0",
71
+ "proper-lockfile": "4.1.2"
72
+ }
63
73
  }