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/CHANGELOG.md +30 -0
- package/README.en.md +30 -26
- package/README.md +24 -58
- package/cordis.patch.yml +1 -1
- package/docs/CHANGELOG.en.md +21 -0
- package/docs/USAGE.en.md +54 -0
- package/docs/USAGE.md +89 -0
- package/docs/VALIDATION.md +20 -0
- package/lib/citation-errors.d.ts +2 -0
- package/lib/citation-errors.js +50 -0
- package/lib/citation-model.d.ts +60 -0
- package/lib/citation-model.js +75 -0
- package/lib/citation-parser.d.ts +2 -0
- package/lib/citation-parser.js +29 -0
- package/lib/citation-store.d.ts +10 -0
- package/lib/citation-store.js +107 -0
- package/lib/citation-workbench.d.ts +105 -0
- package/lib/citation-workbench.js +277 -0
- package/lib/citation-worker.d.ts +1 -0
- package/lib/citation-worker.js +44 -0
- package/lib/client.js +69 -0
- package/lib/crossref.js +2 -1
- package/lib/format.d.ts +1 -1
- package/lib/format.js +29 -22
- package/lib/index.d.ts +7 -2
- package/lib/index.js +19 -9
- package/package.json +15 -5
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
|
-
|
|
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
package/lib/format.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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(
|
|
73
|
-
return
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
132
|
+
return authorLead + title + '[M]. ' + tail + '.';
|
|
128
133
|
}
|
|
129
134
|
if (work.containerTitle !== '' && work.type === 'proceedings-article') {
|
|
130
|
-
return
|
|
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
|
|
139
|
+
return authorLead + title + label + '. (' + yearText(work) + ')[' + (lang === 'zh' ? '引用日期 ' : 'cited ') + accessed + '].' + urlPart;
|
|
135
140
|
}
|
|
136
|
-
/**
|
|
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
|
-
/**
|
|
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)
|
|
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)
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
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
|
}
|