dsh-cite 0.6.0 → 0.6.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 +5 -0
- package/docs/USAGE.en.md +4 -0
- package/docs/USAGE.md +4 -0
- package/docs/VALIDATION.md +4 -0
- package/docs/validation-0.6.1.md +19 -0
- package/lib/citation-model.d.ts +1 -2
- package/lib/citation-model.js +4 -15
- package/lib/citation-text.d.ts +5 -0
- package/lib/citation-text.js +20 -0
- package/lib/citation-worker.js +4 -3
- package/lib/crossref.js +6 -5
- package/lib/format.js +2 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
[历史英文记录](docs/CHANGELOG.en.md)
|
|
6
6
|
|
|
7
|
+
## 0.6.1(2026-10-26)
|
|
8
|
+
|
|
9
|
+
- 文献正文与标识符分开规范化:保留数学比较符、SICI DOI 和 URL 中的特殊字符。只在明确的元数据入口处理已知格式标签,BibTeX 导出可再次导入。
|
|
10
|
+
- Windows 回归 47 项通过、0 项跳过;宿主与未验收范围见[本次记录](docs/validation-0.6.1.md)。
|
|
11
|
+
|
|
7
12
|
## 0.6.0(2026-10-25)
|
|
8
13
|
|
|
9
14
|
- 引文主工作台,文献列表、元数据、来源与引文预览分栏;搜索与导入后的选中状态一致。
|
package/docs/USAGE.en.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
[Overview](../README.en.md) · [Changelog](../CHANGELOG.md) · [Validation](VALIDATION.md)
|
|
4
4
|
|
|
5
|
+
## Identifier fidelity
|
|
6
|
+
|
|
7
|
+
BibTeX import/export retains URLs and DOIs. Mathematical comparisons are not treated as arbitrary HTML. Known Crossref JATS formatting is handled separately from raw identifiers.
|
|
8
|
+
|
|
5
9
|
## Current improvements
|
|
6
10
|
|
|
7
11
|
DOIs with balanced parentheses can be queried directly. Transport or parsing timeouts are explicit, and cancellation stops waiting. Offline library import and external Crossref lookup are verified separately.
|
package/docs/USAGE.md
CHANGED
package/docs/VALIDATION.md
CHANGED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# 0.6.1 验证记录
|
|
2
|
+
|
|
3
|
+
日期:2026-10-26。官方 Windows 宿主:DSH 0.2.1-rc.3。
|
|
4
|
+
|
|
5
|
+
## 行为变化
|
|
6
|
+
|
|
7
|
+
文献正文与标识符分开规范化:保留数学比较符、SICI DOI 和 URL 中的特殊字符。只在明确的元数据入口处理已知格式标签,BibTeX 导出可再次导入。
|
|
8
|
+
|
|
9
|
+
## 验证与限制
|
|
10
|
+
|
|
11
|
+
- Windows 本地:47 项,47 通过、0 跳过;没有把跳过计为通过。
|
|
12
|
+
- 官方 RC.3 Web 实际完成导入预览、保存、文献库下载 BibTeX、重新上传与 DOI 去重;桌面宽度和 390px 窄屏截图检查通过,无页面异常。未调用真实 Crossref 补全服务。
|
|
13
|
+
- GitHub 的跨平台 CI 和 npm 发布完整性以该版本工作流及 registry 为准;本记录不把本地测试等同于已发布。
|
|
14
|
+
|
|
15
|
+
## English
|
|
16
|
+
|
|
17
|
+
Citation text and identifiers are normalized separately. Math comparisons, legacy SICI DOIs and URL punctuation survive import/export; known formatting tags are handled only by metadata adapters.
|
|
18
|
+
|
|
19
|
+
Runtime/account limits are listed above. Synthetic peers and model adapters do not establish live account delivery or model quality.
|
package/lib/citation-model.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Work } from './crossref.js';
|
|
2
|
+
export { plainText } from './citation-text.js';
|
|
2
3
|
export declare const MAX_INPUT_BYTES: number;
|
|
3
4
|
export declare const MAX_BATCH = 100;
|
|
4
5
|
export declare const MAX_LIBRARY = 500;
|
|
@@ -47,8 +48,6 @@ export interface CitationLibrary {
|
|
|
47
48
|
entries: LibraryEntry[];
|
|
48
49
|
sources: SourceRecord[];
|
|
49
50
|
}
|
|
50
|
-
/** Never render provider/BibTeX markup as HTML. Keep the original source separately. */
|
|
51
|
-
export declare function plainText(value: unknown): string;
|
|
52
51
|
export declare function normalizeWork(value: unknown): Work;
|
|
53
52
|
export declare function workWarnings(work: Work): string[];
|
|
54
53
|
export declare function candidateKey(work: Work): string;
|
package/lib/citation-model.js
CHANGED
|
@@ -1,20 +1,9 @@
|
|
|
1
1
|
import { assertDoi } from './crossref.js';
|
|
2
|
+
import { literalText, plainText } from './citation-text.js';
|
|
3
|
+
export { plainText } from './citation-text.js';
|
|
2
4
|
export const MAX_INPUT_BYTES = 256 * 1024;
|
|
3
5
|
export const MAX_BATCH = 100;
|
|
4
6
|
export const MAX_LIBRARY = 500;
|
|
5
|
-
/** Never render provider/BibTeX markup as HTML. Keep the original source separately. */
|
|
6
|
-
export function plainText(value) {
|
|
7
|
-
if (value === undefined || value === null)
|
|
8
|
-
return '';
|
|
9
|
-
if (typeof value !== 'string' && typeof value !== 'number')
|
|
10
|
-
throw new Error('文献字段不是文本。');
|
|
11
|
-
const text = String(value);
|
|
12
|
-
if (text.length > 16384)
|
|
13
|
-
throw new Error('单个文献字段过长,请拆分或检查原始数据。');
|
|
14
|
-
return text.replace(/<[^>]*>/g, '').replace(/&(?:amp|lt|gt|quot|apos|nbsp);/g, token => ({ '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ' ': ' ' }[token]))
|
|
15
|
-
.replace(/&#(x[0-9a-f]+|\d+);/gi, (_, code) => { const n = code[0].toLowerCase() === 'x' ? parseInt(code.slice(1), 16) : Number(code); return n > 0 && n <= 0x10ffff && !(n >= 0xd800 && n <= 0xdfff) ? String.fromCodePoint(n) : '�'; })
|
|
16
|
-
.replace(/\s+/g, ' ').trim();
|
|
17
|
-
}
|
|
18
7
|
export function normalizeWork(value) {
|
|
19
8
|
if (!value || typeof value !== 'object')
|
|
20
9
|
throw new Error('文献记录无效。');
|
|
@@ -22,11 +11,11 @@ export function normalizeWork(value) {
|
|
|
22
11
|
if (!Array.isArray(authors) || authors.length > 2000)
|
|
23
12
|
throw new Error('作者列表无效或超过 2000 人。');
|
|
24
13
|
const work = {
|
|
25
|
-
doi:
|
|
14
|
+
doi: literalText(raw.doi), type: plainText(raw.type) || 'other', title: plainText(raw.title),
|
|
26
15
|
authors: authors.map(a => { if (!a || typeof a !== 'object')
|
|
27
16
|
throw new Error('作者记录无效。'); return { given: plainText(a.given), family: plainText(a.family), name: plainText(a.name) }; }).filter(a => a.given || a.family || a.name),
|
|
28
17
|
containerTitle: plainText(raw.containerTitle), publisher: plainText(raw.publisher), year: typeof raw.year === 'number' && Number.isInteger(raw.year) && raw.year > 0 && raw.year <= 9999 ? raw.year : 0,
|
|
29
|
-
language: plainText(raw.language), volume: plainText(raw.volume), issue: plainText(raw.issue), page: plainText(raw.page), url:
|
|
18
|
+
language: plainText(raw.language), volume: plainText(raw.volume), issue: plainText(raw.issue), page: plainText(raw.page), url: literalText(raw.url), isbn: plainText(raw.isbn),
|
|
30
19
|
};
|
|
31
20
|
if (work.doi)
|
|
32
21
|
work.doi = assertDoi(work.doi).toLowerCase();
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Display text and literal identifiers have different normalization rules. */
|
|
2
|
+
export declare function literalText(value: unknown): string;
|
|
3
|
+
export declare function plainText(value: unknown): string;
|
|
4
|
+
/** Strip known JATS/CSL formatting only at provider/parser adapters, never from identifiers. */
|
|
5
|
+
export declare function providerText(value: unknown): string;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Display text and literal identifiers have different normalization rules. */
|
|
2
|
+
export function literalText(value) {
|
|
3
|
+
if (value === undefined || value === null)
|
|
4
|
+
return '';
|
|
5
|
+
if (typeof value !== 'string' && typeof value !== 'number')
|
|
6
|
+
throw new Error('文献字段不是文本。');
|
|
7
|
+
const text = String(value);
|
|
8
|
+
if (text.length > 16384)
|
|
9
|
+
throw new Error('单个文献字段过长,请拆分或检查原始数据。');
|
|
10
|
+
return text.trim();
|
|
11
|
+
}
|
|
12
|
+
export function plainText(value) {
|
|
13
|
+
return literalText(value).replace(/&(?:amp|lt|gt|quot|apos|nbsp);/g, token => ({ '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ' ': ' ' }[token]))
|
|
14
|
+
.replace(/&#(x[0-9a-f]+|\d+);/gi, (_, code) => { const n = code[0].toLowerCase() === 'x' ? parseInt(code.slice(1), 16) : Number(code); return n > 0 && n <= 0x10ffff && !(n >= 0xd800 && n <= 0xdfff) ? String.fromCodePoint(n) : '�'; })
|
|
15
|
+
.replace(/\s+/g, ' ').trim();
|
|
16
|
+
}
|
|
17
|
+
/** Strip known JATS/CSL formatting only at provider/parser adapters, never from identifiers. */
|
|
18
|
+
export function providerText(value) {
|
|
19
|
+
return plainText(literalText(value).replace(/<\/?(?:(?:jats|mml):[a-z][\w.-]*|(?:i|b|em|strong|sub|sup|span|p|br|italic|bold|title|sc))\b[^<>]*>/gi, ''));
|
|
20
|
+
}
|
package/lib/citation-worker.js
CHANGED
|
@@ -2,14 +2,15 @@ import { parentPort, workerData } from 'node:worker_threads';
|
|
|
2
2
|
import { Cite } from '@citation-js/core';
|
|
3
3
|
import '@citation-js/plugin-bibtex';
|
|
4
4
|
import { assertDoi } from './crossref.js';
|
|
5
|
-
import { MAX_BATCH, normalizeWork,
|
|
5
|
+
import { MAX_BATCH, normalizeWork, workWarnings } from './citation-model.js';
|
|
6
|
+
import { providerText } from './citation-text.js';
|
|
6
7
|
const types = { 'article-journal': 'journal-article', book: 'book', chapter: 'book-chapter', 'paper-conference': 'proceedings-article', thesis: 'dissertation', report: 'report', dataset: 'dataset', patent: 'patent', standard: 'standard' };
|
|
7
8
|
// Citation.js treats BibTeX's publisher "and" separator as a list. The local
|
|
8
9
|
// Work model is plain text; preserve these names and retain the original source.
|
|
9
|
-
const cslText = (value) => Array.isArray(value) ? value.map(
|
|
10
|
+
const cslText = (value) => Array.isArray(value) ? value.map(providerText).join(' and ') : providerText(value);
|
|
10
11
|
function workOf(csl) {
|
|
11
12
|
return normalizeWork({
|
|
12
|
-
doi: csl.DOI, type: types[csl.type] || 'other', title: csl.title,
|
|
13
|
+
doi: csl.DOI, type: types[csl.type] || 'other', title: cslText(csl.title),
|
|
13
14
|
authors: (csl.author || []).map(a => { const family = [a['non-dropping-particle'], a.family, a.suffix].filter(Boolean).join(' '); const given = [a.given, a['dropping-particle']].filter(Boolean).join(' '); return a.literal || !given ? { name: a.literal || family, family: '', given: '' } : { name: '', family, given }; }),
|
|
14
15
|
containerTitle: cslText(csl['container-title']), publisher: cslText(csl.publisher), year: Number(csl.issued?.['date-parts']?.[0]?.[0] || 0), language: csl.language, volume: csl.volume, issue: csl.issue, page: csl.page, url: csl.URL, isbn: csl.ISBN,
|
|
15
16
|
});
|
package/lib/crossref.js
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module dsh-cite/crossref
|
|
5
5
|
*/
|
|
6
|
+
import { providerText } from './citation-text.js';
|
|
6
7
|
const CROSSREF_API = 'https://api.crossref.org/works/';
|
|
7
|
-
const DOI_PATTERN = /^10\.\d{4,9}\/[
|
|
8
|
+
const DOI_PATTERN = /^10\.\d{4,9}\/[^\s{}\\\u0000-\u001f\u007f]+$/i;
|
|
8
9
|
export function assertDoi(value) {
|
|
9
10
|
const doi = value.trim().replace(/^https?:\/\/(dx\.)?doi\.org\//i, '').replace(/^doi:\s*/i, '');
|
|
10
11
|
if (!DOI_PATTERN.test(doi))
|
|
@@ -47,10 +48,10 @@ function normalizeWork(raw) {
|
|
|
47
48
|
return {
|
|
48
49
|
doi,
|
|
49
50
|
type: typeof raw.type === 'string' ? raw.type : 'other',
|
|
50
|
-
title: firstText(raw.title),
|
|
51
|
+
title: providerText(firstText(raw.title)),
|
|
51
52
|
authors,
|
|
52
|
-
containerTitle: firstText(raw['container-title']),
|
|
53
|
-
publisher: typeof raw.publisher === 'string' ? raw.publisher
|
|
53
|
+
containerTitle: providerText(firstText(raw['container-title'])),
|
|
54
|
+
publisher: typeof raw.publisher === 'string' ? providerText(raw.publisher) : '',
|
|
54
55
|
year: year || 0,
|
|
55
56
|
language: typeof raw.language === 'string' ? raw.language.trim().toLowerCase() : '',
|
|
56
57
|
volume: typeof raw.volume === 'string' ? raw.volume.trim() : '',
|
|
@@ -161,7 +162,7 @@ export async function searchWorks(query, limit, cfg, fetchImpl, signal) {
|
|
|
161
162
|
}
|
|
162
163
|
/** 从任意文本里提取 DOI。 */
|
|
163
164
|
export function extractDois(text) {
|
|
164
|
-
const pattern = /\b10\.\d{4,9}\/[-._;()/:A-Za-z0-9]+/gi;
|
|
165
|
+
const pattern = /\b10\.\d{4,9}\/[-._;()/:A-Za-z0-9<>]+/gi;
|
|
165
166
|
const seen = new Set();
|
|
166
167
|
const result = [];
|
|
167
168
|
for (const match of text.match(pattern) ?? []) {
|
package/lib/format.js
CHANGED
|
@@ -278,7 +278,8 @@ export function buildBibtex(work, key) {
|
|
|
278
278
|
fields.push(' publisher={' + escapeLatex(work.publisher) + '}');
|
|
279
279
|
if (work.doi !== '')
|
|
280
280
|
fields.push(' doi={' + work.doi + '}');
|
|
281
|
+
// BibTeX/biblatex URL fields are verbatim, unlike titles and personal names.
|
|
281
282
|
if (work.url !== '')
|
|
282
|
-
fields.push(' url={' +
|
|
283
|
+
fields.push(' url={' + work.url.replaceAll('{', '%7B').replaceAll('}', '%7D') + '}');
|
|
283
284
|
return '@' + type + '{' + baseKey + ',\n' + fields.join(',\n') + '\n}';
|
|
284
285
|
}
|