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 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
@@ -2,6 +2,10 @@
2
2
 
3
3
  [返回简介](../README.md) · [更新记录](../CHANGELOG.md) · [验证记录](VALIDATION.md)
4
4
 
5
+ ## 标识符保真
6
+
7
+ BibTeX 导入、导出保留 URL 与 DOI;数学比较符不作为任意 HTML 删除。Crossref 的已知 JATS 格式单独处理,避免影响原始标识符。
8
+
5
9
  ## 本次改进
6
10
 
7
11
  可直接查询带成对括号的 DOI。网络或响应读取超时会给出明确错误;取消后不继续等待。文献库离线导入与外部 Crossref 检索分别验收。
@@ -1,5 +1,9 @@
1
1
  # dsh-cite 验证记录
2
2
 
3
+ ## 2026-10-26:0.6.1
4
+
5
+ [本次修复、回归与未验收范围](validation-0.6.1.md)。
6
+
3
7
  本页整理原 README 的历史验证说明,保留当时的版本、日期与范围。自动测试、启动检查、浏览器操作和真实服务验收分别记录,不能相互替代。更详细的版本验收文件仍保留在仓库中。
4
8
 
5
9
  ## 0.4.1(含 0.4.0 功能,2026-10-03)
@@ -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.
@@ -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;
@@ -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 => ({ '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&apos;': "'", '&nbsp;': ' ' }[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: plainText(raw.doi), type: plainText(raw.type) || 'other', title: plainText(raw.title),
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: plainText(raw.url), isbn: plainText(raw.isbn),
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 => ({ '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&apos;': "'", '&nbsp;': ' ' }[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
+ }
@@ -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, plainText, workWarnings } from './citation-model.js';
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(plainText).join(' and ') : plainText(value);
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}\/[-._;()/:A-Za-z0-9]+$/i;
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.trim() : '',
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={' + escapeLatex(work.url) + '}');
283
+ fields.push(' url={' + work.url.replaceAll('{', '%7B').replaceAll('}', '%7D') + '}');
283
284
  return '@' + type + '{' + baseKey + ',\n' + fields.join(',\n') + '\n}';
284
285
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cite",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "批量导入 DOI / BibTeX,预览去重、补全文献元数据并导出引文。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",