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
+507 -23
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()
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
+264 -35
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}`
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: '已取消' }
+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}`
}
+7 -5
View File
@@ -1286,11 +1286,13 @@ 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}`
: `${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)
}
+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 {