208 lines
7.1 KiB
JavaScript
208 lines
7.1 KiB
JavaScript
/**
|
||
* 章节编号工具:归一化、冲突检测、合并排序
|
||
*/
|
||
|
||
// ===== 中文数字 → 阿拉伯数字 =====
|
||
|
||
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;
|
||
}
|