mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 11:37:06 +08:00
feat: 完善聊天导出与档案浏览
1. 修复数据量较大时,历史数据可能无法完整导出的问题 2. 优化分享 Tab 的信息展示,支持小程序、链接、地图等消息 3. 优化系统 Tab 的信息展示,支持红包、转账、拍一拍、撤回等消息 4. 修复默认进入时,时间轴不随消息自动定位的问题 5. 增加“定位到聊天位置”功能
This commit is contained in:
@@ -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 = '<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
@@ -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: '已取消' }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user