diff --git a/electron/main/parser/formats/line-native-txt.ts b/electron/main/parser/formats/line-native-txt.ts index b4fff600..3945808e 100644 --- a/electron/main/parser/formats/line-native-txt.ts +++ b/electron/main/parser/formats/line-native-txt.ts @@ -1,12 +1,23 @@ /** * LINE 官方导出 TXT 格式解析器 - * 支持私聊和群聊,支持英文/中文/日文等多语言导出 + * 支持私聊和群聊,支持多语言导出(EN / ZH-CN / ZH-TW / JA) * - * 特征: - * - 私聊:有头部(标题+保存时间),消息用 Tab 分隔 - * - 群聊:无头部,直接日期行开始,消息用空格分隔 - * - 时间格式:HH:MM(无秒数) - * - 日期行独立,包含年月日 + * 格式特征: + * - 头部格式(私聊和群聊相同): + * Line 1: [LINE] {name}的聊天记录 / Chat history with/in {name} / ... + * Line 2: 保存日期: YYYY/MM/DD HH:MM / Saved on: ... + * Line 3: (空行) + * - 日期行:YYYY/MM/DD(星期)或 Day, MM/DD/YYYY + * - 消息格式:TIME\t{sender}\t{content}(Tab 分隔) + * - 系统消息:TIME\t\t{content}(双 Tab,无发送者) + * - 时间格式:HH:MM / 上午|下午HH:MM / 午前|午後HH:MM / HH:MMam|pm + * - 多行消息:用双引号包裹 + * + * 私聊 vs 群聊区分: + * - EN: "Chat history with {name}" (私聊) vs "Chat history in {name}" (群聊) + * - JA: "{name}とのトーク履歴" (私聊) vs "{name}のトーク履歴" (群聊) + * - ZH-CN: "与{name}的聊天记录" (私聊) vs "{name}的聊天记录" (群聊) + * - ZH-TW: "與{name}的聊天記錄" (私聊) vs "{name}的聊天記錄" (群聊) */ import * as fs from 'fs' @@ -30,16 +41,15 @@ export const feature: FormatFeature = { id: 'line-native-txt', name: 'LINE 官方导出 TXT', platform: KNOWN_PLATFORMS.LINE, - priority: 35, // 在 QQ 官方导出之后 + priority: 35, extensions: ['.txt'], signatures: { head: [ - // 私聊:Tab 分隔的消息格式 - /^\d{1,2}:\d{2}\t[^\t\n]+\t/m, - // 群聊:日期行格式 (YYYY.MM.DD) - /^\d{4}\.\d{2}\.\d{2} (Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)/m, - // 私聊头部关键词(多语言) - /^(Chat history with |\[LINE\] )/m, + // 头部标识(多语言) + /^\[LINE\] /m, + /^(?:\[LINE\] )?Chat history (?:with|in) /m, + // Tab 分隔的消息格式(支持多种时间格式) + /^((?:上午|下午|午前|午後)?\d{1,2}:\d{2}(?:[AaPp][Mm])?)\t[^\t\n]+\t/m, ], }, } @@ -57,20 +67,41 @@ function extractNameFromFilePath(filePath: string): string { } /** - * 从私聊头部提取对方名称 + * 从头部提取聊天名称和类型 + * 支持:英文、日文、简体中文、繁体中文 */ -function extractNameFromHeader(header: string): string | null { - // 英文:Chat history with {name} - const enMatch = header.match(/^Chat history with (.+)$/m) - if (enMatch) return enMatch[1].trim() +function extractNameFromHeader(header: string): { name: string; isGroup: boolean } | null { + // ===== 英文 ===== + // 私聊:Chat history with {name} + const enPrivateMatch = header.match(/^(?:\[LINE\] )?Chat history with (.+)$/m) + if (enPrivateMatch) return { name: enPrivateMatch[1].trim(), isGroup: false } + // 群聊:Chat history in {name} + const enGroupMatch = header.match(/^(?:\[LINE\] )?Chat history in (.+)$/m) + if (enGroupMatch) return { name: enGroupMatch[1].trim(), isGroup: true } - // 中文:[LINE] 与{name}的对话 - const zhMatch = header.match(/^\[LINE\] 与(.+)的对话$/m) - if (zhMatch) return zhMatch[1].trim() + // ===== 日文 ===== + // 私聊:{name}とのトーク履歴 + const jaPrivateMatch = header.match(/^\[LINE\] (.+)とのトーク履歴/) + if (jaPrivateMatch) return { name: jaPrivateMatch[1].trim(), isGroup: false } + // 群聊:{name}のトーク履歴 + const jaGroupMatch = header.match(/^\[LINE\] (.+)のトーク履歴/) + if (jaGroupMatch) return { name: jaGroupMatch[1].trim(), isGroup: true } - // 日文:[LINE] {name}とのトーク履歴 - const jaMatch = header.match(/^\[LINE\] (.+)とのトーク/) - if (jaMatch) return jaMatch[1].trim() + // ===== 简体中文 ===== + // 私聊:与{name}的聊天记录 + const zhCnPrivateMatch = header.match(/^\[LINE\] 与(.+)的聊天记录/) + if (zhCnPrivateMatch) return { name: zhCnPrivateMatch[1].trim(), isGroup: false } + // 群聊:{name}的聊天记录 + const zhCnGroupMatch = header.match(/^\[LINE\] (.+)的聊天记录/) + if (zhCnGroupMatch) return { name: zhCnGroupMatch[1].trim(), isGroup: true } + + // ===== 繁体中文 ===== + // 私聊:與{name}的聊天記錄 + const zhTwPrivateMatch = header.match(/^\[LINE\] 與(.+)的聊天記錄/) + if (zhTwPrivateMatch) return { name: zhTwPrivateMatch[1].trim(), isGroup: false } + // 群聊:{name}的聊天記錄 + const zhTwGroupMatch = header.match(/^\[LINE\] (.+)的聊天記錄/) + if (zhTwGroupMatch) return { name: zhTwGroupMatch[1].trim(), isGroup: true } return null } @@ -115,49 +146,62 @@ function parseDateLine(line: string): Date | null { /** * 消息行正则模式 + * 时间格式:HH:MM / HH:MMam|pm / 上午|下午|午前|午後HH:MM */ -// 私聊:HH:MM\t{name}\t{content} -const PRIVATE_MSG_PATTERN = /^(\d{1,2}:\d{2})\t([^\t]+)\t(.*)$/ -// 群聊:HH:MM {name} {content} -const GROUP_MSG_PATTERN = /^(\d{1,2}:\d{2}) ([^\s]+) (.*)$/ +// 私聊/群聊(有发送者):TIME\t{name}\t{content} +const PRIVATE_MSG_PATTERN = /^((?:上午|下午|午前|午後)?\d{1,2}:\d{2}(?:[AaPp][Mm])?)\t([^\t]+)\t(.*)$/ +// 群聊:HH:MM {name} {content} (已废弃,实际都用 Tab 分隔) +const GROUP_MSG_PATTERN = /^((?:上午|下午|午前|午後)?\d{1,2}:\d{2}(?:[AaPp][Mm])?) ([^\s]+) (.*)$/ +// 系统消息:双 Tab(无发送者),如「下午07:04\t\tXXX已加入群組」 +const SYSTEM_MSG_PATTERN = /^((?:上午|下午|午前|午後)?\d{1,2}:\d{2}(?:[AaPp][Mm])?)\t\t(.+)$/ /** - * 特殊消息类型映射(多语言) + * 特殊消息类型映射(多语言:EN / ZH-CN / ZH-TW / JA) */ const SPECIAL_MESSAGE_TYPES: Record = { - // 图片 - '[Photo]': MessageType.IMAGE, - '[照片]': MessageType.IMAGE, - '[写真]': MessageType.IMAGE, - Photos: MessageType.IMAGE, + // 图片 / Photo + '[Photo]': MessageType.IMAGE, // EN + '[照片]': MessageType.IMAGE, // ZH-CN / ZH-TW + '[写真]': MessageType.IMAGE, // JA + Photos: MessageType.IMAGE, // EN (fallback) - // 语音 - '[Voice message]': MessageType.VOICE, - '[语音信息]': MessageType.VOICE, - '[ボイスメッセージ]': MessageType.VOICE, - Audio: MessageType.VOICE, + // 语音 / Voice + '[Voice message]': MessageType.VOICE, // EN + '[语音信息]': MessageType.VOICE, // ZH-CN + '[語音訊息]': MessageType.VOICE, // ZH-TW + '[ボイスメッセージ]': MessageType.VOICE, // JA + Audio: MessageType.VOICE, // EN (fallback) - // 视频 - Videos: MessageType.VIDEO, - '[Video]': MessageType.VIDEO, - '[视频]': MessageType.VIDEO, - '[動画]': MessageType.VIDEO, + // 视频 / Video + '[Video]': MessageType.VIDEO, // EN + '[视频]': MessageType.VIDEO, // ZH-CN + '[影片]': MessageType.VIDEO, // ZH-TW + '[動画]': MessageType.VIDEO, // JA + Videos: MessageType.VIDEO, // EN (fallback) - // 文件 - '[File]': MessageType.FILE, - '[文件]': MessageType.FILE, - '[ファイル]': MessageType.FILE, + // 文件 / File + '[File]': MessageType.FILE, // EN + '[文件]': MessageType.FILE, // ZH-CN + '[檔案]': MessageType.FILE, // ZH-TW + '[ファイル]': MessageType.FILE, // JA - // 贴纸/表情 - Stickers: MessageType.EMOJI, - '[Sticker]': MessageType.EMOJI, - '[贴图]': MessageType.EMOJI, - '[スタンプ]': MessageType.EMOJI, + // 贴纸 / Sticker + '[Sticker]': MessageType.EMOJI, // EN + '[贴图]': MessageType.EMOJI, // ZH-CN + '[貼圖]': MessageType.EMOJI, // ZH-TW + '[スタンプ]': MessageType.EMOJI, // JA + Stickers: MessageType.EMOJI, // EN (fallback) - // 位置 - '[Location]': MessageType.LOCATION, - '[位置]': MessageType.LOCATION, - '[位置情報]': MessageType.LOCATION, + // 位置 / Location + '[Location]': MessageType.LOCATION, // EN + '[位置]': MessageType.LOCATION, // ZH-CN / ZH-TW + '[位置情報]': MessageType.LOCATION, // JA + + // 记事本 / Notes + '[Notes]': MessageType.TEXT, // EN + '[记事本]': MessageType.TEXT, // ZH-CN + '[記事本]': MessageType.TEXT, // ZH-TW + '[ノート]': MessageType.TEXT, // JA } /** @@ -176,13 +220,45 @@ function detectMessageType(content: string): MessageType { return MessageType.LOCATION } - // 检查系统消息 + // 检查系统消息(多语言:EN / ZH-CN / ZH-TW / JA) if ( - content.includes(' added ') || - content.includes(' to the group') || - content.includes('unsent a message') || - content === 'Message unsent.' || - content.startsWith('Auto-reply') + // --- 加入群组 / Join group --- + content.includes(' joined the group') || // EN + content.includes('已加入该群') || // ZH-CN + content.includes('已加入群組') || // ZH-TW + content.includes('がグループに参加しました') || // JA + + // --- 拉人进群 / Added to group --- + content.includes(' added ') || // EN + content.includes(' to the group') || // EN + content.includes('已将') || // ZH-CN + content.includes('添加至群') || // ZH-CN + content.includes('添加到群') || // ZH-CN (另一格式) + content.includes('已新增') || // ZH-TW + content.includes('至群組') || // ZH-TW + content.includes('をグループに追加しました') || // JA + + // --- 退出群组 / Left group --- + content.includes(' left the group') || // EN + content.includes('已退群') || // ZH-CN + content.includes('已離開群組') || // ZH-TW + content.includes('がグループを退会しました') || // JA + + // --- 设定公告 / Announcement --- + content.includes('made an announcement') || // EN + content.includes('发布了通告') || // ZH-CN + content.includes('已設定公告') || // ZH-TW + content.includes('がアナウンスしました') || // JA + + // --- 收回讯息 / Unsent message --- + content.includes('unsent a message') || // EN + content === 'Message unsent.' || // EN + content.includes('撤回了一条消息') || // ZH-CN + content.includes('已收回訊息') || // ZH-TW + content.includes('送信を取り消しました') || // JA + + // --- 其他 / Others --- + content.startsWith('Auto-reply') // EN 自动回复 ) { return MessageType.SYSTEM } @@ -225,17 +301,17 @@ async function* parseLINE(options: ParseOptions): AsyncGenerator 0) { const firstLine = lines[0].trim() onLog?.('debug', `LINE 第一行: "${firstLine}"`) - const headerName = extractNameFromHeader(firstLine) - if (headerName) { - chatName = headerName - isPrivateChat = true - useTabSeparator = true + const headerResult = extractNameFromHeader(firstLine) + if (headerResult) { + chatName = headerResult.name + isPrivateChat = !headerResult.isGroup + useTabSeparator = true // 两种头部格式都使用 Tab 分隔 lineIndex = 3 // 跳过头部(标题、保存时间、空行) - onLog?.('debug', `LINE 检测到私聊头部,对方: ${headerName}`) + onLog?.('debug', `LINE 检测到头部,名称: ${headerResult.name}, 群聊: ${headerResult.isGroup}`) } } @@ -284,10 +360,45 @@ async function* parseLINE(options: ParseOptions): AsyncGenerator