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) =>
+ '- ' +
+ (item.sender ? '' + displayText(item.sender) + '' : '') +
+ (item.sentAt ? '' : '') +
+ '' + displayText(item.text || '[消息]') + '' +
+ renderForwardItems(item.nested, depth + 1) + '
'
+ ).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'])
+ })
+})