feat: 完善聊天导出与档案浏览

1. 修复数据量较大时,历史数据可能无法完整导出的问题
2. 优化分享 Tab 的信息展示,支持小程序、链接、地图等消息
3. 优化系统 Tab 的信息展示,支持红包、转账、拍一拍、撤回等消息
4. 修复默认进入时,时间轴不随消息自动定位的问题
5. 增加“定位到聊天位置”功能
This commit is contained in:
majun.jason
2026-08-05 00:14:28 +08:00
parent 66a6ee3e32
commit 4e84b52cc4
12 changed files with 1615 additions and 75 deletions
+506 -22
View File
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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 = '<div class="structured-title">' + esc(title) + '</div>'
return '<div class="structured-content share-content" data-rich-kind="share">' +
'<div class="structured-kicker">' + displayText(label) + '</div>' +
(href
? '<a class="structured-link" href="' + href + '" target="_blank" rel="noreferrer noopener">' + titleMarkup + '</a>'
: titleMarkup) +
(description ? '<div class="structured-description">' + esc(description) + '</div>' : '') +
'<div class="structured-footer"><span>' + appName + '</span>' +
(host ? '<span>·</span><span>' + esc(host) + '</span>' : '') + '</div></div>'
}
const renderMiniProgramContent = (data) => {
const previewUrl = imageUrl(data.thumbDataUrl)
const iconUrl = imageUrl(data.iconUrl)
return '<div class="structured-content mini-program-content" data-rich-kind="miniProgram">' +
'<div class="structured-kicker">小程序</div>' +
'<div class="structured-title">' + displayText(data.title || '小程序') + '</div>' +
(data.description
? '<div class="structured-description">' + displayText(data.description) + '</div>'
: '') +
(previewUrl
? '<img class="mini-program-preview" data-preview src="' + previewUrl + '" alt="">'
: '') +
'<div class="structured-footer">' +
(iconUrl ? '<img class="mini-program-icon" src="' + iconUrl + '" alt="">' : '') +
'<span>' + displayText(data.appName || '小程序') + '</span></div></div>'
}
const renderForwardItems = (items, depth = 0) => {
if (!Array.isArray(items) || !items.length || depth > 4) return ''
return '<ol class="forward-list">' + items.map((item) =>
'<li class="forward-item">' +
(item.sender ? '<strong>' + displayText(item.sender) + '</strong>' : '') +
(item.sentAt ? '<time>' + displayText(item.sentAt) + '</time>' : '') +
'<span>' + displayText(item.text || '[消息]') + '</span>' +
renderForwardItems(item.nested, depth + 1) + '</li>'
).join('') + '</ol>'
}
const renderForwardContent = (data) => {
const items = Array.isArray(data.items) ? data.items : []
return '<div class="structured-content forward-content" data-rich-kind="forwardBundle">' +
'<div class="structured-kicker">聊天记录</div>' +
'<div class="structured-title">' + displayText(data.title || '聊天记录') + '</div>' +
(!items.length && data.description
? '<div class="structured-description">' + displayText(data.description) + '</div>'
: '') +
(items.length
? '<details><summary>展开 ' + items.length + ' 条消息</summary>' + renderForwardItems(items) + '</details>'
: '') + '</div>'
}
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 = '<div class="structured-content location-content" data-rich-kind="location">' +
'<div class="structured-kicker">位置</div>' +
'<div class="structured-title">' + displayText(title) + '</div>' +
(address ? '<div class="structured-description">' + displayText(address) + '</div>' : '') +
(coordinates
? '<div class="structured-footer"><span class="location-coordinates">' +
esc(coordinates) + '</span><span class="location-action">在地图中打开</span></div>'
: '') + '</div>'
return mapUrl
? '<a class="structured-link" href="' + mapUrl +
'" target="_blank" rel="noreferrer noopener">' + card + '</a>'
: card
}
const renderPaymentContent = (data, kind) => {
const isTransfer = kind === 'transfer'
return '<div class="structured-content payment-content ' + (isTransfer ? 'transfer' : 'red-packet') +
'" data-rich-kind="' + (isTransfer ? 'transfer' : 'redPacket') + '">' +
'<div class="structured-kicker">' + (isTransfer ? '微信转账' : '微信红包') + '</div>' +
'<div class="structured-title">' + displayText(data.title || (isTransfer ? '微信转账' : '微信红包')) + '</div>' +
'<div class="structured-description">' +
displayText(data.description || data.des || (isTransfer ? '转账消息' : '恭喜发财,大吉大利')) +
'</div></div>'
}
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 '<div class="structured-content" data-rich-kind="voip">' +
'<div class="structured-kicker">通话</div>' +
'<div class="structured-title">' + title + '</div>' +
'<div class="structured-description">' +
displayText([data.status, durationText].filter(Boolean).join(' · ') || '通话消息') +
'</div></div>'
}
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'
? '<div class="quote-reference"><strong>' + esc(data.quotedSender || '引用消息') + '</strong><span>' + esc(data.quotedContent || '[引用消息]') + '</span></div>'
: ''
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 => `
? '<img src="' + esc(message.exportAvatarUrl) + '" alt="">'
: avatarFallback) + '</div>'
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 ? '<div class="content">' + content + '</div>' : ''
const source = conversations.length > 1 && activeConversation === 'all'
? '<span class="conversation-source">' + esc(message.exportConversationName || '聊天') + '</span>'
: ''
const locateAction = activeKind === 'all'
? ''
: '<button class="locate-all" type="button" data-locate-index="' + archiveIndex +
'" aria-label="定位到聊天位置"><span class="locate-icon" aria-hidden="true">⌖</span>' +
'<span class="locate-label" aria-hidden="true">定位到聊天位置</span></button>'
return '<article class="message' + (message.isSender ? ' sent' : '') + (isSystem ? ' system' : '') +
'" data-index="' + archiveIndex + '" data-month="' + esc(monthKey(message)) + '">' +
'<div class="time">' + esc(fullTime(message)) + source + '</div><div class="row">' +
(isSystem ? '' : avatar) + '<div class="bubble"><div class="sender">' +
(isSystem ? '' : esc(sender)) + '</div>' + media + audio + quote +
'<div class="content">' + content + '</div>' + mediaStatus + '</div></div></article>'
(isSystem ? '' : esc(sender)) + '</div>' + media + audio + quote + structured +
contentMarkup + mediaStatus + '</div>' + locateAction + '</div></article>'
}
const renderConversations = () => {
@@ -547,11 +912,48 @@ const renderExportScript = (name: string): string => `
timeline.innerHTML = html || '<div class="timeline-empty">时间信息不可用</div>'
}
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 ? '<div class="lazy-hint">向上滚动加载更早消息</div>' : ''
@@ -583,7 +992,7 @@ const renderExportScript = (name: string): string => `
: '<div class="empty">没有符合条件的消息<br><small>可以更换筛选条件或关键词</small></div>'
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()
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
+255 -26
View File
@@ -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<string> => {
const hash = createHash('sha1')
await new Promise<void>((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<boolean> => {
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<void> {
const referenced = Object.fromEntries(
Object.keys(htmlArchiveResourcePrefixes).map((directory) => [directory, new Set<string>()])
) as Record<HtmlArchiveResourceDirectory, Set<string>>
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<Message, Message>()
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<string, Promise<boolean>>()
const resourceExists = (value?: string): Promise<boolean> => {
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}`
const avatarName = avatarFileName(resolved.buffer, resolved.extension)
const avatarUrl = `avatars/${avatarName}`
if (!(await resourceExists(avatarUrl))) {
await fs.writeFile(join(outputDir, 'avatars', avatarName), resolved.buffer)
conversationAvatarUrls.set(target.userMd5, `avatars/${avatarName}`)
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')
const voiceName = `voice_${bufferHashPart(audioBuffer)}.wav`
const voiceUrl = `voices/${voiceName}`
if (!(await resourceExists(voiceUrl))) {
await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer)
message.voiceDataUrl = `voices/${voiceName}`
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)
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}`
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)
message.exportMediaUrl = `media/${name}`
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`
const name = `video_${await fileHashPart(source)}.mp4`
const mediaUrl = `media/${name}`
if (!(await resourceExists(mediaUrl))) {
await copyWritableExportFile(source, join(outputDir, 'media', name))
message.exportMediaUrl = `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}`
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)
message.exportMediaUrl = `media/${name}`
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: '已取消' }
+17
View File
@@ -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<FormattedMessage[]> {
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()
+4 -3
View File
@@ -40,12 +40,13 @@ let archivePath = ''
let writeTimer: NodeJS.Timeout | null = null
let writeQueue: Promise<void> = 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}`
}
+6 -4
View File
@@ -1286,10 +1286,12 @@ export class Wcdb4Client {
const merged = new Map<string, Wcdb4Message>()
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}`
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)
+48 -3
View File
@@ -34,6 +34,24 @@ export interface GroupMemberInfo {
m_nsHeadImgUrl: string
}
function mergeExportMessages(messages: WechatMessage[]): WechatMessage[] {
const merged = new Map<string, WechatMessage>()
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<string, string>()
@@ -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<WechatMessage[]> {
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 {
+357
View File
@@ -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&amp;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 })
}
})
+145 -4
View File
@@ -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<string, Message[]>,
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('<script src="data/messages.js"></script>')
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
+100
View File
@@ -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'
}
+78
View File
@@ -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')
})
})
+17 -2
View File
@@ -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)])
+71
View File
@@ -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'])
})
})