From 4e84b52cc41a25939b09fb24c1e0616bab0fe742 Mon Sep 17 00:00:00 2001 From: "majun.jason" Date: Wed, 5 Aug 2026 00:12:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E4=B8=8E=E6=A1=A3=E6=A1=88=E6=B5=8F=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 修复数据量较大时,历史数据可能无法完整导出的问题 2. 优化分享 Tab 的信息展示,支持小程序、链接、地图等消息 3. 优化系统 Tab 的信息展示,支持红包、转账、拍一拍、撤回等消息 4. 修复默认进入时,时间轴不随消息自动定位的问题 5. 增加“定位到聊天位置”功能 --- src/main/export-html-template.ts | 530 +++++++++++++++++++- src/main/export-service.ts | 299 +++++++++-- src/main/services/chat-service.ts | 17 + src/main/services/recall-archive-service.ts | 7 +- src/main/wcdb4-client.ts | 12 +- src/main/wechat-db.ts | 51 +- tests/e2e/export-archive.spec.ts | 357 +++++++++++++ tests/integration/export-media-flow.test.ts | 149 +++++- tests/unit/export-media.test.ts | 100 ++++ tests/unit/recall-archive-service.test.ts | 78 +++ tests/unit/wcdb-message-shards.test.ts | 19 +- tests/unit/wechat-db-messages.test.ts | 71 +++ 12 files changed, 1615 insertions(+), 75 deletions(-) create mode 100644 tests/unit/recall-archive-service.test.ts create mode 100644 tests/unit/wechat-db-messages.test.ts diff --git a/src/main/export-html-template.ts b/src/main/export-html-template.ts index c753011..d38ead5 100644 --- a/src/main/export-html-template.ts +++ b/src/main/export-html-template.ts @@ -177,12 +177,81 @@ body { width: min(100%, 820px); margin: 0 auto 22px; } +.locate-all { + position: relative; + display: grid; + width: 28px; + height: 28px; + flex: 0 0 auto; + border: 1px solid #b8d4c7; + border-radius: 50%; + padding: 0; + place-items: center; + background: #fff; + color: var(--accent); + font: inherit; + cursor: pointer; + opacity: 0; + pointer-events: none; + box-shadow: 0 3px 10px #29483b1a; + transition: opacity .15s ease, background .15s ease; +} +.locate-icon { font-size: 16px; line-height: 1; } +.locate-label { + position: absolute; + z-index: 3; + top: 50%; + left: calc(100% + 7px); + border: 1px solid #b8d4c7; + border-radius: 7px; + padding: 5px 8px; + background: #fff; + color: var(--accent); + font-size: 11px; + font-weight: 650; + line-height: 1.4; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transform: translate(-2px, -50%); + box-shadow: 0 3px 10px #29483b1a; + transition: opacity .15s ease, transform .15s ease; +} +.message.sent .locate-label { right: calc(100% + 7px); left: auto; transform: translate(2px, -50%); } +.message:hover .locate-all, .locate-all:focus-visible { + opacity: 1; + pointer-events: auto; +} +.locate-all:hover, .locate-all:focus-visible { background: var(--accent-soft); outline: none; } +.locate-all:hover .locate-label, .locate-all:focus-visible .locate-label { + opacity: 1; + transform: translate(0, -50%); +} +.message.located .bubble { animation: locate-message 1.5s ease-out; } +@keyframes locate-message { + 0%, 32% { outline: 3px solid #36a477; outline-offset: 3px; } + 100% { outline: 3px solid transparent; outline-offset: 3px; } +} +@media (prefers-reduced-motion: reduce) { + .message.located .bubble { animation: none; outline: 3px solid #36a477; outline-offset: 3px; } +} .message.sent { align-items: flex-end; } .message.system { align-items: center; } -.message.system .row { justify-content: center; } +.message.system .row { + position: relative; + width: fit-content; + max-width: 92%; + justify-content: center; +} +.message.system .locate-all { + position: absolute; + top: 50%; + left: calc(100% + 10px); + transform: translateY(-50%); +} .message.system .avatar { display: none; } .message.system .bubble { - max-width: 92%; + max-width: 100%; padding: 5px 10px; border: 0; border-radius: 5px; @@ -265,6 +334,95 @@ body { } .quote-reference strong { font-weight: 650; color: var(--text); } .quote-reference span { white-space: pre-wrap; } +.structured-content { + display: grid; + gap: 7px; + width: min(420px, 100%); + min-width: min(240px, 100%); +} +.structured-kicker { + color: var(--accent); + font-size: 11px; + font-weight: 700; +} +.structured-title { + color: var(--text); + font-size: 15px; + font-weight: 700; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} +.structured-description { + color: var(--muted); + font-size: 12px; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; +} +.structured-footer { + display: flex; + gap: 6px; + align-items: center; + padding-top: 6px; + border-top: 1px solid var(--border); + color: var(--muted); + font-size: 11px; +} +.structured-link { color: inherit; text-decoration: none; } +.structured-link:hover .structured-title { color: var(--accent); } +.location-content { + gap: 8px; + padding-top: 8px; + border-top: 3px solid #3f8f76; +} +.location-content .structured-footer { justify-content: space-between; } +.location-coordinates { + color: var(--muted); + font-size: 11px; + font-variant-numeric: tabular-nums; +} +.location-action { color: var(--accent); font-weight: 650; } +.mini-program-content { border-top: 3px solid #4d9d75; padding-top: 8px; } +.mini-program-preview { + display: block; + width: 100%; + max-height: 260px; + border-radius: 8px; + object-fit: cover; + cursor: zoom-in; +} +.mini-program-icon { + width: 20px; + height: 20px; + flex: 0 0 auto; + border-radius: 4px; + object-fit: cover; +} +.forward-content details { margin-top: 2px; } +.forward-content summary { + color: var(--accent); + cursor: pointer; + font-size: 12px; + font-weight: 650; +} +.forward-list, .forward-list ol { + display: grid; + gap: 7px; + margin: 8px 0 0; + padding-left: 18px; +} +.forward-item { color: var(--muted); font-size: 12px; line-height: 1.5; } +.forward-item strong { color: var(--text); font-weight: 650; } +.forward-item time { margin-left: 6px; font-size: 10px; } +.forward-item span { display: block; white-space: pre-wrap; word-break: break-word; } +.payment-content { + gap: 4px; + padding-left: 12px; + border-left: 4px solid #d15246; +} +.payment-content.transfer { border-left-color: var(--accent); } +.payment-content .structured-title { font-size: 16px; } .media-image { display: block; max-width: 100%; @@ -321,7 +479,15 @@ body { .archive-layout { grid-template-columns: 1fr; align-content: start; margin-top: 10px; } .archive-layout.single-conversation { grid-template-columns: 1fr; } .archive-navigation { align-self: start; gap: 8px; } - .timeline { align-self: start; display: flex; gap: 6px; overflow: auto; padding: 8px; } + .timeline { + align-self: stretch; + display: flex; + width: 100%; + max-width: 100%; + gap: 6px; + overflow: auto; + padding: 8px; + } .archive-heading { align-items: flex-start; } .conversation-filter { flex-basis: 100%; width: 100%; } .timeline-year { @@ -394,6 +560,8 @@ const renderExportScript = (name: string): string => ` let windowEnd = 0 let scrollLoadPending = false let scrollLoadSuppressed = false + let activeMonthUpdatePending = false + const tabPositions = new Map() let lastScrollTop = 0 let zoom = 1 @@ -409,6 +577,37 @@ const renderExportScript = (name: string): string => ` /[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character] || character ) + const decodeEntities = (value) => { + const textarea = document.createElement('textarea') + textarea.innerHTML = String(value ?? '') + return textarea.value + } + const displayText = (value) => esc(decodeEntities(value)) + const externalUrl = (value) => { + const decoded = decodeEntities(value).trim() + if (!decoded) return '' + try { + const parsed = new URL(decoded) + return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? esc(decoded) : '' + } catch { + return '' + } + } + const imageUrl = (value) => { + const decoded = decodeEntities(value).trim() + if (decoded.toLowerCase().startsWith('data:image/')) return esc(decoded) + return '' + } + const urlHost = (value) => { + const decoded = decodeEntities(value).trim() + if (!decoded) return '' + try { + const parsed = new URL(decoded) + return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.hostname : '' + } catch { + return '' + } + } const pad = (value) => String(value).padStart(2, '0') const fullTime = (message) => { const timestamp = Number(message.createTime || 0) @@ -426,20 +625,37 @@ const renderExportScript = (name: string): string => ` } const kindOf = (message) => { const data = message.contentData || {} - if (message.exportMediaType === 'file' || (data.type === 'share' && String(data.typeVal) === '6')) return 'file' + const shareType = String(data.typeVal || '') + if ( + message.exportMediaType === 'file' || + (data.type === 'share' && (shareType === '6' || shareType === '74')) + ) return 'file' if ( message.exportMediaType === 'image' || message.exportMediaType === 'video' || message.exportMediaType === 'sticker' || data.type === 'image' || data.type === 'video' || data.type === 'sticker' ) return 'media' if (message.voiceDataUrl || data.type === 'voice' || message.type === '语音') return 'voice' + if ( + data.type === 'system' || data.type === 'unknown' || data.type === 'redPacket' || + data.type === 'voip' || data.type === 'card' || message.from === 'system' || + (data.type === 'share' && shareType === '2000') || + message.type === '微信红包' || message.type === '转账' + ) return 'system' if ( data.type === 'share' || data.type === 'location' || data.type === 'miniProgram' || data.type === 'forwardBundle' ) return 'share' - if (data.type === 'system' || data.type === 'unknown' || message.from === 'system') return 'system' return 'text' } + const forwardedSearchText = (items) => Array.isArray(items) + ? items.flatMap((item) => [ + item.sender, + item.sentAt, + item.text, + forwardedSearchText(item.nested) + ]).flat().filter(Boolean) + : [] const searchText = (message) => [ message.exportConversationName, message.name, @@ -447,11 +663,153 @@ const renderExportScript = (name: string): string => ` message.content, message.type, message.contentData && message.contentData.title, + message.contentData && message.contentData.des, + message.contentData && message.contentData.description, + message.contentData && message.contentData.appname, + message.contentData && message.contentData.appName, message.contentData && message.contentData.quotedSender, message.contentData && message.contentData.quotedContent, + message.contentData && message.contentData.nickname, + message.contentData && message.contentData.username, + message.contentData && message.contentData.poiname, + message.contentData && message.contentData.label, + message.contentData && message.contentData.status, + message.contentData && forwardedSearchText(message.contentData.items), message.exportMediaName ].filter(Boolean).join(' ').toLowerCase() + const shareLabel = (typeVal) => { + if (String(typeVal) === '5') return '公众号链接' + if (String(typeVal) === '51') return '视频号' + return '分享' + } + const renderShareContent = (data) => { + const label = shareLabel(data.typeVal) + let title = decodeEntities(data.title || label) + let description = decodeEntities(data.des || '') + if (String(data.typeVal) === '51' && /当前微信版本不支持展示该内容/.test(title) && description) { + const lines = description.split(/\\n+/).map((line) => line.trim()).filter(Boolean) + title = lines.shift() || label + description = lines.join('\\n') + } + const href = externalUrl(data.url) + const host = urlHost(data.url) + const appName = displayText(data.appname || label) + const titleMarkup = '
' + esc(title) + '
' + return '
' + + '
' + displayText(label) + '
' + + (href + ? '' + titleMarkup + '' + : titleMarkup) + + (description ? '
' + esc(description) + '
' : '') + + '
' + } + const renderMiniProgramContent = (data) => { + const previewUrl = imageUrl(data.thumbDataUrl) + const iconUrl = imageUrl(data.iconUrl) + return '
' + + '
小程序
' + + '
' + displayText(data.title || '小程序') + '
' + + (data.description + ? '
' + displayText(data.description) + '
' + : '') + + (previewUrl + ? '' + : '') + + '
' + } + const renderForwardItems = (items, depth = 0) => { + if (!Array.isArray(items) || !items.length || depth > 4) return '' + return '
    ' + items.map((item) => + '
  1. ' + + (item.sender ? '' + displayText(item.sender) + '' : '') + + (item.sentAt ? '' : '') + + '' + displayText(item.text || '[消息]') + '' + + renderForwardItems(item.nested, depth + 1) + '
  2. ' + ).join('') + '
' + } + const renderForwardContent = (data) => { + const items = Array.isArray(data.items) ? data.items : [] + return '
' + + '
聊天记录
' + + '
' + displayText(data.title || '聊天记录') + '
' + + (!items.length && data.description + ? '
' + displayText(data.description) + '
' + : '') + + (items.length + ? '
展开 ' + items.length + ' 条消息' + renderForwardItems(items) + '
' + : '') + '
' + } + const renderLocationContent = (data) => { + const latitude = Number(data.lat) + const longitude = Number(data.lng) + const hasCoordinates = Number.isFinite(latitude) && Number.isFinite(longitude) && + (latitude !== 0 || longitude !== 0) + const title = data.poiname || data.label || '位置' + const address = data.label && data.label !== data.poiname ? data.label : '' + const coordinates = hasCoordinates + ? latitude.toFixed(6) + ', ' + longitude.toFixed(6) + : '' + const mapUrl = hasCoordinates + ? externalUrl('https://maps.apple.com/?q=' + encodeURIComponent(title) + + '&ll=' + latitude + ',' + longitude) + : '' + const card = '
' + + '
位置
' + + '
' + displayText(title) + '
' + + (address ? '
' + displayText(address) + '
' : '') + + (coordinates + ? '' + : '') + '
' + return mapUrl + ? '' + card + '' + : card + } + const renderPaymentContent = (data, kind) => { + const isTransfer = kind === 'transfer' + return '
' + + '
' + (isTransfer ? '微信转账' : '微信红包') + '
' + + '
' + displayText(data.title || (isTransfer ? '微信转账' : '微信红包')) + '
' + + '
' + + displayText(data.description || data.des || (isTransfer ? '转账消息' : '恭喜发财,大吉大利')) + + '
' + } + const renderVoipContent = (data) => { + const title = Number(data.roomType) === 1 ? '视频通话' : '语音通话' + const duration = Number(data.duration || 0) + const minutes = Math.floor(duration / 60) + const seconds = duration % 60 + const durationText = duration + ? (minutes ? minutes + '分' : '') + seconds + '秒' + : '' + return '
' + + '
通话
' + + '
' + title + '
' + + '
' + + displayText([data.status, durationText].filter(Boolean).join(' · ') || '通话消息') + + '
' + } + const renderStructuredContent = (data) => { + if (data.type === 'share') { + const typeVal = String(data.typeVal || '') + if (typeVal === '6' || typeVal === '74') return '' + if (typeVal === '2000') return renderPaymentContent(data, 'transfer') + return renderShareContent(data) + } + if (data.type === 'miniProgram') return renderMiniProgramContent(data) + if (data.type === 'forwardBundle') return renderForwardContent(data) + if (data.type === 'location') return renderLocationContent(data) + if (data.type === 'redPacket') return renderPaymentContent(data, 'redPacket') + if (data.type === 'voip') return renderVoipContent(data) + return '' + } + const renderMessage = (message, archiveIndex) => { const data = message.contentData || {} const mediaUrl = message.exportMediaUrl ? esc(message.exportMediaUrl) : '' @@ -476,7 +834,8 @@ const renderExportScript = (name: string): string => ` const quote = data.type === 'quote' ? '
' + esc(data.quotedSender || '引用消息') + '' + esc(data.quotedContent || '[引用消息]') + '
' : '' - const isSystem = data.type === 'system' && data.pat + const structured = renderStructuredContent(data) + const isSystem = data.type === 'system' const sender = message.name || (message.isSender ? '我' : '联系人') const avatarFallback = esc(String(sender || '友').slice(0, 1)) const avatar = message.exportShowAvatar === false @@ -485,16 +844,22 @@ const renderExportScript = (name: string): string => ` ? '' : avatarFallback) + '' const text = message.content || (data.type === 'quote' ? data.title : '') - const content = esc(text || (!media && !audio && !quote ? '[' + (message.type || '消息') + ']' : '')) + const content = esc(text || (!media && !audio && !quote && !structured ? '[' + (message.type || '消息') + ']' : '')) + const contentMarkup = content ? '
' + content + '
' : '' const source = conversations.length > 1 && activeConversation === 'all' ? '' + esc(message.exportConversationName || '聊天') + '' : '' + const locateAction = activeKind === 'all' + ? '' + : '' return '
' + '
' + esc(fullTime(message)) + source + '
' + (isSystem ? '' : avatar) + '
' + - (isSystem ? '' : esc(sender)) + '
' + media + audio + quote + - '
' + content + '
' + mediaStatus + '
' + (isSystem ? '' : esc(sender)) + '' + media + audio + quote + structured + + contentMarkup + mediaStatus + '' + locateAction + '' } const renderConversations = () => { @@ -547,11 +912,48 @@ const renderExportScript = (name: string): string => ` timeline.innerHTML = html || '
时间信息不可用
' } + const visibleMessages = () => { + const messages = Array.from(list.querySelectorAll('.message')) + const listBounds = list.getBoundingClientRect() + return messages.filter((message) => { + const bounds = message.getBoundingClientRect() + return bounds.bottom > listBounds.top && bounds.top < listBounds.bottom + }) + } const updateActiveMonth = () => { - const first = list.querySelector('.message') - const key = first && first.dataset.month + const messages = Array.from(list.querySelectorAll('.message')) + const visible = visibleMessages() + const atBottom = list.scrollHeight - list.scrollTop - list.clientHeight <= 2 + const activeMessage = atBottom + ? visible[visible.length - 1] || messages[messages.length - 1] + : visible[0] || messages[0] + const key = activeMessage && activeMessage.dataset.month + let activeButton timeline.querySelectorAll('.timeline-month').forEach((button) => { - button.classList.toggle('active', button.dataset.month === key) + const active = button.dataset.month === key + button.classList.toggle('active', active) + if (active) activeButton = button + }) + if (!activeButton) return + const timelineBounds = timeline.getBoundingClientRect() + const buttonBounds = activeButton.getBoundingClientRect() + if (buttonBounds.top < timelineBounds.top) { + timeline.scrollTop -= timelineBounds.top - buttonBounds.top + } else if (buttonBounds.bottom > timelineBounds.bottom) { + timeline.scrollTop += buttonBounds.bottom - timelineBounds.bottom + } + if (buttonBounds.left < timelineBounds.left) { + timeline.scrollLeft -= timelineBounds.left - buttonBounds.left + } else if (buttonBounds.right > timelineBounds.right) { + timeline.scrollLeft += buttonBounds.right - timelineBounds.right + } + } + const scheduleActiveMonthUpdate = () => { + if (activeMonthUpdatePending) return + activeMonthUpdatePending = true + nextFrame(() => { + updateActiveMonth() + activeMonthUpdatePending = false }) } const updateCount = () => { @@ -568,12 +970,19 @@ const renderExportScript = (name: string): string => ` list.style.scrollBehavior = 'auto' list.scrollTop = value lastScrollTop = list.scrollTop + updateActiveMonth() nextFrame(() => { list.style.scrollBehavior = previousBehavior lastScrollTop = list.scrollTop + updateActiveMonth() scrollLoadSuppressed = false }) } + const scrollTopForTarget = (target, offset) => { + const targetBounds = target.getBoundingClientRect() + const listBounds = list.getBoundingClientRect() + return list.scrollTop + targetBounds.top - listBounds.top - offset + } const renderWindow = (anchorIndex, anchorOffset) => { const visible = filtered.slice(windowStart, windowEnd) const before = windowStart > 0 ? '
向上滚动加载更早消息
' : '' @@ -583,7 +992,7 @@ const renderExportScript = (name: string): string => ` : '
没有符合条件的消息
可以更换筛选条件或关键词
' if (Number.isInteger(anchorIndex)) { const anchor = list.querySelector('.message[data-index="' + anchorIndex + '"]') - if (anchor) setScrollTop(anchor.offsetTop - anchorOffset) + if (anchor) setScrollTop(scrollTopForTarget(anchor, anchorOffset)) } updateCount() updateActiveMonth() @@ -598,15 +1007,81 @@ const renderExportScript = (name: string): string => ` renderWindow() setScrollTop(preferLatest ? list.scrollHeight : 0) } - const applyFilters = () => { + const tabPositionKey = () => [ + activeConversation, + activeKind, + query.value.trim().toLowerCase() + ].join('\u0000') + const rememberTabPosition = () => { + const anchor = visibleMessages()[0] + const index = anchor ? Number(anchor.dataset.index) : -1 + const message = Number.isInteger(index) ? filtered[index] : undefined + if (!anchor || !message) return + tabPositions.set(tabPositionKey(), { + message, + offset: anchor.getBoundingClientRect().top - list.getBoundingClientRect().top + }) + } + const restoreTabPosition = () => { + const position = tabPositions.get(tabPositionKey()) + if (!position) return false + const index = filtered.indexOf(position.message) + if (index < 0) { + tabPositions.delete(tabPositionKey()) + return false + } + windowStart = Math.max(0, index - Math.floor(PAGE_SIZE / 2)) + windowEnd = Math.min(filtered.length, windowStart + PAGE_SIZE) + windowStart = Math.max(0, windowEnd - PAGE_SIZE) + renderWindow() + const target = list.querySelector('.message[data-index="' + index + '"]') + if (!target) return false + setScrollTop(Math.max(0, scrollTopForTarget(target, position.offset))) + return true + } + const matchingMessages = () => { const term = query.value.trim().toLowerCase() - filtered = allMessages.filter((message) => + return allMessages.filter((message) => (activeConversation === 'all' || message.exportConversationId === activeConversation) && (activeKind === 'all' || kindOf(message) === activeKind) && (!term || searchText(message).includes(term)) ) + } + const applyFilters = (restorePosition = false) => { + filtered = matchingMessages() renderTimeline() - resetWindow(true) + if (!restorePosition || !restoreTabPosition()) resetWindow(true) + } + const setActiveKind = (kind) => { + activeKind = kind + filters.querySelectorAll('[data-kind]').forEach((item) => { + item.classList.toggle('active', item.dataset.kind === kind) + }) + } + const locateInAll = (sourceIndex) => { + const targetMessage = filtered[sourceIndex] + if (!targetMessage) return + rememberTabPosition() + setActiveKind('all') + filtered = matchingMessages() + renderTimeline() + const targetIndex = filtered.indexOf(targetMessage) + if (targetIndex < 0) { + resetWindow(true) + return + } + windowStart = Math.max(0, targetIndex - Math.floor(PAGE_SIZE / 2)) + windowEnd = Math.min(filtered.length, windowStart + PAGE_SIZE) + windowStart = Math.max(0, windowEnd - PAGE_SIZE) + renderWindow() + const target = list.querySelector('.message[data-index="' + targetIndex + '"]') + if (!target) return + setScrollTop(Math.max( + 0, + scrollTopForTarget(target, Math.max(24, (list.clientHeight - target.offsetHeight) / 2)) + )) + target.classList.add('located') + window.setTimeout(() => target.classList.remove('located'), 1600) } const jumpToMonth = (key) => { const index = filtered.findIndex((message) => monthKey(message) === key) @@ -616,7 +1091,7 @@ const renderExportScript = (name: string): string => ` windowStart = Math.max(0, windowEnd - PAGE_SIZE) renderWindow() const target = list.querySelector('.message[data-index="' + index + '"]') - setScrollTop(target ? Math.max(0, target.offsetTop - 24) : 0) + setScrollTop(target ? Math.max(0, scrollTopForTarget(target, 24)) : 0) timeline.querySelectorAll('.timeline-month').forEach((button) => { button.classList.toggle('active', button.dataset.month === key) }) @@ -626,7 +1101,9 @@ const renderExportScript = (name: string): string => ` const anchor = direction < 0 ? renderedMessages[0] : renderedMessages[renderedMessages.length - 1] const anchorIndex = anchor ? Number(anchor.dataset.index) : undefined - const anchorOffset = anchor ? anchor.offsetTop - list.scrollTop : 0 + const anchorOffset = anchor + ? anchor.getBoundingClientRect().top - list.getBoundingClientRect().top + : 0 if (direction < 0) { windowStart = Math.max(0, windowStart - WINDOW_STEP) windowEnd = Math.min(filtered.length, windowStart + PAGE_SIZE) @@ -653,6 +1130,7 @@ const renderExportScript = (name: string): string => ` const nearTop = currentTop < 180 const nearBottom = list.scrollHeight - currentTop - list.clientHeight < 240 lastScrollTop = currentTop + scheduleActiveMonthUpdate() if (scrollLoadSuppressed) return if (movingUp && nearTop) scheduleWindowSlide(-1) else if (!movingUp && nearBottom) scheduleWindowSlide(1) @@ -662,17 +1140,18 @@ const renderExportScript = (name: string): string => ` scheduleWindowSlide(-1) } }, { passive: true }) - query.addEventListener('input', applyFilters) + query.addEventListener('input', () => applyFilters()) filters.addEventListener('click', (event) => { const button = event.target.closest('[data-kind]') if (!button) return - activeKind = button.dataset.kind - filters.querySelectorAll('[data-kind]').forEach((item) => item.classList.toggle('active', item === button)) - applyFilters() + rememberTabPosition() + setActiveKind(button.dataset.kind) + applyFilters(true) }) conversationSelect.addEventListener('change', () => { + rememberTabPosition() activeConversation = conversationSelect.value - applyFilters() + applyFilters(true) }) timeline.addEventListener('click', (event) => { const button = event.target.closest('[data-month]') @@ -686,6 +1165,11 @@ const renderExportScript = (name: string): string => ` updateZoom() } list.addEventListener('click', (event) => { + const locateButton = event.target.closest('[data-locate-index]') + if (locateButton) { + locateInAll(Number(locateButton.dataset.locateIndex)) + return + } const image = event.target.closest('img[data-preview]') if (!image) return preview.src = image.src diff --git a/src/main/export-service.ts b/src/main/export-service.ts index 7b39afb..1b0fbaf 100644 --- a/src/main/export-service.ts +++ b/src/main/export-service.ts @@ -1,6 +1,6 @@ import { app, BrowserWindow, shell } from 'electron' import { createHash } from 'crypto' -import { createWriteStream, promises as fs } from 'fs' +import { createReadStream, createWriteStream, promises as fs } from 'fs' import { extname, join } from 'path' import { fileURLToPath } from 'url' import { ZipArchive, type Archiver } from 'archiver' @@ -67,9 +67,75 @@ export interface HtmlExportArchive { messages: Message[] } +const htmlArchiveResourcePrefixes = { + voices: ['voice_'], + media: ['image_', 'video_', 'sticker_', 'file_'], + files: ['file_'], + avatars: ['avatar_', 'conversation_'] +} as const +type HtmlArchiveResourceDirectory = keyof typeof htmlArchiveResourcePrefixes + const archiveDataPrefix = 'window.__WECHAT_EXPORT__ = ' const hashPart = (value: string, length = 16): string => createHash('sha1').update(value).digest('hex').slice(0, length) +const bufferHashPart = (buffer: Buffer, length = 16): string => + createHash('sha1').update(buffer).digest('hex').slice(0, length) +const fileHashPart = async (filePath: string, length = 16): Promise => { + const hash = createHash('sha1') + await new Promise((resolve, reject) => { + const input = createReadStream(filePath) + input.on('data', (chunk) => hash.update(chunk)) + input.once('end', resolve) + input.once('error', reject) + }) + return hash.digest('hex').slice(0, length) +} +const avatarFileName = (buffer: Buffer, extension: string): string => + `avatar_${bufferHashPart(buffer)}.${extension}` + +const htmlArchiveSourceSignature = (message: Message): string => + JSON.stringify([ + message.type, + message.content, + message.contentData || null, + message.localId || null, + message.serverId || null, + message.createTime || null, + message.sessionId || null, + message.senderId || null, + message.isSender, + message.recalled || false, + message.recalledBy || null + ]) + +const htmlArchiveResourcePath = (outputDir: string, value?: string): string | null => { + if (!value) return null + const normalized = value.replace(/\\/g, '/').split(/[?#]/, 1)[0] + const [directory, fileName, ...rest] = normalized.split('/') + if ( + !directory || + !fileName || + rest.length > 0 || + !Object.prototype.hasOwnProperty.call(htmlArchiveResourcePrefixes, directory) + ) { + return null + } + return join(outputDir, directory, fileName) +} + +const htmlArchiveResourceExists = async (outputDir: string, value?: string): Promise => { + const filePath = htmlArchiveResourcePath(outputDir, value) + if (!filePath) return false + try { + const stat = await fs.stat(filePath) + if (!stat.isFile()) return false + if ((stat.mode & 0o200) === 0) await fs.chmod(filePath, 0o644) + return true + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false + throw error + } +} export const exportMessageKey = (message: Message, sourceId = ''): string => { const conversationId = message.exportConversationId || sourceId @@ -159,6 +225,15 @@ export function normalizeHtmlArchiveSelfNames( }) } +export function stripHtmlArchiveInlineAvatars(messages: Message[]): Message[] { + return messages.map((message) => { + if (message.img == null) return message + const archiveMessage = { ...message } + delete archiveMessage.img + return archiveMessage + }) +} + export async function readHtmlArchive( outputDir: string, targets: ExportTarget[], @@ -315,6 +390,54 @@ export async function writeHtmlArchive( } } +export async function pruneHtmlArchiveResources( + outputDir: string, + archive: HtmlExportArchive +): Promise { + const referenced = Object.fromEntries( + Object.keys(htmlArchiveResourcePrefixes).map((directory) => [directory, new Set()]) + ) as Record> + const addReference = (value?: string): void => { + if (!value) return + const path = value.replace(/\\/g, '/').split(/[?#]/, 1)[0] + const separator = path.indexOf('/') + if (separator <= 0 || path.indexOf('/', separator + 1) >= 0) return + const directory = path.slice(0, separator) as HtmlArchiveResourceDirectory + const fileName = path.slice(separator + 1) + if (!referenced[directory] || !fileName) return + referenced[directory].add(fileName) + } + + for (const conversation of archive.conversations) addReference(conversation.avatarUrl) + for (const message of archive.messages) { + addReference(message.voiceDataUrl) + addReference(message.exportMediaUrl) + addReference(message.exportAvatarUrl) + } + + for (const [directory, prefixes] of Object.entries(htmlArchiveResourcePrefixes) as [ + HtmlArchiveResourceDirectory, + readonly string[] + ][]) { + let entries + try { + entries = await fs.readdir(join(outputDir, directory), { withFileTypes: true }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue + throw error + } + for (const entry of entries) { + if ( + entry.isFile() && + prefixes.some((prefix) => entry.name.startsWith(prefix)) && + !referenced[directory].has(entry.name) + ) { + await fs.unlink(join(outputDir, directory, entry.name)) + } + } + } +} + const keepMediaError = (request: ExportRequest, message: Message, error: string): void => { if (request.keepMissing !== false) message.exportMediaError = error } @@ -444,7 +567,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro return { success: false, error: '已取消' } } const targetMessages = ( - await chat.listMessagesAsync(target.userMd5, request.startTime, request.endTime) + await chat.listMessagesForExport(target.userMd5, request.startTime, request.endTime) ).filter((message) => request.kinds.includes(kindOf(message))) for (const [messageOrder, message] of targetMessages.entries()) { messageEntries.push({ @@ -475,6 +598,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro .map((entry) => entry.message) const rawSelfInfo = await chat.getSelfAccountInfoAsync() const selfInfo = rawSelfInfo ? mergeCachedSelfInfo(rawSelfInfo.accountRoot, rawSelfInfo) : null + const client = chat.getChatDb()?.getWcdb4Client() const isUsableSelfName = (value: string | undefined): value is string => { const name = String(value || '').trim() if (!name) return false @@ -484,14 +608,30 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro } for (const message of messages) { const target = targetById.get(message.exportConversationId || '') + const peerUsername = target ? client?.getUsernameByMd5(target.userMd5) || '' : '' + const isGroupChat = target?.type === 'group' || peerUsername.endsWith('@chatroom') message.exportMediaUrl = undefined message.exportMediaType = undefined message.exportMediaName = undefined message.exportMediaError = undefined message.voiceDataUrl = undefined message.exportShowAvatar = request.includeAvatars !== false + if (!isGroupChat && !message.senderId) { + message.senderId = message.isSender ? selfInfo?.wxid : peerUsername || message.sessionId + } const mappedName = message.senderId ? target?.nameMap?.[message.senderId] : undefined - if (mappedName && (!message.isSender || isUsableSelfName(mappedName))) { + if (!isGroupChat) { + message.name = message.isSender + ? isUsableSelfName(selfInfo?.nickname) + ? selfInfo.nickname + : isUsableSelfName(message.name) + ? message.name + : '我' + : target?.name || message.name || '联系人' + if (!message.img) { + message.img = message.isSender ? selfInfo?.avatar : target?.avatarUrl + } + } else if (mappedName && (!message.isSender || isUsableSelfName(mappedName))) { message.name = mappedName } else if (message.isSender && isUsableSelfName(selfInfo?.nickname)) { message.name = selfInfo.nickname @@ -529,10 +669,35 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro : join(root, `${outputFolder}.${ext}`) if (request.format === 'html') { const previousArchive = await readHtmlArchive(outputDir, targets, archiveName) + const previousMessagesByKey = new Map( + previousArchive.messages.map((message) => [exportMessageKey(message), message]) + ) + const reusablePreviousMessages = new Map() + for (const message of messages) { + const previous = previousMessagesByKey.get(exportMessageKey(message)) + if ( + previous && + htmlArchiveSourceSignature(previous) === htmlArchiveSourceSignature(message) + ) { + reusablePreviousMessages.set(message, previous) + } + } + const resourceExistence = new Map>() + const resourceExists = (value?: string): Promise => { + if (!value) return Promise.resolve(false) + const existing = resourceExistence.get(value) + if (existing) return existing + const pending = htmlArchiveResourceExists(outputDir, value) + resourceExistence.set(value, pending) + return pending + } + const markResourceExists = (value: string): void => { + resourceExistence.set(value, Promise.resolve(true)) + } await fs.mkdir(join(outputDir, 'voices'), { recursive: true }) await fs.mkdir(join(outputDir, 'media'), { recursive: true }) + await fs.mkdir(join(outputDir, 'files'), { recursive: true }) await fs.mkdir(join(outputDir, 'avatars'), { recursive: true }) - const client = chat.getChatDb()?.getWcdb4Client() const avatarUsernames = Array.from( new Set( messages @@ -564,9 +729,13 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro if (!target.avatarUrl) continue const resolved = await readAvatarAsset(target.avatarUrl) if (!resolved) continue - const avatarName = `conversation_${hashPart(target.userMd5)}.${resolved.extension}` - await fs.writeFile(join(outputDir, 'avatars', avatarName), resolved.buffer) - conversationAvatarUrls.set(target.userMd5, `avatars/${avatarName}`) + const avatarName = avatarFileName(resolved.buffer, resolved.extension) + const avatarUrl = `avatars/${avatarName}` + if (!(await resourceExists(avatarUrl))) { + await fs.writeFile(join(outputDir, 'avatars', avatarName), resolved.buffer) + markResourceExists(avatarUrl) + } + conversationAvatarUrls.set(target.userMd5, avatarUrl) } } const voiceService = @@ -577,7 +746,12 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro for (const message of messages) { if (!jobs.has(request.jobId)) throw new Error('已取消') if (kindOf(message) !== 'voice') continue - const conversationId = message.exportConversationId || targets[0].userMd5 + const previous = reusablePreviousMessages.get(message) + if (previous?.voiceDataUrl && (await resourceExists(previous.voiceDataUrl))) { + message.voiceDataUrl = previous.voiceDataUrl + message.voiceDuration = previous.voiceDuration + continue + } if (!message.sessionId || message.localId == null || !message.createTime) { keepMediaError(request, message, '语音标识不完整,无法定位本地语音') continue @@ -599,10 +773,14 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro keepMediaError(request, message, reason) continue } - const voiceName = `voice_${hashPart(exportMessageKey(message, conversationId))}.wav` const audioBuffer = Buffer.from(voice.data, 'base64') - await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer) - message.voiceDataUrl = `voices/${voiceName}` + const voiceName = `voice_${bufferHashPart(audioBuffer)}.wav` + const voiceUrl = `voices/${voiceName}` + if (!(await resourceExists(voiceUrl))) { + await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer) + markResourceExists(voiceUrl) + } + message.voiceDataUrl = voiceUrl message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2))) } catch (error) { keepMediaError( @@ -632,20 +810,26 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro } const conversationId = message.exportConversationId || targets[0].userMd5 message.exportShowAvatar = request.includeAvatars !== false + const previous = reusablePreviousMessages.get(message) const avatar = (message.senderId ? avatarMap[message.senderId] : undefined) || message.img - const resolvedAvatar = avatar ? await readAvatarAsset(avatar) : null - const avatarBuffer = resolvedAvatar?.buffer || null - const avatarExtension = resolvedAvatar?.extension || 'jpg' - if (avatarBuffer) { - const avatarKey = `${conversationId}:${ - message.senderId || avatar || `message_${exportMessageKey(message, conversationId)}` - }` - let avatarName = exportedAvatars.get(avatarKey) - if (!avatarName) { - avatarName = `avatar_${hashPart(avatarKey)}.${avatarExtension}` - await fs.writeFile(join(outputDir, 'avatars', avatarName), avatarBuffer) + const avatarKey = `${conversationId}:${ + message.senderId || avatar || `message_${exportMessageKey(message, conversationId)}` + }` + let avatarName = exportedAvatars.get(avatarKey) + if (!avatarName && avatar) { + const resolvedAvatar = await readAvatarAsset(avatar) + if (resolvedAvatar?.buffer) { + const avatarExtension = resolvedAvatar.extension || 'jpg' + avatarName = avatarFileName(resolvedAvatar.buffer, avatarExtension) + const avatarUrl = `avatars/${avatarName}` + if (!(await resourceExists(avatarUrl))) { + await fs.writeFile(join(outputDir, 'avatars', avatarName), resolvedAvatar.buffer) + markResourceExists(avatarUrl) + } exportedAvatars.set(avatarKey, avatarName) } + } + if (avatarName) { message.exportAvatarUrl = `avatars/${avatarName}` } if (!request.includeMedia || !message.contentData) { @@ -658,6 +842,32 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro }) continue } + const reusableMediaType = + message.contentData.type === 'image' || + message.contentData.type === 'video' || + message.contentData.type === 'sticker' + ? message.contentData.type + : message.contentData.type === 'share' && message.contentData.typeVal === '6' + ? 'file' + : null + if ( + reusableMediaType && + previous?.exportMediaUrl && + (!previous.exportMediaType || previous.exportMediaType === reusableMediaType) && + (await resourceExists(previous.exportMediaUrl)) + ) { + message.exportMediaUrl = previous.exportMediaUrl + message.exportMediaType = reusableMediaType + message.exportMediaName = previous.exportMediaName + send({ + jobId: request.jobId, + phase: 'writing', + processed: index + 1, + total: messages.length, + percent: 15 + Math.round(((index + 1) / Math.max(messages.length, 1)) * 75) + }) + continue + } if (message.contentData.type === 'image') { if (!imageService) { keepMediaError(request, message, '未配置图片解密密钥,无法导出图片') @@ -698,9 +908,13 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro } const decoded = decryptedImage ? decodeDataUrl(decryptedImage.data) : null if (decoded) { - const name = `image_${hashPart(exportMessageKey(message, conversationId))}.${decoded.extension}` - await fs.writeFile(join(outputDir, 'media', name), decoded.buffer) - message.exportMediaUrl = `media/${name}` + const name = `image_${bufferHashPart(decoded.buffer)}.${decoded.extension}` + const mediaUrl = `media/${name}` + if (!(await resourceExists(mediaUrl))) { + await fs.writeFile(join(outputDir, 'media', name), decoded.buffer) + markResourceExists(mediaUrl) + } + message.exportMediaUrl = mediaUrl message.exportMediaType = 'image' if (usedFallback) { keepMediaError(request, message, '原图不可用,已降级使用缩略图') @@ -740,9 +954,13 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro } else if (extname(source).toLowerCase() !== '.mp4') { keepMediaError(request, message, '视频格式不支持,仅支持本地 MP4 文件') } else { - const name = `video_${hashPart(exportMessageKey(message, conversationId))}.mp4` - await copyWritableExportFile(source, join(outputDir, 'media', name)) - message.exportMediaUrl = `media/${name}` + const name = `video_${await fileHashPart(source)}.mp4` + const mediaUrl = `media/${name}` + if (!(await resourceExists(mediaUrl))) { + await copyWritableExportFile(source, join(outputDir, 'media', name)) + markResourceExists(mediaUrl) + } + message.exportMediaUrl = mediaUrl message.exportMediaType = 'video' } } @@ -755,9 +973,13 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro ? await readAvatarAsset(stickerSource) : null if (decoded) { - const name = `sticker_${hashPart(exportMessageKey(message, conversationId))}.${decoded.extension}` - await fs.writeFile(join(outputDir, 'media', name), decoded.buffer) - message.exportMediaUrl = `media/${name}` + const name = `sticker_${bufferHashPart(decoded.buffer)}.${decoded.extension}` + const mediaUrl = `media/${name}` + if (!(await resourceExists(mediaUrl))) { + await fs.writeFile(join(outputDir, 'media', name), decoded.buffer) + markResourceExists(mediaUrl) + } + message.exportMediaUrl = mediaUrl message.exportMediaType = 'sticker' } else { keepMediaError(request, message, result.error || '表情资源缺失或下载失败') @@ -770,9 +992,13 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro if (!resolved.success || !resolved.filePath || !resolved.fileName) { keepMediaError(request, message, resolved.error || '本地文件附件缺失') } else { - const name = `file_${hashPart(exportMessageKey(message, conversationId))}_${safeFilePart(resolved.fileName)}` - await copyWritableExportFile(resolved.filePath, join(outputDir, 'media', name)) - message.exportMediaUrl = `media/${name}` + const name = `file_${await fileHashPart(resolved.filePath)}_${safeFilePart(resolved.fileName)}` + const mediaUrl = `files/${name}` + if (!(await resourceExists(mediaUrl))) { + await copyWritableExportFile(resolved.filePath, join(outputDir, 'files', name)) + markResourceExists(mediaUrl) + } + message.exportMediaUrl = mediaUrl message.exportMediaType = 'file' message.exportMediaName = message.contentData.title || resolved.fileName } @@ -792,7 +1018,9 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro '', targets.map((target) => target.userMd5) ) - const normalizedMessages = normalizeHtmlArchiveSelfNames(mergedMessages, selfInfo) + const normalizedMessages = stripHtmlArchiveInlineAvatars( + normalizeHtmlArchiveSelfNames(mergedMessages, selfInfo) + ) const archive: HtmlExportArchive = { version: 2, name: archiveName, @@ -813,6 +1041,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro if (!jobs.has(request.jobId)) throw new Error('已取消') await fs.writeFile(outputPath, renderExportPage(archiveName), 'utf8') await writeHtmlArchive(outputDir, archive) + await pruneHtmlArchiveResources(outputDir, archive) let completedPath = outputPath if (request.zip) { if (!jobs.has(request.jobId)) return { success: false, error: '已取消' } diff --git a/src/main/services/chat-service.ts b/src/main/services/chat-service.ts index 67da5ec..f546142 100644 --- a/src/main/services/chat-service.ts +++ b/src/main/services/chat-service.ts @@ -447,6 +447,23 @@ export async function listMessagesAsync( return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit) } +export async function listMessagesForExport( + userMd5: string, + startTime?: number, + endTime?: number +): Promise { + if (!dbRef) return [] + const rawMessages = await dbRef.getUserMessagesForExport(userMd5, startTime, endTime) + const sourceMessages = listSourceMessages(userMd5, startTime, endTime, undefined, rawMessages) + const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || '' + recordRecallArchiveMessages(userMd5, username, sourceMessages) + const mergedMessages = mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime) + console.log( + `[ChatService] listMessagesForExport end md5=${userMd5} source=${sourceMessages.length} merged=${mergedMessages.length}` + ) + return mergedMessages +} + export function getGroupSnapshot(userMd5: string): GroupSnapshot | null { if (!dbRef) return null const wcdb4Client = dbRef.getWcdb4Client() diff --git a/src/main/services/recall-archive-service.ts b/src/main/services/recall-archive-service.ts index eb013be..8e0e702 100644 --- a/src/main/services/recall-archive-service.ts +++ b/src/main/services/recall-archive-service.ts @@ -40,12 +40,13 @@ let archivePath = '' let writeTimer: NodeJS.Timeout | null = null let writeQueue: Promise = Promise.resolve() -function messageIdentity(message: Message): string { +export function messageIdentity(message: Message): string { if (message.recoveredFromRecallJournal) { return `recovered:${message.localId || 0}:${message.serverId || message.id}` } - if (message.localId) return `local:${message.localId}` - if (message.serverId) return `server:${message.serverId}` + const serverId = String(message.serverId || '').trim() + if (serverId && serverId !== '0') return `server:${serverId}` + if (message.localId) return `local:${message.localId}:${message.createTime || 0}` if (message.id) return `id:${message.id}` return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}` } diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts index 29576b8..5a47359 100644 --- a/src/main/wcdb4-client.ts +++ b/src/main/wcdb4-client.ts @@ -1286,11 +1286,13 @@ export class Wcdb4Client { const merged = new Map() for (const message of [...recovered, ...current]) { const recoveredRow = Boolean(message.raw?.['_wxe_recovered']) - const identity = message.mesLocalID - ? `local:${message.mesLocalID}` - : message.serverId - ? `server:${message.serverId}` - : `${message.msgCreateTime}:${message.msgContent}` + const serverId = String(message.serverId || '').trim() + const identity = + serverId && serverId !== '0' + ? `server:${serverId}` + : message.mesLocalID + ? `local:${message.mesLocalID}:${message.msgCreateTime || 0}` + : `${message.msgCreateTime}:${message.msgContent}` const key = recoveredRow ? `recovered:${identity}` : identity merged.set(key, message) } diff --git a/src/main/wechat-db.ts b/src/main/wechat-db.ts index bde5a9b..31afaf7 100644 --- a/src/main/wechat-db.ts +++ b/src/main/wechat-db.ts @@ -34,6 +34,24 @@ export interface GroupMemberInfo { m_nsHeadImgUrl: string } +function mergeExportMessages(messages: WechatMessage[]): WechatMessage[] { + const merged = new Map() + for (const message of messages) { + const serverId = String(message.serverId || '') + const recovered = Boolean(message.raw?.['_wxe_recovered'] || message['_wxe_recovered']) + const identity = + serverId && serverId !== '0' + ? `server:${serverId}` + : `local:${message.mesLocalID}:${message.msgCreateTime}` + merged.set(recovered ? `recovered:${identity}` : identity, message) + } + return Array.from(merged.values()).sort( + (left, right) => + Number(left.msgCreateTime || 0) - Number(right.msgCreateTime || 0) || + Number(left.mesLocalID || 0) - Number(right.mesLocalID || 0) + ) +} + export class WechatDb { private wcdb4Client: Wcdb4Client private chatMd5ToUsername = new Map() @@ -170,8 +188,8 @@ export class WechatDb { const username = this.chatMd5ToUsername.get(userMd5) if (!username) return [] return this.wcdb4Client.getMessages(username, startTime, endTime, options).map((message) => ({ - ...message, - ...message.raw + ...message.raw, + ...message })) } @@ -185,7 +203,34 @@ export class WechatDb { const username = this.chatMd5ToUsername.get(userMd5) if (!username) return [] const messages = await this.wcdb4Client.getMessagesAsync(username, startTime, endTime, options) - return messages.map((message) => ({ ...message, ...message.raw })) + return messages.map((message) => ({ ...message.raw, ...message })) + } + + public async getUserMessagesForExport( + userMd5: string, + startTime?: number, + endTime?: number + ): Promise { + this.ensureChatTableMapping() + const username = this.chatMd5ToUsername.get(userMd5) + if (!username) return [] + if (startTime && endTime && startTime > endTime) return [] + + // A bounded native cursor can omit rows stored across a message shard boundary. + // Scan without bounds first, then apply the requested range in application code. + const rows = await this.wcdb4Client.getMessagesAsync(username) + const messages = rows + .map((message) => ({ ...message.raw, ...message })) + .filter((message) => { + const createTime = Number(message.msgCreateTime || 0) + if (startTime && createTime < startTime) return false + if (endTime && createTime > endTime) return false + return true + }) + console.log( + `[WechatDb] export scan username=${username} raw=${rows.length} filtered=${messages.length} start=${startTime || 0} end=${endTime || 0}` + ) + return mergeExportMessages(messages) } public searchAllMessages(keyword: string): string | null { diff --git a/tests/e2e/export-archive.spec.ts b/tests/e2e/export-archive.spec.ts index 6b1e679..af116d5 100644 --- a/tests/e2e/export-archive.spec.ts +++ b/tests/e2e/export-archive.spec.ts @@ -168,3 +168,360 @@ test('EXPORT-ARCHIVE-02 legacy single-chat archive keeps its original layout', a rmSync(outputDir, { recursive: true, force: true }) } }) + +test('EXPORT-ARCHIVE-04 timeline follows the latest visible month after changing tabs', async ({ + page +}) => { + const outputDir = mkdtempSync(join(tmpdir(), 'wxe-timeline-sync-e2e-')) + try { + const dataPath = join(outputDir, 'data', 'messages.js') + mkdirSync(dirname(dataPath), { recursive: true }) + writeFileSync(join(outputDir, 'index.html'), renderExportPage('时间轴同步档案'), 'utf8') + const oldVoiceMessages = Array.from({ length: 240 }, (_, index) => ({ + ...archiveMessage( + `old-voice-${index}`, + 'timeline', + '时间轴同步档案', + `旧语音-${index}`, + Date.UTC(2006 + Math.floor(index / 12), index % 12, 1) / 1000 + ), + type: '语音' + })) + writeFileSync( + dataPath, + `window.__WECHAT_EXPORT__ = ${JSON.stringify({ + version: 1, + sourceId: 'timeline', + name: '时间轴同步档案', + exportedAt: '2026-08-04T00:00:00.000Z', + messages: [ + ...oldVoiceMessages, + { + ...archiveMessage( + 'latest-voice', + 'timeline', + '时间轴同步档案', + '最新语音', + 1_775_520_000 + ), + type: '语音' + } + ] + })};\n`, + 'utf8' + ) + + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto(pathToFileURL(join(outputDir, 'index.html')).href) + await page.getByRole('button', { name: '语音', exact: true }).click() + + const messages = page.locator('#messages') + const activeMonth = page.locator('.timeline-month.active') + await expect(activeMonth).toHaveAttribute('data-month', '2026-04') + expect( + await messages.evaluate( + (element) => element.scrollHeight - element.scrollTop - element.clientHeight + ) + ).toBeLessThanOrEqual(2) + const timelinePosition = await activeMonth.evaluate((element) => { + const button = element.getBoundingClientRect() + const timeline = element.parentElement!.getBoundingClientRect() + return { + buttonTop: button.top, + buttonBottom: button.bottom, + timelineTop: timeline.top, + timelineBottom: timeline.bottom + } + }) + expect(timelinePosition.buttonTop).toBeGreaterThanOrEqual(timelinePosition.timelineTop) + expect(timelinePosition.buttonBottom).toBeLessThanOrEqual(timelinePosition.timelineBottom + 1) + + await messages.evaluate((element) => { + element.scrollTop = 0 + }) + await expect(activeMonth).toHaveAttribute('data-month', '2006-01') + } finally { + rmSync(outputDir, { recursive: true, force: true }) + } +}) + +test('EXPORT-ARCHIVE-05 each message tab restores its previous scroll anchor', async ({ page }) => { + const outputDir = mkdtempSync(join(tmpdir(), 'wxe-tab-position-e2e-')) + try { + const dataPath = join(outputDir, 'data', 'messages.js') + mkdirSync(dirname(dataPath), { recursive: true }) + writeFileSync(join(outputDir, 'index.html'), renderExportPage('Tab 位置档案'), 'utf8') + const messages = Array.from({ length: 600 }, (_, index) => ({ + ...archiveMessage( + `message-${index}`, + 'tab-position', + 'Tab 位置档案', + `${index % 2 === 0 ? '文字' : '语音'}消息-${index}`, + 1_735_689_600 + index * 86_400 + ), + type: index % 2 === 0 ? '普通文本' : '语音' + })) + writeFileSync( + dataPath, + `window.__WECHAT_EXPORT__ = ${JSON.stringify({ + version: 1, + sourceId: 'tab-position', + name: 'Tab 位置档案', + exportedAt: '2026-08-04T00:00:00.000Z', + messages + })};\n`, + 'utf8' + ) + + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto(pathToFileURL(join(outputDir, 'index.html')).href) + await page.getByRole('button', { name: '文字', exact: true }).click() + + const messageList = page.locator('#messages') + const target = page.locator('.message[data-index="100"]') + await target.evaluate((element) => { + const list = element.parentElement! + list.scrollTop += element.getBoundingClientRect().top - list.getBoundingClientRect().top - 37 + }) + await expect(target).toBeInViewport() + const before = await target.evaluate((element) => { + const message = element.getBoundingClientRect() + const list = element.parentElement!.getBoundingClientRect() + return message.top - list.top + }) + + await page.getByRole('button', { name: '语音', exact: true }).click() + await page.getByRole('button', { name: '文字', exact: true }).click() + + await expect(target).toBeInViewport() + const after = await target.evaluate((element) => { + const message = element.getBoundingClientRect() + const list = element.parentElement!.getBoundingClientRect() + return message.top - list.top + }) + expect(Math.abs(after - before)).toBeLessThanOrEqual(1) + expect( + await messageList.evaluate( + (element) => element.scrollHeight - element.scrollTop - element.clientHeight + ) + ).toBeGreaterThan(100) + } finally { + rmSync(outputDir, { recursive: true, force: true }) + } +}) + +test('EXPORT-ARCHIVE-03 renders shares and locations, and groups payments under system', async ({ + page +}, testInfo) => { + const outputDir = mkdtempSync(join(tmpdir(), 'wxe-structured-archive-e2e-')) + try { + const dataPath = join(outputDir, 'data', 'messages.js') + mkdirSync(dirname(dataPath), { recursive: true }) + writeFileSync(join(outputDir, 'index.html'), renderExportPage('结构化消息档案'), 'utf8') + const message = ( + id: string, + type: string, + createTime: number, + contentData: Message['contentData'] + ): Message => ({ + ...archiveMessage(id, 'structured', '结构化消息档案', '', createTime), + type, + contentData + }) + writeFileSync( + dataPath, + `window.__WECHAT_EXPORT__ = ${JSON.stringify({ + version: 1, + sourceId: 'structured', + name: '结构化消息档案', + exportedAt: '2026-08-04T12:57:32.000Z', + messages: [ + message('article', '公众号链接', 1_775_000_001, { + type: 'share', + typeVal: '5', + title: '真正的公众号标题', + des: '文章摘要与关键内容', + appname: '示例公众号', + url: 'https://example.com/article?a=1&b=2' + }), + message('mini', '小程序', 1_775_000_002, { + type: 'miniProgram', + title: '小程序商品标题', + description: '商品的真实描述', + appName: '示例小程序' + }), + message('channel', '视频号', 1_775_000_003, { + type: 'share', + typeVal: '51', + title: '当前微信版本不支持展示该内容,请升级至最新版本。', + des: '视频号真实标题\n视频号正文内容', + url: 'https://example.com/channel' + }), + message('forward', '合并转发', 1_775_000_004, { + type: 'forwardBundle', + title: '项目群的聊天记录', + description: '项目成员: 项目结论', + items: [ + { + messageType: 1, + sender: '项目成员', + sentAt: '2026-08-04 20:00', + text: '项目结论已经确认' + } + ] + }), + message('red-packet', '微信红包', 1_775_000_005, { + type: 'redPacket', + title: '微信红包', + description: '我给你发了一个红包' + }), + message('transfer', '转账', 1_775_000_006, { + type: 'share', + typeVal: '2000', + title: '微信转账', + des: '收到转账¥1000.00元', + url: '' + }), + message('voip', '通话', 1_775_000_007, { + type: 'voip', + status: '通话时长 2分15秒' + }), + message('location', '位置', 1_775_000_008, { + type: 'location', + poiname: '望和公园南园', + label: '北京市朝阳区望京街道北四环东路41号望和公园', + lat: 39.986984, + lng: 116.448578 + }), + { + ...message('legacy-recall', '系统消息', 1_775_000_009, { + type: 'system', + content: '"联系人" 撤回了一条消息' + }), + from: 'system', + content: '"联系人" 撤回了一条消息', + name: '' + }, + { + ...message('structured-recall', '系统消息', 1_775_000_010, { + type: 'system', + content: '你撤回了一条消息', + recall: { + targetId: 'fixture-target', + replacement: '你撤回了一条消息', + actor: '你' + } + }), + from: 'system', + content: '你撤回了一条消息', + name: '' + }, + { + ...message('location-sharing-ended', '系统消息', 1_775_000_011, { + type: 'system', + content: '位置共享已经结束' + }), + from: 'system', + content: '位置共享已经结束', + name: '' + } + ] + })};\n`, + 'utf8' + ) + + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto(pathToFileURL(join(outputDir, 'index.html')).href) + await expect(page.locator('[data-rich-kind="share"]')).toHaveCount(2) + await expect(page.getByText('真正的公众号标题')).toBeVisible() + await expect(page.getByText('文章摘要与关键内容')).toBeVisible() + await expect(page.getByText('小程序商品标题')).toBeVisible() + await expect(page.getByText('视频号真实标题')).toBeVisible() + await expect(page.getByText('当前微信版本不支持展示该内容,请升级至最新版本。')).toHaveCount(0) + await expect(page.getByText('项目群的聊天记录')).toBeVisible() + await page.getByText('展开 1 条消息').click() + await expect(page.getByText('项目结论已经确认')).toBeVisible() + + await page.getByRole('button', { name: '分享', exact: true }).click() + await expect(page.locator('.message')).toHaveCount(5) + await expect(page.locator('[data-rich-kind="forwardBundle"]')).toHaveCount(1) + const locationCard = page.locator('[data-rich-kind="location"]') + await expect(locationCard).toHaveCount(1) + await expect(locationCard.getByText('望和公园南园')).toBeVisible() + await expect(locationCard.getByText('北京市朝阳区望京街道北四环东路41号望和公园')).toBeVisible() + await expect(locationCard.getByText('39.986984, 116.448578')).toBeVisible() + await expect(locationCard.getByText('在地图中打开')).toBeVisible() + await expect(locationCard.locator('xpath=ancestor::a')).toHaveAttribute( + 'href', + /^https:\/\/maps\.apple\.com\/\?q=.*&ll=39\.986984,116\.448578$/ + ) + await expect(page.locator('.content', { hasText: '[位置]' })).toHaveCount(0) + await expect(page.locator('[data-rich-kind="transfer"]')).toHaveCount(0) + const locationMessage = locationCard.locator('xpath=ancestor::article') + const locateButton = locationMessage.getByRole('button', { name: '定位到聊天位置' }) + const locateLabel = locateButton.locator('.locate-label') + await page.mouse.move(0, 0) + await expect(locateButton).toHaveCSS('opacity', '0') + await locationMessage.hover() + await expect(locateButton).toHaveCSS('opacity', '1') + await expect(locateLabel).toHaveCSS('opacity', '0') + await locateButton.hover() + await expect(locateLabel).toHaveCSS('opacity', '1') + await page.screenshot({ path: testInfo.outputPath('locate-hover-1440.png') }) + await locateButton.click() + await expect(page.getByRole('button', { name: '全部', exact: true })).toHaveClass(/active/) + await expect(page.locator('.message.located')).toContainText('望和公园南园') + const locatedPosition = await page.locator('.message.located').evaluate((element) => { + const messageRect = element.getBoundingClientRect() + const listRect = element.parentElement!.getBoundingClientRect() + return { + messageTop: messageRect.top, + messageBottom: messageRect.bottom, + listTop: listRect.top, + listBottom: listRect.bottom + } + }) + expect(locatedPosition.messageBottom).toBeGreaterThan(locatedPosition.listTop) + expect(locatedPosition.messageTop).toBeLessThan(locatedPosition.listBottom) + await page.locator('#messages').evaluate((element) => { + element.scrollTop = 0 + }) + await page.screenshot({ + path: testInfo.outputPath('structured-archive-1440.png'), + fullPage: true + }) + + await page.getByRole('button', { name: '系统 / 其他', exact: true }).click() + await expect(page.locator('.message')).toHaveCount(6) + await expect(page.getByText('我给你发了一个红包')).toBeVisible() + await expect(page.getByText('收到转账¥1000.00元')).toBeVisible() + await expect(page.getByText('通话时长 2分15秒')).toBeVisible() + const systemNotices = page.locator('.message.system') + await expect(systemNotices).toHaveCount(3) + const recallNotices = systemNotices.filter({ hasText: '撤回了一条消息' }) + await expect(recallNotices).toHaveCount(2) + await expect(recallNotices.locator('.avatar')).toHaveCount(0) + await expect(recallNotices.locator('.sender').first()).toBeHidden() + const locationNotice = systemNotices.filter({ hasText: '位置共享已经结束' }) + await expect(locationNotice).toHaveCount(1) + await expect(locationNotice.locator('.avatar')).toHaveCount(0) + const recallAlignment = await recallNotices.first().evaluate((element) => { + const messageRect = element.getBoundingClientRect() + const bubbleRect = element.querySelector('.bubble')!.getBoundingClientRect() + return Math.abs( + messageRect.left + messageRect.width / 2 - (bubbleRect.left + bubbleRect.width / 2) + ) + }) + expect(recallAlignment).toBeLessThan(1) + await page.setViewportSize({ width: 390, height: 844 }) + expect( + await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth) + ).toBe(true) + await page.screenshot({ + path: testInfo.outputPath('structured-archive-390.png'), + fullPage: true + }) + } finally { + rmSync(outputDir, { recursive: true, force: true }) + } +}) diff --git a/tests/integration/export-media-flow.test.ts b/tests/integration/export-media-flow.test.ts index 6ce817a..ba594d7 100644 --- a/tests/integration/export-media-flow.test.ts +++ b/tests/integration/export-media-flow.test.ts @@ -8,6 +8,8 @@ import { readdirSync, rmSync, statSync, + unlinkSync, + utimesSync, writeFileSync } from 'fs' import { tmpdir } from 'os' @@ -22,6 +24,8 @@ const state = vi.hoisted(() => ({ videoPath: '', messages: [] as Message[], messagesByUser: {} as Record, + exportReads: [] as string[], + voiceLookups: [] as number[], videoLookups: [] as { createTime?: number duration?: number @@ -46,8 +50,15 @@ vi.mock('../../src/main/services/chat-service', () => ({ listMessages: () => structuredClone(state.messages), listMessagesAsync: async (userMd5: string) => structuredClone(state.messagesByUser[userMd5] || state.messages), + listMessagesForExport: async (userMd5: string) => { + state.exportReads.push(userMd5) + return structuredClone(state.messagesByUser[userMd5] || state.messages) + }, getChatDb: () => ({ - getWcdb4Client: () => ({ getAccountRoot: () => state.accountRoot }) + getWcdb4Client: () => ({ + getAccountRoot: () => state.accountRoot, + getUsernameByMd5: (userMd5: string) => `wxid_${userMd5}` + }) }), getContactAvatars: () => ({}), getSelfAccountInfoAsync: async () => ({ @@ -69,6 +80,7 @@ vi.mock('../../src/main/voice-service', () => ({ _sessionId: string, localId: number ): Promise<{ success: boolean; data?: string; error?: string }> { + state.voiceLookups.push(localId) return localId === 1 ? { success: true, @@ -187,6 +199,8 @@ describe('media export flow', () => { state.imageLookups = [] state.videoLookups = [] state.messagesByUser = {} + state.exportReads = [] + state.voiceLookups = [] const fileMonth = join(state.accountRoot, 'msg', 'file', '2026-08') mkdirSync(fileMonth, { recursive: true }) writeFileSync(join(fileMonth, '测试附件.txt'), '附件内容') @@ -272,7 +286,7 @@ describe('media export flow', () => { expect(html).toContain('') expect(voice.voiceDataUrl).toMatch(/^voices\/voice_[0-9a-f]{16}\.wav$/) expect(video.exportMediaUrl).toMatch(/^media\/video_[0-9a-f]{16}\.mp4$/) - expect(file.exportMediaUrl).toMatch(/^media\/file_[0-9a-f]{16}_测试附件\.txt$/) + expect(file.exportMediaUrl).toMatch(/^files\/file_[0-9a-f]{16}_测试附件\.txt$/) expect(missingVoice.exportMediaError).toBe('语音文件缺失:本地未找到语音数据') expect(state.imageLookups[0]).toMatchObject({ allowThumbnail: false, @@ -288,6 +302,42 @@ describe('media export flow', () => { height: 630 }) expect(progress.length).toBeGreaterThan(0) + expect(state.exportReads).toEqual(['fixture-user']) + }) + + it('keeps one-to-one sender sides and fills both display names', async () => { + const { runExport } = await import('../../src/main/export-service') + const win = { isDestroyed: () => true, webContents: { send: vi.fn() } } + state.messages = [ + message({ id: 'peer-message', content: '对方消息', isSender: false, name: '', senderId: '' }), + message({ id: 'self-message', content: '我的消息', isSender: true, name: '', senderId: '' }) + ] + + const result = await runExport( + { + jobId: 'one-to-one-identity', + targets: [target('jamie', 'Jamie')], + format: 'html', + outputName: 'one-to-one-identity', + kinds: ['text'], + includeMedia: false + }, + win as never + ) + + expect(result.success, result.error).toBe(true) + const archive = readArchive(result.outputPath!) + expect( + archive.messages.map(({ id, isSender, name, senderId }) => ({ + id, + isSender, + name, + senderId + })) + ).toEqual([ + { id: 'peer-message', isSender: false, name: 'Jamie', senderId: 'wxid_jamie' }, + { id: 'self-message', isSender: true, name: '濑岛田井卫', senderId: 'a969409112' } + ]) }) it('incrementally merges the same HTML archive, deduplicates messages, and keeps old media', async () => { @@ -316,6 +366,19 @@ describe('media export flow', () => { expect(first.success).toBe(true) const firstArchive = readArchive(first.outputPath!) const oldVoiceUrl = firstArchive.messages.find((item) => item.id === 'voice-old')!.voiceDataUrl + const outputDir = dirname(first.outputPath!) + firstArchive.messages.find((item) => item.id === 'voice-old')!.img = + 'data:image/jpeg;base64,bGVnYWN5LWlubGluZS1hdmF0YXI=' + writeFileSync( + join(outputDir, 'data', 'messages.js'), + `window.__WECHAT_EXPORT__ = ${JSON.stringify(firstArchive)};\n`, + 'utf8' + ) + writeFileSync(join(outputDir, 'voices', 'voice_orphan.wav'), 'orphan voice') + writeFileSync(join(outputDir, 'media', 'image_orphan.png'), 'orphan image') + writeFileSync(join(outputDir, 'media', 'file_orphan.txt'), 'legacy orphan file') + writeFileSync(join(outputDir, 'files', 'file_orphan.txt'), 'orphan file') + writeFileSync(join(outputDir, 'avatars', 'avatar_orphan.png'), 'orphan avatar') state.messages = [ message({ id: 'text-old', content: '同一条消息已更新', createTime: 1_785_549_660 }), @@ -344,9 +407,80 @@ describe('media export flow', () => { expect(secondArchive.messages.find((item) => item.id === 'voice-old')?.voiceDataUrl).toBe( oldVoiceUrl ) + expect(secondArchive.messages.every((item) => item.img == null)).toBe(true) + expect(existsSync(join(outputDir, oldVoiceUrl!))).toBe(true) + expect(existsSync(join(outputDir, 'voices', 'voice_orphan.wav'))).toBe(false) + expect(existsSync(join(outputDir, 'media', 'image_orphan.png'))).toBe(false) + expect(existsSync(join(outputDir, 'media', 'file_orphan.txt'))).toBe(false) + expect(existsSync(join(outputDir, 'files', 'file_orphan.txt'))).toBe(false) + expect(existsSync(join(outputDir, 'avatars', 'avatar_orphan.png'))).toBe(false) expect(existsSync(join(dirname(second.outputPath!), 'data', 'messages.js.bak'))).toBe(true) }) + it('reuses unchanged resources and retries only missing or unresolved media', async () => { + const { runExport } = await import('../../src/main/export-service') + const win = { isDestroyed: () => true, webContents: { send: vi.fn() } } + const request = { + targets: [target('fixture-user', '资源复用会话')], + format: 'html' as const, + outputName: 'resource-reuse-fixture', + kinds: ['voice', 'image', 'video', 'file'] as const, + includeMedia: true, + keepMissing: true + } + + const first = await runExport( + { ...request, jobId: 'resource-reuse-first', kinds: [...request.kinds] }, + win as never + ) + expect(first.success, first.error).toBe(true) + const firstArchive = readArchive(first.outputPath!) + const outputDir = dirname(first.outputPath!) + const voicePath = join( + outputDir, + firstArchive.messages.find((item) => item.id === 'voice-ok')!.voiceDataUrl! + ) + const imagePath = join( + outputDir, + firstArchive.messages.find((item) => item.id === 'image')!.exportMediaUrl! + ) + const videoPath = join( + outputDir, + firstArchive.messages.find((item) => item.id === 'video')!.exportMediaUrl! + ) + const filePath = join( + outputDir, + firstArchive.messages.find((item) => item.id === 'file')!.exportMediaUrl! + ) + const oldTimestamp = new Date(1_000_000) + utimesSync(videoPath, oldTimestamp, oldTimestamp) + utimesSync(filePath, oldTimestamp, oldTimestamp) + + const second = await runExport( + { ...request, jobId: 'resource-reuse-second', kinds: [...request.kinds] }, + win as never + ) + expect(second.success, second.error).toBe(true) + expect(state.imageLookups).toHaveLength(1) + expect(state.videoLookups).toHaveLength(1) + expect(state.voiceLookups).toEqual([1, 2, 2]) + expect(statSync(videoPath).mtimeMs).toBe(oldTimestamp.getTime()) + expect(statSync(filePath).mtimeMs).toBe(oldTimestamp.getTime()) + + unlinkSync(voicePath) + unlinkSync(imagePath) + const third = await runExport( + { ...request, jobId: 'resource-reuse-third', kinds: [...request.kinds] }, + win as never + ) + expect(third.success, third.error).toBe(true) + expect(state.imageLookups).toHaveLength(2) + expect(state.videoLookups).toHaveLength(1) + expect(state.voiceLookups).toEqual([1, 2, 2, 1, 2]) + expect(existsSync(voicePath)).toBe(true) + expect(existsSync(imagePath)).toBe(true) + }) + it('keeps copied videos writable and can replace a legacy read-only video incrementally', async () => { const { runExport } = await import('../../src/main/export-service') const win = { isDestroyed: () => true, webContents: { send: vi.fn() } } @@ -440,10 +574,11 @@ describe('media export flow', () => { ['beta', 'beta-later', 150], ['alpha', 'alpha-later', 200] ]) + expect(state.exportReads).toEqual(['alpha', 'beta']) const imagePaths = archive.messages .filter((item) => item.id === 'same-id') .map((item) => item.exportMediaUrl) - expect(new Set(imagePaths).size).toBe(2) + expect(new Set(imagePaths).size).toBe(1) for (const imagePath of imagePaths) { expect(existsSync(join(dirname(result.outputPath!), imagePath!))).toBe(true) } @@ -495,9 +630,15 @@ describe('media export flow', () => { expect(firstSize).toBeGreaterThan(0) expect(readFileSync(second.outputPath!).subarray(0, 2).toString()).toBe('PK') const entries = execFileSync('unzip', ['-Z1', second.outputPath!], { encoding: 'utf8' }) + const htmlPath = join(state.documents, 'WechatExplorer', '导出', 'zip-fixture', 'index.html') + const archive = readArchive(htmlPath) expect(entries).toContain('zip-fixture/index.html') expect(entries).toContain('zip-fixture/data/messages.js') - expect(entries).toMatch(/zip-fixture\/avatars\/conversation_[0-9a-f]{16}\.png/) + const avatarEntries = entries + .split('\n') + .filter((entry) => /zip-fixture\/avatars\/avatar_[0-9a-f]{16}\.png$/.test(entry)) + expect(avatarEntries).toHaveLength(1) + expect(archive.conversations[0].avatarUrl).toBe(archive.messages[0].exportAvatarUrl) expect(entries).toMatch(/zip-fixture\/media\/image_[0-9a-f]{16}\.png/) expect(progress.some((args) => (args[1] as { phase?: string })?.phase === 'compressing')).toBe( true diff --git a/tests/unit/export-media.test.ts b/tests/unit/export-media.test.ts index 297b3a4..e15f208 100644 --- a/tests/unit/export-media.test.ts +++ b/tests/unit/export-media.test.ts @@ -148,6 +148,97 @@ describe('export media', () => { dom.window.close() }) + it('locates every filtered message kind in all messages, including outside the latest window', () => { + const html = renderExportPage('定位消息') + const dom = new JSDOM(html, { runScripts: 'outside-only' }) + const categorized: Message[] = [ + { + ...messageForArchive('target-text', 'fixture', '定位消息', '目标文字', 1), + type: '普通文本' + }, + { + ...messageForArchive('target-media', 'fixture', '定位消息', '', 2), + type: '图片', + exportMediaType: 'image', + exportMediaUrl: 'media/target.jpg' + }, + { + ...messageForArchive('target-voice', 'fixture', '定位消息', '', 3), + type: '语音', + voiceDataUrl: 'voices/target.wav' + }, + { + ...messageForArchive('target-file', 'fixture', '定位消息', '', 4), + type: '文件', + exportMediaType: 'file', + exportMediaUrl: 'files/target.pdf' + }, + { + ...messageForArchive('target-share', 'fixture', '定位消息', '', 5), + type: '分享', + contentData: { type: 'share', typeVal: '5', title: '目标分享' } + }, + { + ...messageForArchive('target-system', 'fixture', '定位消息', '目标系统消息', 6), + from: 'system', + type: '系统消息', + contentData: { type: 'system', content: '目标系统消息' } + } + ] + const laterMessages = Array.from({ length: EXPORT_PAGE_SIZE }, (_, index) => ({ + ...messageForArchive( + `later-${index}`, + 'fixture', + '定位消息', + `稍后消息-${index}`, + 100 + index + ), + type: '普通文本' + })) + Object.assign(dom.window, { + __WECHAT_EXPORT__: { + version: 1, + sourceId: 'fixture', + name: '定位消息', + messages: [...categorized, ...laterMessages] + } + }) + + dom.window.eval(inlineScriptOf(html)) + + expect(dom.window.document.querySelectorAll('.locate-all')).toHaveLength(0) + for (const kind of ['media', 'voice', 'file', 'share', 'system']) { + const filterButton = dom.window.document.querySelector(`[data-kind="${kind}"]`) as HTMLElement + filterButton.click() + const locateButton = dom.window.document.querySelector('.locate-all') as HTMLElement + expect(locateButton?.getAttribute('aria-label')).toBe('定位到聊天位置') + expect(locateButton?.querySelector('.locate-icon')?.textContent).toBe('⌖') + expect(locateButton?.querySelector('.locate-label')?.textContent).toBe('定位到聊天位置') + locateButton.click() + expect( + dom.window.document.querySelector('[data-kind="all"]')?.classList.contains('active') + ).toBe(true) + expect( + dom.window.document.querySelector('.message.located')?.getAttribute('data-index') + ).toBe(String(categorized.findIndex((message) => kindOfFixture(message) === kind))) + } + + const textFilter = dom.window.document.querySelector('[data-kind="text"]') as HTMLElement + textFilter.click() + const search = dom.window.document.querySelector('#query') as HTMLInputElement + search.value = '目标文字' + search.dispatchEvent(new dom.window.Event('input')) + ;(dom.window.document.querySelector('.locate-all') as HTMLElement).click() + expect( + dom.window.document.querySelector('[data-kind="all"]')?.classList.contains('active') + ).toBe(true) + expect(dom.window.document.querySelector('.message.located')?.getAttribute('data-index')).toBe( + '0' + ) + expect(dom.window.document.querySelector('.message.located')?.textContent).toContain('目标文字') + dom.window.close() + }) + it('keeps relative media, file download, quote, and missing-media renderers', () => { const html = renderExportPage('媒体档案') @@ -190,3 +281,12 @@ function messageForArchive( exportConversationName: conversationName } } + +function kindOfFixture(message: Message): string { + if (message.exportMediaType === 'image') return 'media' + if (message.voiceDataUrl) return 'voice' + if (message.exportMediaType === 'file') return 'file' + if (message.contentData?.type === 'share') return 'share' + if (message.contentData?.type === 'system') return 'system' + return 'text' +} diff --git a/tests/unit/recall-archive-service.test.ts b/tests/unit/recall-archive-service.test.ts new file mode 100644 index 0000000..aeef39f --- /dev/null +++ b/tests/unit/recall-archive-service.test.ts @@ -0,0 +1,78 @@ +import crypto from 'crypto' +import { mkdirSync, mkdtempSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { beforeAll, describe, expect, it, vi } from 'vitest' +import type { Message } from '../../src/shared/types' + +const state = vi.hoisted(() => ({ userData: '' })) + +vi.mock('electron', () => ({ + app: { getPath: () => state.userData } +})) + +const message = (createTime: number, serverId?: string): Message => ({ + id: `message-${createTime}`, + from: 'user', + isSender: false, + type: '普通文本', + datetime: new Date(createTime * 1000).toISOString(), + content: String(createTime), + img: '', + name: 'Jamie', + sessionId: 'fixture-user', + localId: 1, + serverId, + createTime +}) + +describe('recall archive message identity', () => { + beforeAll(() => { + state.userData = mkdtempSync(join(tmpdir(), 'wxe-recall-identity-')) + }) + + it('does not collapse messages whose local ids repeat across database shards', async () => { + const accountRoot = '/fixture/account' + const sessionMd5 = 'fixture-session' + const archiveDir = join(state.userData, 'recall-archive') + const archiveName = crypto + .createHash('sha1') + .update(`${process.platform}:${accountRoot}`) + .digest('hex') + .slice(0, 16) + mkdirSync(archiveDir, { recursive: true }) + writeFileSync( + join(archiveDir, `${archiveName}.json`), + JSON.stringify({ + version: 1, + accountRoot, + updatedAt: Date.now(), + sessions: { + [sessionMd5]: { + username: 'fixture-user', + updatedAt: Date.now(), + messages: [], + recalls: [] + } + } + }) + ) + const { configureRecallArchive, mergeRecallArchiveMessages, messageIdentity } = + await import('../../src/main/services/recall-archive-service') + configureRecallArchive(accountRoot) + const oldMessage = message(1_731_327_263) + const newMessage = message(1_765_000_000) + + expect(messageIdentity(oldMessage)).not.toBe(messageIdentity(newMessage)) + expect(mergeRecallArchiveMessages(sessionMd5, [oldMessage, newMessage])).toEqual([ + oldMessage, + newMessage + ]) + }) + + it('prefers the globally unique server id when one is available', async () => { + const { messageIdentity } = await import('../../src/main/services/recall-archive-service') + + expect(messageIdentity(message(1_731_327_263, 'server-2024'))).toBe('server:server-2024') + }) +}) diff --git a/tests/unit/wcdb-message-shards.test.ts b/tests/unit/wcdb-message-shards.test.ts index 02907ba..a174e93 100644 --- a/tests/unit/wcdb-message-shards.test.ts +++ b/tests/unit/wcdb-message-shards.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import { Wcdb4Client, type Wcdb4Message } from '../../src/main/wcdb4-client' -const message = (id: string, year: number): Wcdb4Message => ({ +const message = (id: string, year: number, serverId = `server-${id}`): Wcdb4Message => ({ mesLocalID: id, - serverId: `server-${id}`, + serverId, mesDes: 0, messageType: '1', msgCreateTime: String(Math.floor(Date.UTC(year, 0, 1) / 1000)), @@ -12,6 +12,21 @@ const message = (id: string, year: number): Wcdb4Message => ({ }) describe('WCDB message shard pagination', () => { + it('keeps messages whose local ids repeat across database shards', async () => { + const cursor = vi.fn(async () => [ + message('1', 2024, 'server-2024'), + message('1', 2025, 'server-2025') + ]) + const client = Object.assign(Object.create(Wcdb4Client.prototype), { + getMessagesByCursorAsync: cursor + }) as Wcdb4Client + + const result = await client.getMessagesAsync('fixture@chatroom') + + expect(result).toHaveLength(2) + expect(result.map((item) => item.serverId)).toEqual(['server-2024', 'server-2025']) + }) + it('merges cursor and all-store rows for a bounded cross-year page', async () => { const cursor = vi.fn(async () => [message('2025', 2025)]) const tableScan = vi.fn(async () => [message('2017', 2017), message('2025', 2025)]) diff --git a/tests/unit/wechat-db-messages.test.ts b/tests/unit/wechat-db-messages.test.ts new file mode 100644 index 0000000..987a169 --- /dev/null +++ b/tests/unit/wechat-db-messages.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest' +import { WechatDb, type WechatMessage } from '../../src/main/wechat-db' + +describe('WechatDb normalized messages', () => { + it('keeps normalized identity fields when raw table columns conflict', async () => { + const normalized = { + mesLocalID: '1', + serverId: 'server-1', + mesDes: 0, + messageType: '1', + msgCreateTime: '1731327263', + msgContent: 'fixture', + sender: 'wxid_self', + senderNickname: 'Nanin', + raw: { + mesDes: 1, + sender: '', + senderNickname: '' + } + } + const client = { getMessagesAsync: vi.fn(async () => [normalized]) } + const db = Object.assign(Object.create(WechatDb.prototype), { + wcdb4Client: client, + chatMd5ToUsername: new Map([['fixture-md5', 'fixture-user']]), + ensureChatTableMapping: vi.fn() + }) as WechatDb + + const start = Math.floor(new Date(2024, 10, 11).getTime() / 1000) + const end = Math.floor(new Date(2024, 11, 1).getTime() / 1000) + const messages = await db.getUserMessagesForExport('fixture-md5', start, end) + + expect(messages[0]).toMatchObject({ + mesDes: 0, + sender: 'wxid_self', + senderNickname: 'Nanin' + }) + }) + + it('scans without time bounds, then filters, deduplicates and sorts in application code', async () => { + const row = (id: string, createTime: number): WechatMessage => ({ + mesLocalID: id, + serverId: `server-${id}`, + mesDes: 0, + messageType: '1', + msgCreateTime: String(createTime), + msgContent: id, + raw: {} + }) + const start = Math.floor(new Date(2025, 0, 1).getTime() / 1000) + const end = Math.floor(new Date(2025, 0, 4).getTime() / 1000) + const shardBoundaryMessage = row('jan-2-boundary', start + 32 * 60 * 60) + const getMessagesAsync = vi.fn(async () => [ + row('before-range', start - 1), + row('newest', start + 48 * 60 * 60), + shardBoundaryMessage, + { ...shardBoundaryMessage }, + row('after-range', end + 1) + ]) + const db = Object.assign(Object.create(WechatDb.prototype), { + wcdb4Client: { getMessagesAsync }, + chatMd5ToUsername: new Map([['fixture-md5', 'fixture-user']]), + ensureChatTableMapping: vi.fn() + }) as WechatDb + + const messages = await db.getUserMessagesForExport('fixture-md5', start, end) + + expect(getMessagesAsync).toHaveBeenCalledOnce() + expect(getMessagesAsync).toHaveBeenCalledWith('fixture-user') + expect(messages.map((message) => message.mesLocalID)).toEqual(['jan-2-boundary', 'newest']) + }) +})