Files
author/app/lib/chapter-number.js

208 lines
7.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 章节编号工具:归一化、冲突检测、合并排序
*/
// ===== 中文数字 → 阿拉伯数字 =====
const CN_DIGITS = { 零: 0, 〇: 0, 一: 1, 壹: 1, 二: 2, 贰: 2, 两: 2, 三: 3, 叁: 3, 四: 4, 肆: 4, 五: 5, 伍: 5, 六: 6, 陆: 6, 七: 7, 柒: 7, 八: 8, 捌: 8, 九: 9, 玖: 9 };
const CN_UNITS = { 十: 10, 拾: 10, 百: 100, 佰: 100, 千: 1000, 仟: 1000, 万: 10000 };
/**
* 将中文数字字符串转换为阿拉伯数字
* 支持:一 ~ 九万九千九百九十九,以及"十三"省略"一十"的写法
* @param {string} str 纯中文数字字符串,如 "三十三"、"一百零五"
* @returns {number|null} 转换结果,无法识别返回 null
*/
export function chineseToNumber(str) {
if (!str || str.length === 0) return null;
let result = 0;
let current = 0; // 当前累计(百位以下)
let section = 0; // 当前段值
for (let i = 0; i < str.length; i++) {
const ch = str[i];
const digit = CN_DIGITS[ch];
const unit = CN_UNITS[ch];
if (digit !== undefined) {
current = digit;
} else if (unit !== undefined) {
if (unit === 10000) {
// "万":把之前的累计乘以万
section = (section + (current || 1)) * unit;
result += section;
section = 0;
current = 0;
} else {
// 十、百、千
section += (current || 1) * unit;
current = 0;
}
} else {
// 非中文数字字符
return null;
}
}
result += section + current;
return result > 0 ? result : null;
}
/**
* 从章节标题中提取归一化的章节编号(数字)
* 支持格式:
* "第三十三章 xxx" → 33
* "第33章 xxx" → 33
* "三十三" → 33
* "33" → 33
* "Chapter 33: xx" → 33
* "第一卷" → (不匹配,仅支持"章")
* 自由文字标题 → null
*
* @param {string} title 章节标题
* @returns {number|null} 归一化编号,无法识别返回 null
*/
export function extractChapterNumber(title) {
if (!title) return null;
const t = title.trim();
// 1️⃣ "第X章" — X 可以是阿拉伯数字或中文数字
const m1 = t.match(/^第([0-9]+)章/);
if (m1) return parseInt(m1[1], 10);
const m2 = t.match(/^第([零〇一壹二贰两三叁四肆五伍六陆七柒八捌九玖十拾百佰千仟万]+)章/);
if (m2) return chineseToNumber(m2[1]);
// 2️⃣ 纯阿拉伯数字开头 — "33"、"33 xxx"、"33.xxx"、"33、xxx"
const m3 = t.match(/^([0-9]+)(?:\s|[.、:::]|$)/);
if (m3) return parseInt(m3[1], 10);
// 3️⃣ 纯中文数字(整个标题或开头部分)
// 先尝试整个标题
const whole = chineseToNumber(t);
if (whole !== null) return whole;
// 再尝试开头的中文数字部分("三十三 xxx" → 33)
const m4 = t.match(/^([零〇一壹二贰两三叁四肆五伍六陆七柒八捌九玖十拾百佰千仟万]+)(?:\s|[.、:::]|$)/);
if (m4) return chineseToNumber(m4[1]);
// 4️⃣ "Chapter X" (英文)
const m5 = t.match(/^chapter\s+([0-9]+)/i);
if (m5) return parseInt(m5[1], 10);
return null;
}
/**
* 检测已有章节和导入章节之间的编号冲突
* @param {Array} existingChapters 已有章节 [{ id, title, content, ... }]
* @param {Array} importedChapters 导入章节
* @returns {{
* conflicts: Array<{ num: number, existing: Array, imported: Array }>,
* noConflictExisting: Array,
* noConflictImported: Array
* }}
*/
export function detectConflicts(existingChapters, importedChapters) {
// 给每个章节标记来源和归一化编号
const tagExisting = existingChapters.map(ch => ({
...ch,
_source: 'existing',
_num: extractChapterNumber(ch.title),
}));
const tagImported = importedChapters.map(ch => ({
...ch,
_source: 'imported',
_num: extractChapterNumber(ch.title),
}));
// 收集已有章节的编号集合
const existingNums = new Map(); // num → [chapters]
for (const ch of tagExisting) {
if (ch._num !== null) {
if (!existingNums.has(ch._num)) existingNums.set(ch._num, []);
existingNums.get(ch._num).push(ch);
}
}
// 检测冲突
const conflictMap = new Map(); // num → { existing: [], imported: [] }
const noConflictImported = [];
for (const ch of tagImported) {
if (ch._num !== null && existingNums.has(ch._num)) {
// 冲突
if (!conflictMap.has(ch._num)) {
conflictMap.set(ch._num, {
num: ch._num,
existing: [...existingNums.get(ch._num)],
imported: [],
});
}
conflictMap.get(ch._num).imported.push(ch);
} else {
noConflictImported.push(ch);
}
}
// 没有冲突的已有章节
const conflictNums = new Set(conflictMap.keys());
const noConflictExisting = tagExisting.filter(ch => ch._num === null || !conflictNums.has(ch._num));
// 按编号排序冲突组
const conflicts = Array.from(conflictMap.values()).sort((a, b) => a.num - b.num);
return { conflicts, noConflictExisting, noConflictImported };
}
/**
* 根据用户选择合并章节并排序
* @param {Array} noConflictExisting 无冲突的已有章节
* @param {Array} noConflictImported 无冲突的导入章节
* @param {Array<{ num: number, selected: Array }>} resolvedConflicts 用户已选择的冲突解决结果
* @returns {Array} 合并后的章节列表(按编号排序)
*/
export function mergeChapters(noConflictExisting, noConflictImported, resolvedConflicts) {
// 所有章节统一为 { chapter, _num }
const allChapters = [];
// 无冲突的已有章节
for (const ch of noConflictExisting) {
allChapters.push({ chapter: ch, _num: ch._num, _source: 'existing' });
}
// 无冲突的导入章节
for (const ch of noConflictImported) {
allChapters.push({ chapter: ch, _num: ch._num, _source: 'imported' });
}
// 已解决冲突的选定章节
for (const group of resolvedConflicts) {
for (const ch of group.selected) {
allChapters.push({ chapter: ch, _num: group.num, _source: ch._source });
}
}
// 分为有编号和无编号
const numbered = allChapters.filter(c => c._num !== null);
const unnumbered = allChapters.filter(c => c._num === null);
// 有编号的按编号排序,同编号内先 existing 后 imported
numbered.sort((a, b) => {
if (a._num !== b._num) return a._num - b._num;
// 同编号:existing 优先
const sourceOrder = { existing: 0, imported: 1 };
return (sourceOrder[a._source] || 0) - (sourceOrder[b._source] || 0);
});
// 合并:有编号 + 无编号追加末尾
const merged = [...numbered, ...unnumbered].map(c => {
// 清理内部标记
const { _source, _num, ...clean } = c.chapter;
return clean;
});
return merged;
}