From b52bdcf4b3aa18aa67c94c154a52420bee80ddce Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 20 Mar 2026 17:03:48 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=88=AB=E7=9A=84=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/exportHtml.css | 27 +++ electron/services/exportHtmlStyles.ts | 27 +++ electron/services/exportService.ts | 313 ++++++++++++++++++++++++-- 3 files changed, 353 insertions(+), 14 deletions(-) diff --git a/electron/services/exportHtml.css b/electron/services/exportHtml.css index 993e478..c6751fc 100644 --- a/electron/services/exportHtml.css +++ b/electron/services/exportHtml.css @@ -186,6 +186,33 @@ body { word-break: break-word; } +.quoted-message { + border-left: 3px solid rgba(79, 70, 229, 0.35); + background: rgba(79, 70, 229, 0.06); + border-radius: 12px; + padding: 8px 10px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.message.sent .quoted-message { + background: rgba(37, 99, 235, 0.08); + border-left-color: rgba(37, 99, 235, 0.35); +} + +.quoted-sender { + font-size: 12px; + color: #374151; + font-weight: 600; +} + +.quoted-text { + font-size: 13px; + color: #4b5563; + word-break: break-word; +} + .message-link-card { color: #2563eb; text-decoration: underline; diff --git a/electron/services/exportHtmlStyles.ts b/electron/services/exportHtmlStyles.ts index 935eb49..96f4288 100644 --- a/electron/services/exportHtmlStyles.ts +++ b/electron/services/exportHtmlStyles.ts @@ -186,6 +186,33 @@ body { word-break: break-word; } +.quoted-message { + border-left: 3px solid rgba(79, 70, 229, 0.35); + background: rgba(79, 70, 229, 0.06); + border-radius: 12px; + padding: 8px 10px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.message.sent .quoted-message { + background: rgba(37, 99, 235, 0.08); + border-left-color: rgba(37, 99, 235, 0.35); +} + +.quoted-sender { + font-size: 12px; + color: #374151; + font-weight: 600; +} + +.quoted-text { + font-size: 13px; + color: #4b5563; + word-break: break-word; +} + .message-link-card { color: #2563eb; text-decoration: underline; diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 0e27cc7..89e77ca 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -1634,7 +1634,13 @@ class ExportService { if (type === '6') return title ? `[文件] ${title}` : '[文件]' if (type === '19') return this.formatForwardChatRecordContent(normalizedContent) if (type === '33' || type === '36') return title ? `[小程序] ${title}` : '[小程序]' - if (type === '57') return title || '[引用消息]' + if (type === '57') { + const quoteDisplay = this.extractQuotedReplyDisplay(content) + if (quoteDisplay) { + return this.buildQuotedReplyText(quoteDisplay) + } + return title || '[引用消息]' + } if (type === '5' || type === '49') return title ? `[链接] ${title}` : '[链接]' return title ? `[链接] ${title}` : '[链接]' } @@ -1643,6 +1649,10 @@ class ExportService { case 266287972401: return this.cleanSystemMessage(content) // 拍一拍 case 244813135921: { // 引用消息 + const quoteDisplay = this.extractQuotedReplyDisplay(content) + if (quoteDisplay) { + return this.buildQuotedReplyText(quoteDisplay) + } const title = this.extractXmlValue(content, 'title') return title || '[引用消息]' } @@ -1676,7 +1686,13 @@ class ExportService { if (xmlType === '6') return title ? `[文件] ${title}` : '[文件]' if (xmlType === '19') return this.formatForwardChatRecordContent(normalizedContent) if (xmlType === '33' || xmlType === '36') return title ? `[小程序] ${title}` : '[小程序]' - if (xmlType === '57') return title || '[引用消息]' + if (xmlType === '57') { + const quoteDisplay = this.extractQuotedReplyDisplay(content) + if (quoteDisplay) { + return this.buildQuotedReplyText(quoteDisplay) + } + return title || '[引用消息]' + } if (xmlType === '5' || xmlType === '49') return title ? `[链接] ${title}` : '[链接]' // 有 title 就返回 title @@ -1801,6 +1817,10 @@ class ExportService { return `[小程序]${appName}` } if (subType === 57) { + const quoteDisplay = this.extractQuotedReplyDisplay(safeContent) + if (quoteDisplay) { + return this.buildQuotedReplyText(quoteDisplay) + } return title || '[引用消息]' } if (title) { @@ -1812,6 +1832,151 @@ class ExportService { return '[其他消息]' } + private formatQuotedReferencePreview(content: string, type?: string): string { + const safeContent = content || '' + const referType = Number.parseInt(String(type || ''), 10) + if (!Number.isFinite(referType)) { + const sanitized = this.sanitizeQuotedContent(safeContent) + return sanitized || '[消息]' + } + + if (referType === 49) { + const normalized = this.normalizeAppMessageContent(safeContent) + const title = + this.extractXmlValue(normalized, 'title') || + this.extractXmlValue(normalized, 'filename') || + this.extractXmlValue(normalized, 'appname') + if (title) return this.stripSenderPrefix(title) + + const subTypeRaw = this.extractAppMessageType(normalized) + const subType = subTypeRaw ? parseInt(subTypeRaw, 10) : 0 + if (subType === 6) return '[文件]' + if (subType === 19) return '[聊天记录]' + if (subType === 33 || subType === 36) return '[小程序]' + return '[链接]' + } + + return this.formatPlainExportContent(safeContent, referType, { exportVoiceAsText: false }) || '[消息]' + } + + private resolveQuotedSenderUsername(fromusr?: string, chatusr?: string): string { + const normalizedChatUsr = String(chatusr || '').trim() + const normalizedFromUsr = String(fromusr || '').trim() + + if (normalizedChatUsr) { + return normalizedChatUsr + } + + if (normalizedFromUsr.endsWith('@chatroom')) { + return '' + } + + return normalizedFromUsr + } + + private buildQuotedReplyText(display: { + replyText: string + quotedSender?: string + quotedPreview: string + }): string { + const quoteLabel = display.quotedSender + ? `${display.quotedSender}:${display.quotedPreview}` + : display.quotedPreview + if (display.replyText) { + return `${display.replyText}[引用 ${quoteLabel}]` + } + return `[引用 ${quoteLabel}]` + } + + private extractQuotedReplyDisplay(content: string): { + replyText: string + quotedSender?: string + quotedPreview: string + } | null { + try { + const normalized = this.normalizeAppMessageContent(content || '') + const referMsgStart = normalized.indexOf('') + const referMsgEnd = normalized.indexOf('') + if (referMsgStart === -1 || referMsgEnd === -1) { + return null + } + + const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11) + const quoteInfo = this.parseQuoteMessage(normalized) + const replyText = this.stripSenderPrefix(this.extractXmlValue(normalized, 'title') || '') + const quotedPreview = this.formatQuotedReferencePreview( + this.extractXmlValue(referMsgXml, 'content'), + this.extractXmlValue(referMsgXml, 'type') + ) + + if (!replyText && !quotedPreview) { + return null + } + + return { + replyText, + quotedSender: quoteInfo.sender || undefined, + quotedPreview: quotedPreview || '[消息]' + } + } catch { + return null + } + } + + private async resolveQuotedReplyDisplayWithNames(args: { + content: string + isGroup: boolean + displayNamePreference: ExportOptions['displayNamePreference'] + getContact: (username: string) => Promise<{ success: boolean; contact?: any; error?: string }> + groupNicknamesMap: Map + cleanedMyWxid: string + rawMyWxid?: string + myDisplayName?: string + }): Promise<{ + replyText: string + quotedSender?: string + quotedPreview: string + } | null> { + const base = this.extractQuotedReplyDisplay(args.content) + if (!base) return null + if (base.quotedSender) return base + + const normalized = this.normalizeAppMessageContent(args.content || '') + const referMsgStart = normalized.indexOf('') + const referMsgEnd = normalized.indexOf('') + if (referMsgStart === -1 || referMsgEnd === -1) { + return base + } + + const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11) + const quotedSenderUsername = this.resolveQuotedSenderUsername( + this.extractXmlValue(referMsgXml, 'fromusr'), + this.extractXmlValue(referMsgXml, 'chatusr') + ) + if (!quotedSenderUsername) { + return base + } + + const isQuotedSelf = this.isSameWxid(quotedSenderUsername, args.cleanedMyWxid) + const fallbackDisplayName = isQuotedSelf + ? (args.myDisplayName || quotedSenderUsername) + : quotedSenderUsername + + const profile = await this.resolveExportDisplayProfile( + quotedSenderUsername, + args.displayNamePreference, + args.getContact, + args.groupNicknamesMap, + fallbackDisplayName, + isQuotedSelf ? [args.rawMyWxid, args.cleanedMyWxid] : [] + ) + + return { + ...base, + quotedSender: profile.displayName || fallbackDisplayName || base.quotedSender + } + } + private parseDurationSeconds(value: string): number | null { const numeric = Number(value) if (!Number.isFinite(numeric) || numeric <= 0) return null @@ -2493,6 +2658,15 @@ class ExportService { } } + private getExportPlatformMessageId(msg: { serverIdRaw?: unknown; serverId?: unknown }): string | undefined { + const value = this.normalizeUnsignedIntToken(msg.serverIdRaw ?? msg.serverId) + return value !== '0' ? value : undefined + } + + private getExportReplyToMessageId(content: string): string | undefined { + return this.extractChatLabReplyToMessageId(content) + } + private extractArkmeAppMessageMeta(content: string, localType: number): Record | null { if (!content) return null @@ -4944,6 +5118,20 @@ class ExportService { ) } + const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({ + content: msg.content, + isGroup, + displayNamePreference: options.displayNamePreference, + getContact: getContactCached, + groupNicknamesMap, + cleanedMyWxid, + rawMyWxid, + myDisplayName: myInfo.displayName || cleanedMyWxid + }) + if (quotedReplyDisplay) { + content = this.buildQuotedReplyText(quotedReplyDisplay) + } + // 获取发送者信息用于名称显示 const senderWxid = msg.senderUsername const contact = senderWxid @@ -4987,6 +5175,12 @@ class ExportService { senderAvatarKey: msg.senderUsername } + const platformMessageId = this.getExportPlatformMessageId(msg) + if (platformMessageId) msgObj.platformMessageId = platformMessageId + + const replyToMessageId = this.getExportReplyToMessageId(msg.content) + if (replyToMessageId) msgObj.replyToMessageId = replyToMessageId + const appMsgMeta = this.extractArkmeAppMessageMeta(msg.content, msg.localType) if (appMsgMeta) { if ( @@ -4996,6 +5190,10 @@ class ExportService { Object.assign(msgObj, appMsgMeta) } } + if (quotedReplyDisplay) { + if (quotedReplyDisplay.quotedSender) msgObj.quotedSender = quotedReplyDisplay.quotedSender + if (quotedReplyDisplay.quotedPreview) msgObj.quotedContent = quotedReplyDisplay.quotedPreview + } if (options.format === 'arkme-json') { const contactCardMeta = this.extractArkmeContactCardMeta(msg.content, msg.localType) @@ -5193,6 +5391,8 @@ class ExportService { senderID, source: message.source } + if (message.platformMessageId) compactMessage.platformMessageId = message.platformMessageId + if (message.replyToMessageId) compactMessage.replyToMessageId = message.replyToMessageId if (message.locationLat != null) compactMessage.locationLat = message.locationLat if (message.locationLng != null) compactMessage.locationLng = message.locationLng if (message.locationPoiname) compactMessage.locationPoiname = message.locationPoiname @@ -5830,6 +6030,20 @@ class ExportService { } } + const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({ + content: msg.content, + isGroup, + displayNamePreference: options.displayNamePreference, + getContact: getContactCached, + groupNicknamesMap, + cleanedMyWxid, + rawMyWxid, + myDisplayName: myInfo.displayName || cleanedMyWxid + }) + if (quotedReplyDisplay) { + enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay) + } + // 调试日志 if (msg.localType === 3 || msg.localType === 47) { } @@ -6077,6 +6291,20 @@ class ExportService { } } + const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({ + content: msg.content, + isGroup, + displayNamePreference: options.displayNamePreference, + getContact: getContactCached, + groupNicknamesMap, + cleanedMyWxid, + rawMyWxid, + myDisplayName: myInfo.displayName || cleanedMyWxid + }) + if (quotedReplyDisplay) { + enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay) + } + appendRow(useCompactColumns ? [ i + 1, @@ -6430,6 +6658,20 @@ class ExportService { } } + const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({ + content: msg.content, + isGroup, + displayNamePreference: options.displayNamePreference, + getContact: getContactCached, + groupNicknamesMap, + cleanedMyWxid, + rawMyWxid, + myDisplayName: myInfo.displayName || cleanedMyWxid + }) + if (quotedReplyDisplay) { + enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay) + } + let senderRole: string let senderWxid: string let senderNickname: string @@ -6724,7 +6966,7 @@ class ExportService { }) const lines: string[] = [] - lines.push('id,MsgSvrID,type_name,is_sender,talker,msg,src,CreateTime') + lines.push('id,MsgSvrID,ReplyToMsgSvrID,type_name,is_sender,talker,msg,src,CreateTime') const senderProfileCache = new Map() for (let i = 0; i < totalMessages; i++) { @@ -6790,15 +7032,31 @@ class ExportService { msg.senderUsername, msg.isSend ) || '') + const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({ + content: msg.content, + isGroup, + displayNamePreference: options.displayNamePreference, + getContact: getContactCached, + groupNicknamesMap, + cleanedMyWxid, + rawMyWxid, + myDisplayName: myInfo.displayName || cleanedMyWxid + }) + const finalMsgText = quotedReplyDisplay + ? this.buildQuotedReplyText(quotedReplyDisplay) + : msgText const src = this.getWeCloneSource(msg, typeName, mediaItem) + const platformMessageId = this.getExportPlatformMessageId(msg) || '' + const replyToMessageId = this.getExportReplyToMessageId(msg.content) || '' const row = [ i + 1, - i + 1, + platformMessageId, + replyToMessageId, typeName, msg.isSend ? 1 : 0, talker, - msgText, + finalMsgText, src, this.formatIsoTimestamp(msg.createTime) ] @@ -7270,8 +7528,18 @@ class ExportService { const timeText = this.formatTimestamp(msg.createTime) const typeName = this.getMessageTypeName(msg.localType) + const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({ + content: msg.content, + isGroup, + displayNamePreference: options.displayNamePreference, + getContact: getContactCached, + groupNicknamesMap, + cleanedMyWxid, + rawMyWxid, + myDisplayName: myInfo.displayName || cleanedMyWxid + }) - let textContent = this.formatHtmlMessageText( + let textContent = quotedReplyDisplay?.replyText || this.formatHtmlMessageText( msg.content, msg.localType, cleanedMyWxid, @@ -7302,7 +7570,7 @@ class ExportService { } } - const linkCard = this.extractHtmlLinkCard(msg.content, msg.localType) + const linkCard = quotedReplyDisplay ? null : this.extractHtmlLinkCard(msg.content, msg.localType) let mediaHtml = '' if (mediaItem?.kind === 'image') { @@ -7318,25 +7586,40 @@ class ExportService { mediaHtml = `` } - const textHtml = linkCard - ? `` - : (textContent - ? `
${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '
')}
` - : '') + const textHtml = quotedReplyDisplay + ? (() => { + const quotedSenderHtml = quotedReplyDisplay.quotedSender + ? `
${this.escapeHtml(quotedReplyDisplay.quotedSender)}
` + : '' + const quotedPreviewHtml = `
${this.renderTextWithEmoji(quotedReplyDisplay.quotedPreview).replace(/\r?\n/g, '
')}
` + const replyTextHtml = textContent + ? `
${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '
')}
` + : '' + return `
${quotedSenderHtml}${quotedPreviewHtml}
${replyTextHtml}` + })() + : (linkCard + ? `` + : (textContent + ? `
${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '
')}
` + : '')) const senderNameHtml = isGroup ? `
${this.escapeHtml(resolvedSenderName)}
` : '' const timeHtml = `
${this.escapeHtml(timeText)}
` const messageBody = `${timeHtml}${senderNameHtml}
${mediaHtml}${textHtml}
` + const platformMessageId = this.getExportPlatformMessageId(msg) + const replyToMessageId = this.getExportReplyToMessageId(msg.content) // Compact JSON object - const itemObj = { + const itemObj: Record = { i: i + 1, // index t: msg.createTime, // timestamp s: isSenderMe ? 1 : 0, // isSend a: avatarHtml, // avatar HTML b: messageBody // body HTML } + if (platformMessageId) itemObj.p = platformMessageId + if (replyToMessageId) itemObj.r = replyToMessageId writeBuf.push(JSON.stringify(itemObj)) @@ -7384,8 +7667,10 @@ class ExportService { // Render Item Function const renderItem = (item, index) => { const isSenderMe = item.s === 1; + const platformIdAttr = item.p ? \` data-platform-message-id="\${item.p}"\` : ''; + const replyToAttr = item.r ? \` data-reply-to-message-id="\${item.r}"\` : ''; return \` -
+
\${item.a}