mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 19:47:08 +08:00
feat: 完善聊天导出与档案浏览
1. 修复数据量较大时,历史数据可能无法完整导出的问题 2. 优化分享 Tab 的信息展示,支持小程序、链接、地图等消息 3. 优化系统 Tab 的信息展示,支持红包、转账、拍一拍、撤回等消息 4. 修复默认进入时,时间轴不随消息自动定位的问题 5. 增加“定位到聊天位置”功能
This commit is contained in:
@@ -177,12 +177,81 @@ body {
|
|||||||
width: min(100%, 820px);
|
width: min(100%, 820px);
|
||||||
margin: 0 auto 22px;
|
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.sent { align-items: flex-end; }
|
||||||
.message.system { align-items: center; }
|
.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 .avatar { display: none; }
|
||||||
.message.system .bubble {
|
.message.system .bubble {
|
||||||
max-width: 92%;
|
max-width: 100%;
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
@@ -265,6 +334,95 @@ body {
|
|||||||
}
|
}
|
||||||
.quote-reference strong { font-weight: 650; color: var(--text); }
|
.quote-reference strong { font-weight: 650; color: var(--text); }
|
||||||
.quote-reference span { white-space: pre-wrap; }
|
.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 {
|
.media-image {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -321,7 +479,15 @@ body {
|
|||||||
.archive-layout { grid-template-columns: 1fr; align-content: start; margin-top: 10px; }
|
.archive-layout { grid-template-columns: 1fr; align-content: start; margin-top: 10px; }
|
||||||
.archive-layout.single-conversation { grid-template-columns: 1fr; }
|
.archive-layout.single-conversation { grid-template-columns: 1fr; }
|
||||||
.archive-navigation { align-self: start; gap: 8px; }
|
.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; }
|
.archive-heading { align-items: flex-start; }
|
||||||
.conversation-filter { flex-basis: 100%; width: 100%; }
|
.conversation-filter { flex-basis: 100%; width: 100%; }
|
||||||
.timeline-year {
|
.timeline-year {
|
||||||
@@ -394,6 +560,8 @@ const renderExportScript = (name: string): string => `
|
|||||||
let windowEnd = 0
|
let windowEnd = 0
|
||||||
let scrollLoadPending = false
|
let scrollLoadPending = false
|
||||||
let scrollLoadSuppressed = false
|
let scrollLoadSuppressed = false
|
||||||
|
let activeMonthUpdatePending = false
|
||||||
|
const tabPositions = new Map()
|
||||||
let lastScrollTop = 0
|
let lastScrollTop = 0
|
||||||
let zoom = 1
|
let zoom = 1
|
||||||
|
|
||||||
@@ -409,6 +577,37 @@ const renderExportScript = (name: string): string => `
|
|||||||
/[&<>"']/g,
|
/[&<>"']/g,
|
||||||
(character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character] || character
|
(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 pad = (value) => String(value).padStart(2, '0')
|
||||||
const fullTime = (message) => {
|
const fullTime = (message) => {
|
||||||
const timestamp = Number(message.createTime || 0)
|
const timestamp = Number(message.createTime || 0)
|
||||||
@@ -426,20 +625,37 @@ const renderExportScript = (name: string): string => `
|
|||||||
}
|
}
|
||||||
const kindOf = (message) => {
|
const kindOf = (message) => {
|
||||||
const data = message.contentData || {}
|
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 (
|
if (
|
||||||
message.exportMediaType === 'image' || message.exportMediaType === 'video' ||
|
message.exportMediaType === 'image' || message.exportMediaType === 'video' ||
|
||||||
message.exportMediaType === 'sticker' || data.type === 'image' ||
|
message.exportMediaType === 'sticker' || data.type === 'image' ||
|
||||||
data.type === 'video' || data.type === 'sticker'
|
data.type === 'video' || data.type === 'sticker'
|
||||||
) return 'media'
|
) return 'media'
|
||||||
if (message.voiceDataUrl || data.type === 'voice' || message.type === '语音') return 'voice'
|
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 (
|
if (
|
||||||
data.type === 'share' || data.type === 'location' ||
|
data.type === 'share' || data.type === 'location' ||
|
||||||
data.type === 'miniProgram' || data.type === 'forwardBundle'
|
data.type === 'miniProgram' || data.type === 'forwardBundle'
|
||||||
) return 'share'
|
) return 'share'
|
||||||
if (data.type === 'system' || data.type === 'unknown' || message.from === 'system') return 'system'
|
|
||||||
return 'text'
|
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) => [
|
const searchText = (message) => [
|
||||||
message.exportConversationName,
|
message.exportConversationName,
|
||||||
message.name,
|
message.name,
|
||||||
@@ -447,11 +663,153 @@ const renderExportScript = (name: string): string => `
|
|||||||
message.content,
|
message.content,
|
||||||
message.type,
|
message.type,
|
||||||
message.contentData && message.contentData.title,
|
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.quotedSender,
|
||||||
message.contentData && message.contentData.quotedContent,
|
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
|
message.exportMediaName
|
||||||
].filter(Boolean).join(' ').toLowerCase()
|
].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 renderMessage = (message, archiveIndex) => {
|
||||||
const data = message.contentData || {}
|
const data = message.contentData || {}
|
||||||
const mediaUrl = message.exportMediaUrl ? esc(message.exportMediaUrl) : ''
|
const mediaUrl = message.exportMediaUrl ? esc(message.exportMediaUrl) : ''
|
||||||
@@ -476,7 +834,8 @@ const renderExportScript = (name: string): string => `
|
|||||||
const quote = data.type === 'quote'
|
const quote = data.type === 'quote'
|
||||||
? '<div class="quote-reference"><strong>' + esc(data.quotedSender || '引用消息') + '</strong><span>' + esc(data.quotedContent || '[引用消息]') + '</span></div>'
|
? '<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 sender = message.name || (message.isSender ? '我' : '联系人')
|
||||||
const avatarFallback = esc(String(sender || '友').slice(0, 1))
|
const avatarFallback = esc(String(sender || '友').slice(0, 1))
|
||||||
const avatar = message.exportShowAvatar === false
|
const avatar = message.exportShowAvatar === false
|
||||||
@@ -485,16 +844,22 @@ const renderExportScript = (name: string): string => `
|
|||||||
? '<img src="' + esc(message.exportAvatarUrl) + '" alt="">'
|
? '<img src="' + esc(message.exportAvatarUrl) + '" alt="">'
|
||||||
: avatarFallback) + '</div>'
|
: avatarFallback) + '</div>'
|
||||||
const text = message.content || (data.type === 'quote' ? data.title : '')
|
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'
|
const source = conversations.length > 1 && activeConversation === 'all'
|
||||||
? '<span class="conversation-source">' + esc(message.exportConversationName || '聊天') + '</span>'
|
? '<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' : '') +
|
return '<article class="message' + (message.isSender ? ' sent' : '') + (isSystem ? ' system' : '') +
|
||||||
'" data-index="' + archiveIndex + '" data-month="' + esc(monthKey(message)) + '">' +
|
'" data-index="' + archiveIndex + '" data-month="' + esc(monthKey(message)) + '">' +
|
||||||
'<div class="time">' + esc(fullTime(message)) + source + '</div><div class="row">' +
|
'<div class="time">' + esc(fullTime(message)) + source + '</div><div class="row">' +
|
||||||
(isSystem ? '' : avatar) + '<div class="bubble"><div class="sender">' +
|
(isSystem ? '' : avatar) + '<div class="bubble"><div class="sender">' +
|
||||||
(isSystem ? '' : esc(sender)) + '</div>' + media + audio + quote +
|
(isSystem ? '' : esc(sender)) + '</div>' + media + audio + quote + structured +
|
||||||
'<div class="content">' + content + '</div>' + mediaStatus + '</div></div></article>'
|
contentMarkup + mediaStatus + '</div>' + locateAction + '</div></article>'
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderConversations = () => {
|
const renderConversations = () => {
|
||||||
@@ -547,11 +912,48 @@ const renderExportScript = (name: string): string => `
|
|||||||
timeline.innerHTML = html || '<div class="timeline-empty">时间信息不可用</div>'
|
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 updateActiveMonth = () => {
|
||||||
const first = list.querySelector('.message')
|
const messages = Array.from(list.querySelectorAll('.message'))
|
||||||
const key = first && first.dataset.month
|
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) => {
|
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 = () => {
|
const updateCount = () => {
|
||||||
@@ -568,12 +970,19 @@ const renderExportScript = (name: string): string => `
|
|||||||
list.style.scrollBehavior = 'auto'
|
list.style.scrollBehavior = 'auto'
|
||||||
list.scrollTop = value
|
list.scrollTop = value
|
||||||
lastScrollTop = list.scrollTop
|
lastScrollTop = list.scrollTop
|
||||||
|
updateActiveMonth()
|
||||||
nextFrame(() => {
|
nextFrame(() => {
|
||||||
list.style.scrollBehavior = previousBehavior
|
list.style.scrollBehavior = previousBehavior
|
||||||
lastScrollTop = list.scrollTop
|
lastScrollTop = list.scrollTop
|
||||||
|
updateActiveMonth()
|
||||||
scrollLoadSuppressed = false
|
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 renderWindow = (anchorIndex, anchorOffset) => {
|
||||||
const visible = filtered.slice(windowStart, windowEnd)
|
const visible = filtered.slice(windowStart, windowEnd)
|
||||||
const before = windowStart > 0 ? '<div class="lazy-hint">向上滚动加载更早消息</div>' : ''
|
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>'
|
: '<div class="empty">没有符合条件的消息<br><small>可以更换筛选条件或关键词</small></div>'
|
||||||
if (Number.isInteger(anchorIndex)) {
|
if (Number.isInteger(anchorIndex)) {
|
||||||
const anchor = list.querySelector('.message[data-index="' + anchorIndex + '"]')
|
const anchor = list.querySelector('.message[data-index="' + anchorIndex + '"]')
|
||||||
if (anchor) setScrollTop(anchor.offsetTop - anchorOffset)
|
if (anchor) setScrollTop(scrollTopForTarget(anchor, anchorOffset))
|
||||||
}
|
}
|
||||||
updateCount()
|
updateCount()
|
||||||
updateActiveMonth()
|
updateActiveMonth()
|
||||||
@@ -598,15 +1007,81 @@ const renderExportScript = (name: string): string => `
|
|||||||
renderWindow()
|
renderWindow()
|
||||||
setScrollTop(preferLatest ? list.scrollHeight : 0)
|
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()
|
const term = query.value.trim().toLowerCase()
|
||||||
filtered = allMessages.filter((message) =>
|
return allMessages.filter((message) =>
|
||||||
(activeConversation === 'all' || message.exportConversationId === activeConversation) &&
|
(activeConversation === 'all' || message.exportConversationId === activeConversation) &&
|
||||||
(activeKind === 'all' || kindOf(message) === activeKind) &&
|
(activeKind === 'all' || kindOf(message) === activeKind) &&
|
||||||
(!term || searchText(message).includes(term))
|
(!term || searchText(message).includes(term))
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
const applyFilters = (restorePosition = false) => {
|
||||||
|
filtered = matchingMessages()
|
||||||
renderTimeline()
|
renderTimeline()
|
||||||
|
if (!restorePosition || !restoreTabPosition()) resetWindow(true)
|
||||||
|
}
|
||||||
|
const setActiveKind = (kind) => {
|
||||||
|
activeKind = kind
|
||||||
|
filters.querySelectorAll('[data-kind]').forEach((item) => {
|
||||||
|
item.classList.toggle('active', item.dataset.kind === kind)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const locateInAll = (sourceIndex) => {
|
||||||
|
const targetMessage = filtered[sourceIndex]
|
||||||
|
if (!targetMessage) return
|
||||||
|
rememberTabPosition()
|
||||||
|
setActiveKind('all')
|
||||||
|
filtered = matchingMessages()
|
||||||
|
renderTimeline()
|
||||||
|
const targetIndex = filtered.indexOf(targetMessage)
|
||||||
|
if (targetIndex < 0) {
|
||||||
resetWindow(true)
|
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 jumpToMonth = (key) => {
|
||||||
const index = filtered.findIndex((message) => monthKey(message) === 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)
|
windowStart = Math.max(0, windowEnd - PAGE_SIZE)
|
||||||
renderWindow()
|
renderWindow()
|
||||||
const target = list.querySelector('.message[data-index="' + index + '"]')
|
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) => {
|
timeline.querySelectorAll('.timeline-month').forEach((button) => {
|
||||||
button.classList.toggle('active', button.dataset.month === key)
|
button.classList.toggle('active', button.dataset.month === key)
|
||||||
})
|
})
|
||||||
@@ -626,7 +1101,9 @@ const renderExportScript = (name: string): string => `
|
|||||||
const anchor =
|
const anchor =
|
||||||
direction < 0 ? renderedMessages[0] : renderedMessages[renderedMessages.length - 1]
|
direction < 0 ? renderedMessages[0] : renderedMessages[renderedMessages.length - 1]
|
||||||
const anchorIndex = anchor ? Number(anchor.dataset.index) : undefined
|
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) {
|
if (direction < 0) {
|
||||||
windowStart = Math.max(0, windowStart - WINDOW_STEP)
|
windowStart = Math.max(0, windowStart - WINDOW_STEP)
|
||||||
windowEnd = Math.min(filtered.length, windowStart + PAGE_SIZE)
|
windowEnd = Math.min(filtered.length, windowStart + PAGE_SIZE)
|
||||||
@@ -653,6 +1130,7 @@ const renderExportScript = (name: string): string => `
|
|||||||
const nearTop = currentTop < 180
|
const nearTop = currentTop < 180
|
||||||
const nearBottom = list.scrollHeight - currentTop - list.clientHeight < 240
|
const nearBottom = list.scrollHeight - currentTop - list.clientHeight < 240
|
||||||
lastScrollTop = currentTop
|
lastScrollTop = currentTop
|
||||||
|
scheduleActiveMonthUpdate()
|
||||||
if (scrollLoadSuppressed) return
|
if (scrollLoadSuppressed) return
|
||||||
if (movingUp && nearTop) scheduleWindowSlide(-1)
|
if (movingUp && nearTop) scheduleWindowSlide(-1)
|
||||||
else if (!movingUp && nearBottom) scheduleWindowSlide(1)
|
else if (!movingUp && nearBottom) scheduleWindowSlide(1)
|
||||||
@@ -662,17 +1140,18 @@ const renderExportScript = (name: string): string => `
|
|||||||
scheduleWindowSlide(-1)
|
scheduleWindowSlide(-1)
|
||||||
}
|
}
|
||||||
}, { passive: true })
|
}, { passive: true })
|
||||||
query.addEventListener('input', applyFilters)
|
query.addEventListener('input', () => applyFilters())
|
||||||
filters.addEventListener('click', (event) => {
|
filters.addEventListener('click', (event) => {
|
||||||
const button = event.target.closest('[data-kind]')
|
const button = event.target.closest('[data-kind]')
|
||||||
if (!button) return
|
if (!button) return
|
||||||
activeKind = button.dataset.kind
|
rememberTabPosition()
|
||||||
filters.querySelectorAll('[data-kind]').forEach((item) => item.classList.toggle('active', item === button))
|
setActiveKind(button.dataset.kind)
|
||||||
applyFilters()
|
applyFilters(true)
|
||||||
})
|
})
|
||||||
conversationSelect.addEventListener('change', () => {
|
conversationSelect.addEventListener('change', () => {
|
||||||
|
rememberTabPosition()
|
||||||
activeConversation = conversationSelect.value
|
activeConversation = conversationSelect.value
|
||||||
applyFilters()
|
applyFilters(true)
|
||||||
})
|
})
|
||||||
timeline.addEventListener('click', (event) => {
|
timeline.addEventListener('click', (event) => {
|
||||||
const button = event.target.closest('[data-month]')
|
const button = event.target.closest('[data-month]')
|
||||||
@@ -686,6 +1165,11 @@ const renderExportScript = (name: string): string => `
|
|||||||
updateZoom()
|
updateZoom()
|
||||||
}
|
}
|
||||||
list.addEventListener('click', (event) => {
|
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]')
|
const image = event.target.closest('img[data-preview]')
|
||||||
if (!image) return
|
if (!image) return
|
||||||
preview.src = image.src
|
preview.src = image.src
|
||||||
|
|||||||
+255
-26
@@ -1,6 +1,6 @@
|
|||||||
import { app, BrowserWindow, shell } from 'electron'
|
import { app, BrowserWindow, shell } from 'electron'
|
||||||
import { createHash } from 'crypto'
|
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 { extname, join } from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
import { ZipArchive, type Archiver } from 'archiver'
|
import { ZipArchive, type Archiver } from 'archiver'
|
||||||
@@ -67,9 +67,75 @@ export interface HtmlExportArchive {
|
|||||||
messages: Message[]
|
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 archiveDataPrefix = 'window.__WECHAT_EXPORT__ = '
|
||||||
const hashPart = (value: string, length = 16): string =>
|
const hashPart = (value: string, length = 16): string =>
|
||||||
createHash('sha1').update(value).digest('hex').slice(0, length)
|
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 => {
|
export const exportMessageKey = (message: Message, sourceId = ''): string => {
|
||||||
const conversationId = message.exportConversationId || sourceId
|
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(
|
export async function readHtmlArchive(
|
||||||
outputDir: string,
|
outputDir: string,
|
||||||
targets: ExportTarget[],
|
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 => {
|
const keepMediaError = (request: ExportRequest, message: Message, error: string): void => {
|
||||||
if (request.keepMissing !== false) message.exportMediaError = error
|
if (request.keepMissing !== false) message.exportMediaError = error
|
||||||
}
|
}
|
||||||
@@ -444,7 +567,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
|||||||
return { success: false, error: '已取消' }
|
return { success: false, error: '已取消' }
|
||||||
}
|
}
|
||||||
const targetMessages = (
|
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)))
|
).filter((message) => request.kinds.includes(kindOf(message)))
|
||||||
for (const [messageOrder, message] of targetMessages.entries()) {
|
for (const [messageOrder, message] of targetMessages.entries()) {
|
||||||
messageEntries.push({
|
messageEntries.push({
|
||||||
@@ -475,6 +598,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
|||||||
.map((entry) => entry.message)
|
.map((entry) => entry.message)
|
||||||
const rawSelfInfo = await chat.getSelfAccountInfoAsync()
|
const rawSelfInfo = await chat.getSelfAccountInfoAsync()
|
||||||
const selfInfo = rawSelfInfo ? mergeCachedSelfInfo(rawSelfInfo.accountRoot, rawSelfInfo) : null
|
const selfInfo = rawSelfInfo ? mergeCachedSelfInfo(rawSelfInfo.accountRoot, rawSelfInfo) : null
|
||||||
|
const client = chat.getChatDb()?.getWcdb4Client()
|
||||||
const isUsableSelfName = (value: string | undefined): value is string => {
|
const isUsableSelfName = (value: string | undefined): value is string => {
|
||||||
const name = String(value || '').trim()
|
const name = String(value || '').trim()
|
||||||
if (!name) return false
|
if (!name) return false
|
||||||
@@ -484,14 +608,30 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
|||||||
}
|
}
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
const target = targetById.get(message.exportConversationId || '')
|
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.exportMediaUrl = undefined
|
||||||
message.exportMediaType = undefined
|
message.exportMediaType = undefined
|
||||||
message.exportMediaName = undefined
|
message.exportMediaName = undefined
|
||||||
message.exportMediaError = undefined
|
message.exportMediaError = undefined
|
||||||
message.voiceDataUrl = undefined
|
message.voiceDataUrl = undefined
|
||||||
message.exportShowAvatar = request.includeAvatars !== false
|
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
|
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
|
message.name = mappedName
|
||||||
} else if (message.isSender && isUsableSelfName(selfInfo?.nickname)) {
|
} else if (message.isSender && isUsableSelfName(selfInfo?.nickname)) {
|
||||||
message.name = selfInfo.nickname
|
message.name = selfInfo.nickname
|
||||||
@@ -529,10 +669,35 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
|||||||
: join(root, `${outputFolder}.${ext}`)
|
: join(root, `${outputFolder}.${ext}`)
|
||||||
if (request.format === 'html') {
|
if (request.format === 'html') {
|
||||||
const previousArchive = await readHtmlArchive(outputDir, targets, archiveName)
|
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, 'voices'), { recursive: true })
|
||||||
await fs.mkdir(join(outputDir, 'media'), { 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 })
|
await fs.mkdir(join(outputDir, 'avatars'), { recursive: true })
|
||||||
const client = chat.getChatDb()?.getWcdb4Client()
|
|
||||||
const avatarUsernames = Array.from(
|
const avatarUsernames = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
messages
|
messages
|
||||||
@@ -564,9 +729,13 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
|||||||
if (!target.avatarUrl) continue
|
if (!target.avatarUrl) continue
|
||||||
const resolved = await readAvatarAsset(target.avatarUrl)
|
const resolved = await readAvatarAsset(target.avatarUrl)
|
||||||
if (!resolved) continue
|
if (!resolved) continue
|
||||||
const avatarName = `conversation_${hashPart(target.userMd5)}.${resolved.extension}`
|
const avatarName = avatarFileName(resolved.buffer, resolved.extension)
|
||||||
|
const avatarUrl = `avatars/${avatarName}`
|
||||||
|
if (!(await resourceExists(avatarUrl))) {
|
||||||
await fs.writeFile(join(outputDir, 'avatars', avatarName), resolved.buffer)
|
await fs.writeFile(join(outputDir, 'avatars', avatarName), resolved.buffer)
|
||||||
conversationAvatarUrls.set(target.userMd5, `avatars/${avatarName}`)
|
markResourceExists(avatarUrl)
|
||||||
|
}
|
||||||
|
conversationAvatarUrls.set(target.userMd5, avatarUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const voiceService =
|
const voiceService =
|
||||||
@@ -577,7 +746,12 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
|||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
if (!jobs.has(request.jobId)) throw new Error('已取消')
|
if (!jobs.has(request.jobId)) throw new Error('已取消')
|
||||||
if (kindOf(message) !== 'voice') continue
|
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) {
|
if (!message.sessionId || message.localId == null || !message.createTime) {
|
||||||
keepMediaError(request, message, '语音标识不完整,无法定位本地语音')
|
keepMediaError(request, message, '语音标识不完整,无法定位本地语音')
|
||||||
continue
|
continue
|
||||||
@@ -599,10 +773,14 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
|||||||
keepMediaError(request, message, reason)
|
keepMediaError(request, message, reason)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const voiceName = `voice_${hashPart(exportMessageKey(message, conversationId))}.wav`
|
|
||||||
const audioBuffer = Buffer.from(voice.data, 'base64')
|
const audioBuffer = Buffer.from(voice.data, 'base64')
|
||||||
|
const voiceName = `voice_${bufferHashPart(audioBuffer)}.wav`
|
||||||
|
const voiceUrl = `voices/${voiceName}`
|
||||||
|
if (!(await resourceExists(voiceUrl))) {
|
||||||
await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer)
|
await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer)
|
||||||
message.voiceDataUrl = `voices/${voiceName}`
|
markResourceExists(voiceUrl)
|
||||||
|
}
|
||||||
|
message.voiceDataUrl = voiceUrl
|
||||||
message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2)))
|
message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2)))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
keepMediaError(
|
keepMediaError(
|
||||||
@@ -632,20 +810,26 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
|||||||
}
|
}
|
||||||
const conversationId = message.exportConversationId || targets[0].userMd5
|
const conversationId = message.exportConversationId || targets[0].userMd5
|
||||||
message.exportShowAvatar = request.includeAvatars !== false
|
message.exportShowAvatar = request.includeAvatars !== false
|
||||||
|
const previous = reusablePreviousMessages.get(message)
|
||||||
const avatar = (message.senderId ? avatarMap[message.senderId] : undefined) || message.img
|
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}:${
|
const avatarKey = `${conversationId}:${
|
||||||
message.senderId || avatar || `message_${exportMessageKey(message, conversationId)}`
|
message.senderId || avatar || `message_${exportMessageKey(message, conversationId)}`
|
||||||
}`
|
}`
|
||||||
let avatarName = exportedAvatars.get(avatarKey)
|
let avatarName = exportedAvatars.get(avatarKey)
|
||||||
if (!avatarName) {
|
if (!avatarName && avatar) {
|
||||||
avatarName = `avatar_${hashPart(avatarKey)}.${avatarExtension}`
|
const resolvedAvatar = await readAvatarAsset(avatar)
|
||||||
await fs.writeFile(join(outputDir, 'avatars', avatarName), avatarBuffer)
|
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)
|
exportedAvatars.set(avatarKey, avatarName)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (avatarName) {
|
||||||
message.exportAvatarUrl = `avatars/${avatarName}`
|
message.exportAvatarUrl = `avatars/${avatarName}`
|
||||||
}
|
}
|
||||||
if (!request.includeMedia || !message.contentData) {
|
if (!request.includeMedia || !message.contentData) {
|
||||||
@@ -658,6 +842,32 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
|||||||
})
|
})
|
||||||
continue
|
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 (message.contentData.type === 'image') {
|
||||||
if (!imageService) {
|
if (!imageService) {
|
||||||
keepMediaError(request, message, '未配置图片解密密钥,无法导出图片')
|
keepMediaError(request, message, '未配置图片解密密钥,无法导出图片')
|
||||||
@@ -698,9 +908,13 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
|||||||
}
|
}
|
||||||
const decoded = decryptedImage ? decodeDataUrl(decryptedImage.data) : null
|
const decoded = decryptedImage ? decodeDataUrl(decryptedImage.data) : null
|
||||||
if (decoded) {
|
if (decoded) {
|
||||||
const name = `image_${hashPart(exportMessageKey(message, conversationId))}.${decoded.extension}`
|
const name = `image_${bufferHashPart(decoded.buffer)}.${decoded.extension}`
|
||||||
|
const mediaUrl = `media/${name}`
|
||||||
|
if (!(await resourceExists(mediaUrl))) {
|
||||||
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
|
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
|
||||||
message.exportMediaUrl = `media/${name}`
|
markResourceExists(mediaUrl)
|
||||||
|
}
|
||||||
|
message.exportMediaUrl = mediaUrl
|
||||||
message.exportMediaType = 'image'
|
message.exportMediaType = 'image'
|
||||||
if (usedFallback) {
|
if (usedFallback) {
|
||||||
keepMediaError(request, message, '原图不可用,已降级使用缩略图')
|
keepMediaError(request, message, '原图不可用,已降级使用缩略图')
|
||||||
@@ -740,9 +954,13 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
|||||||
} else if (extname(source).toLowerCase() !== '.mp4') {
|
} else if (extname(source).toLowerCase() !== '.mp4') {
|
||||||
keepMediaError(request, message, '视频格式不支持,仅支持本地 MP4 文件')
|
keepMediaError(request, message, '视频格式不支持,仅支持本地 MP4 文件')
|
||||||
} else {
|
} else {
|
||||||
const name = `video_${hashPart(exportMessageKey(message, conversationId))}.mp4`
|
const name = `video_${await fileHashPart(source)}.mp4`
|
||||||
|
const mediaUrl = `media/${name}`
|
||||||
|
if (!(await resourceExists(mediaUrl))) {
|
||||||
await copyWritableExportFile(source, join(outputDir, 'media', name))
|
await copyWritableExportFile(source, join(outputDir, 'media', name))
|
||||||
message.exportMediaUrl = `media/${name}`
|
markResourceExists(mediaUrl)
|
||||||
|
}
|
||||||
|
message.exportMediaUrl = mediaUrl
|
||||||
message.exportMediaType = 'video'
|
message.exportMediaType = 'video'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -755,9 +973,13 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
|||||||
? await readAvatarAsset(stickerSource)
|
? await readAvatarAsset(stickerSource)
|
||||||
: null
|
: null
|
||||||
if (decoded) {
|
if (decoded) {
|
||||||
const name = `sticker_${hashPart(exportMessageKey(message, conversationId))}.${decoded.extension}`
|
const name = `sticker_${bufferHashPart(decoded.buffer)}.${decoded.extension}`
|
||||||
|
const mediaUrl = `media/${name}`
|
||||||
|
if (!(await resourceExists(mediaUrl))) {
|
||||||
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
|
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
|
||||||
message.exportMediaUrl = `media/${name}`
|
markResourceExists(mediaUrl)
|
||||||
|
}
|
||||||
|
message.exportMediaUrl = mediaUrl
|
||||||
message.exportMediaType = 'sticker'
|
message.exportMediaType = 'sticker'
|
||||||
} else {
|
} else {
|
||||||
keepMediaError(request, message, result.error || '表情资源缺失或下载失败')
|
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) {
|
if (!resolved.success || !resolved.filePath || !resolved.fileName) {
|
||||||
keepMediaError(request, message, resolved.error || '本地文件附件缺失')
|
keepMediaError(request, message, resolved.error || '本地文件附件缺失')
|
||||||
} else {
|
} else {
|
||||||
const name = `file_${hashPart(exportMessageKey(message, conversationId))}_${safeFilePart(resolved.fileName)}`
|
const name = `file_${await fileHashPart(resolved.filePath)}_${safeFilePart(resolved.fileName)}`
|
||||||
await copyWritableExportFile(resolved.filePath, join(outputDir, 'media', name))
|
const mediaUrl = `files/${name}`
|
||||||
message.exportMediaUrl = `media/${name}`
|
if (!(await resourceExists(mediaUrl))) {
|
||||||
|
await copyWritableExportFile(resolved.filePath, join(outputDir, 'files', name))
|
||||||
|
markResourceExists(mediaUrl)
|
||||||
|
}
|
||||||
|
message.exportMediaUrl = mediaUrl
|
||||||
message.exportMediaType = 'file'
|
message.exportMediaType = 'file'
|
||||||
message.exportMediaName = message.contentData.title || resolved.fileName
|
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)
|
targets.map((target) => target.userMd5)
|
||||||
)
|
)
|
||||||
const normalizedMessages = normalizeHtmlArchiveSelfNames(mergedMessages, selfInfo)
|
const normalizedMessages = stripHtmlArchiveInlineAvatars(
|
||||||
|
normalizeHtmlArchiveSelfNames(mergedMessages, selfInfo)
|
||||||
|
)
|
||||||
const archive: HtmlExportArchive = {
|
const archive: HtmlExportArchive = {
|
||||||
version: 2,
|
version: 2,
|
||||||
name: archiveName,
|
name: archiveName,
|
||||||
@@ -813,6 +1041,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
|||||||
if (!jobs.has(request.jobId)) throw new Error('已取消')
|
if (!jobs.has(request.jobId)) throw new Error('已取消')
|
||||||
await fs.writeFile(outputPath, renderExportPage(archiveName), 'utf8')
|
await fs.writeFile(outputPath, renderExportPage(archiveName), 'utf8')
|
||||||
await writeHtmlArchive(outputDir, archive)
|
await writeHtmlArchive(outputDir, archive)
|
||||||
|
await pruneHtmlArchiveResources(outputDir, archive)
|
||||||
let completedPath = outputPath
|
let completedPath = outputPath
|
||||||
if (request.zip) {
|
if (request.zip) {
|
||||||
if (!jobs.has(request.jobId)) return { success: false, error: '已取消' }
|
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)
|
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 {
|
export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
|
||||||
if (!dbRef) return null
|
if (!dbRef) return null
|
||||||
const wcdb4Client = dbRef.getWcdb4Client()
|
const wcdb4Client = dbRef.getWcdb4Client()
|
||||||
|
|||||||
@@ -40,12 +40,13 @@ let archivePath = ''
|
|||||||
let writeTimer: NodeJS.Timeout | null = null
|
let writeTimer: NodeJS.Timeout | null = null
|
||||||
let writeQueue: Promise<void> = Promise.resolve()
|
let writeQueue: Promise<void> = Promise.resolve()
|
||||||
|
|
||||||
function messageIdentity(message: Message): string {
|
export function messageIdentity(message: Message): string {
|
||||||
if (message.recoveredFromRecallJournal) {
|
if (message.recoveredFromRecallJournal) {
|
||||||
return `recovered:${message.localId || 0}:${message.serverId || message.id}`
|
return `recovered:${message.localId || 0}:${message.serverId || message.id}`
|
||||||
}
|
}
|
||||||
if (message.localId) return `local:${message.localId}`
|
const serverId = String(message.serverId || '').trim()
|
||||||
if (message.serverId) return `server:${message.serverId}`
|
if (serverId && serverId !== '0') return `server:${serverId}`
|
||||||
|
if (message.localId) return `local:${message.localId}:${message.createTime || 0}`
|
||||||
if (message.id) return `id:${message.id}`
|
if (message.id) return `id:${message.id}`
|
||||||
return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}`
|
return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1286,10 +1286,12 @@ export class Wcdb4Client {
|
|||||||
const merged = new Map<string, Wcdb4Message>()
|
const merged = new Map<string, Wcdb4Message>()
|
||||||
for (const message of [...recovered, ...current]) {
|
for (const message of [...recovered, ...current]) {
|
||||||
const recoveredRow = Boolean(message.raw?.['_wxe_recovered'])
|
const recoveredRow = Boolean(message.raw?.['_wxe_recovered'])
|
||||||
const identity = message.mesLocalID
|
const serverId = String(message.serverId || '').trim()
|
||||||
? `local:${message.mesLocalID}`
|
const identity =
|
||||||
: message.serverId
|
serverId && serverId !== '0'
|
||||||
? `server:${message.serverId}`
|
? `server:${serverId}`
|
||||||
|
: message.mesLocalID
|
||||||
|
? `local:${message.mesLocalID}:${message.msgCreateTime || 0}`
|
||||||
: `${message.msgCreateTime}:${message.msgContent}`
|
: `${message.msgCreateTime}:${message.msgContent}`
|
||||||
const key = recoveredRow ? `recovered:${identity}` : identity
|
const key = recoveredRow ? `recovered:${identity}` : identity
|
||||||
merged.set(key, message)
|
merged.set(key, message)
|
||||||
|
|||||||
+48
-3
@@ -34,6 +34,24 @@ export interface GroupMemberInfo {
|
|||||||
m_nsHeadImgUrl: string
|
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 {
|
export class WechatDb {
|
||||||
private wcdb4Client: Wcdb4Client
|
private wcdb4Client: Wcdb4Client
|
||||||
private chatMd5ToUsername = new Map<string, string>()
|
private chatMd5ToUsername = new Map<string, string>()
|
||||||
@@ -170,8 +188,8 @@ export class WechatDb {
|
|||||||
const username = this.chatMd5ToUsername.get(userMd5)
|
const username = this.chatMd5ToUsername.get(userMd5)
|
||||||
if (!username) return []
|
if (!username) return []
|
||||||
return this.wcdb4Client.getMessages(username, startTime, endTime, options).map((message) => ({
|
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)
|
const username = this.chatMd5ToUsername.get(userMd5)
|
||||||
if (!username) return []
|
if (!username) return []
|
||||||
const messages = await this.wcdb4Client.getMessagesAsync(username, startTime, endTime, options)
|
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 {
|
public searchAllMessages(keyword: string): string | null {
|
||||||
|
|||||||
@@ -168,3 +168,360 @@ test('EXPORT-ARCHIVE-02 legacy single-chat archive keeps its original layout', a
|
|||||||
rmSync(outputDir, { recursive: true, force: true })
|
rmSync(outputDir, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('EXPORT-ARCHIVE-04 timeline follows the latest visible month after changing tabs', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
const outputDir = mkdtempSync(join(tmpdir(), 'wxe-timeline-sync-e2e-'))
|
||||||
|
try {
|
||||||
|
const dataPath = join(outputDir, 'data', 'messages.js')
|
||||||
|
mkdirSync(dirname(dataPath), { recursive: true })
|
||||||
|
writeFileSync(join(outputDir, 'index.html'), renderExportPage('时间轴同步档案'), 'utf8')
|
||||||
|
const oldVoiceMessages = Array.from({ length: 240 }, (_, index) => ({
|
||||||
|
...archiveMessage(
|
||||||
|
`old-voice-${index}`,
|
||||||
|
'timeline',
|
||||||
|
'时间轴同步档案',
|
||||||
|
`旧语音-${index}`,
|
||||||
|
Date.UTC(2006 + Math.floor(index / 12), index % 12, 1) / 1000
|
||||||
|
),
|
||||||
|
type: '语音'
|
||||||
|
}))
|
||||||
|
writeFileSync(
|
||||||
|
dataPath,
|
||||||
|
`window.__WECHAT_EXPORT__ = ${JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
sourceId: 'timeline',
|
||||||
|
name: '时间轴同步档案',
|
||||||
|
exportedAt: '2026-08-04T00:00:00.000Z',
|
||||||
|
messages: [
|
||||||
|
...oldVoiceMessages,
|
||||||
|
{
|
||||||
|
...archiveMessage(
|
||||||
|
'latest-voice',
|
||||||
|
'timeline',
|
||||||
|
'时间轴同步档案',
|
||||||
|
'最新语音',
|
||||||
|
1_775_520_000
|
||||||
|
),
|
||||||
|
type: '语音'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})};\n`,
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 })
|
||||||
|
await page.goto(pathToFileURL(join(outputDir, 'index.html')).href)
|
||||||
|
await page.getByRole('button', { name: '语音', exact: true }).click()
|
||||||
|
|
||||||
|
const messages = page.locator('#messages')
|
||||||
|
const activeMonth = page.locator('.timeline-month.active')
|
||||||
|
await expect(activeMonth).toHaveAttribute('data-month', '2026-04')
|
||||||
|
expect(
|
||||||
|
await messages.evaluate(
|
||||||
|
(element) => element.scrollHeight - element.scrollTop - element.clientHeight
|
||||||
|
)
|
||||||
|
).toBeLessThanOrEqual(2)
|
||||||
|
const timelinePosition = await activeMonth.evaluate((element) => {
|
||||||
|
const button = element.getBoundingClientRect()
|
||||||
|
const timeline = element.parentElement!.getBoundingClientRect()
|
||||||
|
return {
|
||||||
|
buttonTop: button.top,
|
||||||
|
buttonBottom: button.bottom,
|
||||||
|
timelineTop: timeline.top,
|
||||||
|
timelineBottom: timeline.bottom
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(timelinePosition.buttonTop).toBeGreaterThanOrEqual(timelinePosition.timelineTop)
|
||||||
|
expect(timelinePosition.buttonBottom).toBeLessThanOrEqual(timelinePosition.timelineBottom + 1)
|
||||||
|
|
||||||
|
await messages.evaluate((element) => {
|
||||||
|
element.scrollTop = 0
|
||||||
|
})
|
||||||
|
await expect(activeMonth).toHaveAttribute('data-month', '2006-01')
|
||||||
|
} finally {
|
||||||
|
rmSync(outputDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('EXPORT-ARCHIVE-05 each message tab restores its previous scroll anchor', async ({ page }) => {
|
||||||
|
const outputDir = mkdtempSync(join(tmpdir(), 'wxe-tab-position-e2e-'))
|
||||||
|
try {
|
||||||
|
const dataPath = join(outputDir, 'data', 'messages.js')
|
||||||
|
mkdirSync(dirname(dataPath), { recursive: true })
|
||||||
|
writeFileSync(join(outputDir, 'index.html'), renderExportPage('Tab 位置档案'), 'utf8')
|
||||||
|
const messages = Array.from({ length: 600 }, (_, index) => ({
|
||||||
|
...archiveMessage(
|
||||||
|
`message-${index}`,
|
||||||
|
'tab-position',
|
||||||
|
'Tab 位置档案',
|
||||||
|
`${index % 2 === 0 ? '文字' : '语音'}消息-${index}`,
|
||||||
|
1_735_689_600 + index * 86_400
|
||||||
|
),
|
||||||
|
type: index % 2 === 0 ? '普通文本' : '语音'
|
||||||
|
}))
|
||||||
|
writeFileSync(
|
||||||
|
dataPath,
|
||||||
|
`window.__WECHAT_EXPORT__ = ${JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
sourceId: 'tab-position',
|
||||||
|
name: 'Tab 位置档案',
|
||||||
|
exportedAt: '2026-08-04T00:00:00.000Z',
|
||||||
|
messages
|
||||||
|
})};\n`,
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 })
|
||||||
|
await page.goto(pathToFileURL(join(outputDir, 'index.html')).href)
|
||||||
|
await page.getByRole('button', { name: '文字', exact: true }).click()
|
||||||
|
|
||||||
|
const messageList = page.locator('#messages')
|
||||||
|
const target = page.locator('.message[data-index="100"]')
|
||||||
|
await target.evaluate((element) => {
|
||||||
|
const list = element.parentElement!
|
||||||
|
list.scrollTop += element.getBoundingClientRect().top - list.getBoundingClientRect().top - 37
|
||||||
|
})
|
||||||
|
await expect(target).toBeInViewport()
|
||||||
|
const before = await target.evaluate((element) => {
|
||||||
|
const message = element.getBoundingClientRect()
|
||||||
|
const list = element.parentElement!.getBoundingClientRect()
|
||||||
|
return message.top - list.top
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '语音', exact: true }).click()
|
||||||
|
await page.getByRole('button', { name: '文字', exact: true }).click()
|
||||||
|
|
||||||
|
await expect(target).toBeInViewport()
|
||||||
|
const after = await target.evaluate((element) => {
|
||||||
|
const message = element.getBoundingClientRect()
|
||||||
|
const list = element.parentElement!.getBoundingClientRect()
|
||||||
|
return message.top - list.top
|
||||||
|
})
|
||||||
|
expect(Math.abs(after - before)).toBeLessThanOrEqual(1)
|
||||||
|
expect(
|
||||||
|
await messageList.evaluate(
|
||||||
|
(element) => element.scrollHeight - element.scrollTop - element.clientHeight
|
||||||
|
)
|
||||||
|
).toBeGreaterThan(100)
|
||||||
|
} finally {
|
||||||
|
rmSync(outputDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('EXPORT-ARCHIVE-03 renders shares and locations, and groups payments under system', async ({
|
||||||
|
page
|
||||||
|
}, testInfo) => {
|
||||||
|
const outputDir = mkdtempSync(join(tmpdir(), 'wxe-structured-archive-e2e-'))
|
||||||
|
try {
|
||||||
|
const dataPath = join(outputDir, 'data', 'messages.js')
|
||||||
|
mkdirSync(dirname(dataPath), { recursive: true })
|
||||||
|
writeFileSync(join(outputDir, 'index.html'), renderExportPage('结构化消息档案'), 'utf8')
|
||||||
|
const message = (
|
||||||
|
id: string,
|
||||||
|
type: string,
|
||||||
|
createTime: number,
|
||||||
|
contentData: Message['contentData']
|
||||||
|
): Message => ({
|
||||||
|
...archiveMessage(id, 'structured', '结构化消息档案', '', createTime),
|
||||||
|
type,
|
||||||
|
contentData
|
||||||
|
})
|
||||||
|
writeFileSync(
|
||||||
|
dataPath,
|
||||||
|
`window.__WECHAT_EXPORT__ = ${JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
sourceId: 'structured',
|
||||||
|
name: '结构化消息档案',
|
||||||
|
exportedAt: '2026-08-04T12:57:32.000Z',
|
||||||
|
messages: [
|
||||||
|
message('article', '公众号链接', 1_775_000_001, {
|
||||||
|
type: 'share',
|
||||||
|
typeVal: '5',
|
||||||
|
title: '真正的公众号标题',
|
||||||
|
des: '文章摘要与关键内容',
|
||||||
|
appname: '示例公众号',
|
||||||
|
url: 'https://example.com/article?a=1&b=2'
|
||||||
|
}),
|
||||||
|
message('mini', '小程序', 1_775_000_002, {
|
||||||
|
type: 'miniProgram',
|
||||||
|
title: '小程序商品标题',
|
||||||
|
description: '商品的真实描述',
|
||||||
|
appName: '示例小程序'
|
||||||
|
}),
|
||||||
|
message('channel', '视频号', 1_775_000_003, {
|
||||||
|
type: 'share',
|
||||||
|
typeVal: '51',
|
||||||
|
title: '当前微信版本不支持展示该内容,请升级至最新版本。',
|
||||||
|
des: '视频号真实标题\n视频号正文内容',
|
||||||
|
url: 'https://example.com/channel'
|
||||||
|
}),
|
||||||
|
message('forward', '合并转发', 1_775_000_004, {
|
||||||
|
type: 'forwardBundle',
|
||||||
|
title: '项目群的聊天记录',
|
||||||
|
description: '项目成员: 项目结论',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
messageType: 1,
|
||||||
|
sender: '项目成员',
|
||||||
|
sentAt: '2026-08-04 20:00',
|
||||||
|
text: '项目结论已经确认'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
message('red-packet', '微信红包', 1_775_000_005, {
|
||||||
|
type: 'redPacket',
|
||||||
|
title: '微信红包',
|
||||||
|
description: '我给你发了一个红包'
|
||||||
|
}),
|
||||||
|
message('transfer', '转账', 1_775_000_006, {
|
||||||
|
type: 'share',
|
||||||
|
typeVal: '2000',
|
||||||
|
title: '微信转账',
|
||||||
|
des: '收到转账¥1000.00元',
|
||||||
|
url: ''
|
||||||
|
}),
|
||||||
|
message('voip', '通话', 1_775_000_007, {
|
||||||
|
type: 'voip',
|
||||||
|
status: '通话时长 2分15秒'
|
||||||
|
}),
|
||||||
|
message('location', '位置', 1_775_000_008, {
|
||||||
|
type: 'location',
|
||||||
|
poiname: '望和公园南园',
|
||||||
|
label: '北京市朝阳区望京街道北四环东路41号望和公园',
|
||||||
|
lat: 39.986984,
|
||||||
|
lng: 116.448578
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
...message('legacy-recall', '系统消息', 1_775_000_009, {
|
||||||
|
type: 'system',
|
||||||
|
content: '"联系人" 撤回了一条消息'
|
||||||
|
}),
|
||||||
|
from: 'system',
|
||||||
|
content: '"联系人" 撤回了一条消息',
|
||||||
|
name: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...message('structured-recall', '系统消息', 1_775_000_010, {
|
||||||
|
type: 'system',
|
||||||
|
content: '你撤回了一条消息',
|
||||||
|
recall: {
|
||||||
|
targetId: 'fixture-target',
|
||||||
|
replacement: '你撤回了一条消息',
|
||||||
|
actor: '你'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
from: 'system',
|
||||||
|
content: '你撤回了一条消息',
|
||||||
|
name: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...message('location-sharing-ended', '系统消息', 1_775_000_011, {
|
||||||
|
type: 'system',
|
||||||
|
content: '位置共享已经结束'
|
||||||
|
}),
|
||||||
|
from: 'system',
|
||||||
|
content: '位置共享已经结束',
|
||||||
|
name: ''
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})};\n`,
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 })
|
||||||
|
await page.goto(pathToFileURL(join(outputDir, 'index.html')).href)
|
||||||
|
await expect(page.locator('[data-rich-kind="share"]')).toHaveCount(2)
|
||||||
|
await expect(page.getByText('真正的公众号标题')).toBeVisible()
|
||||||
|
await expect(page.getByText('文章摘要与关键内容')).toBeVisible()
|
||||||
|
await expect(page.getByText('小程序商品标题')).toBeVisible()
|
||||||
|
await expect(page.getByText('视频号真实标题')).toBeVisible()
|
||||||
|
await expect(page.getByText('当前微信版本不支持展示该内容,请升级至最新版本。')).toHaveCount(0)
|
||||||
|
await expect(page.getByText('项目群的聊天记录')).toBeVisible()
|
||||||
|
await page.getByText('展开 1 条消息').click()
|
||||||
|
await expect(page.getByText('项目结论已经确认')).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '分享', exact: true }).click()
|
||||||
|
await expect(page.locator('.message')).toHaveCount(5)
|
||||||
|
await expect(page.locator('[data-rich-kind="forwardBundle"]')).toHaveCount(1)
|
||||||
|
const locationCard = page.locator('[data-rich-kind="location"]')
|
||||||
|
await expect(locationCard).toHaveCount(1)
|
||||||
|
await expect(locationCard.getByText('望和公园南园')).toBeVisible()
|
||||||
|
await expect(locationCard.getByText('北京市朝阳区望京街道北四环东路41号望和公园')).toBeVisible()
|
||||||
|
await expect(locationCard.getByText('39.986984, 116.448578')).toBeVisible()
|
||||||
|
await expect(locationCard.getByText('在地图中打开')).toBeVisible()
|
||||||
|
await expect(locationCard.locator('xpath=ancestor::a')).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
/^https:\/\/maps\.apple\.com\/\?q=.*&ll=39\.986984,116\.448578$/
|
||||||
|
)
|
||||||
|
await expect(page.locator('.content', { hasText: '[位置]' })).toHaveCount(0)
|
||||||
|
await expect(page.locator('[data-rich-kind="transfer"]')).toHaveCount(0)
|
||||||
|
const locationMessage = locationCard.locator('xpath=ancestor::article')
|
||||||
|
const locateButton = locationMessage.getByRole('button', { name: '定位到聊天位置' })
|
||||||
|
const locateLabel = locateButton.locator('.locate-label')
|
||||||
|
await page.mouse.move(0, 0)
|
||||||
|
await expect(locateButton).toHaveCSS('opacity', '0')
|
||||||
|
await locationMessage.hover()
|
||||||
|
await expect(locateButton).toHaveCSS('opacity', '1')
|
||||||
|
await expect(locateLabel).toHaveCSS('opacity', '0')
|
||||||
|
await locateButton.hover()
|
||||||
|
await expect(locateLabel).toHaveCSS('opacity', '1')
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('locate-hover-1440.png') })
|
||||||
|
await locateButton.click()
|
||||||
|
await expect(page.getByRole('button', { name: '全部', exact: true })).toHaveClass(/active/)
|
||||||
|
await expect(page.locator('.message.located')).toContainText('望和公园南园')
|
||||||
|
const locatedPosition = await page.locator('.message.located').evaluate((element) => {
|
||||||
|
const messageRect = element.getBoundingClientRect()
|
||||||
|
const listRect = element.parentElement!.getBoundingClientRect()
|
||||||
|
return {
|
||||||
|
messageTop: messageRect.top,
|
||||||
|
messageBottom: messageRect.bottom,
|
||||||
|
listTop: listRect.top,
|
||||||
|
listBottom: listRect.bottom
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(locatedPosition.messageBottom).toBeGreaterThan(locatedPosition.listTop)
|
||||||
|
expect(locatedPosition.messageTop).toBeLessThan(locatedPosition.listBottom)
|
||||||
|
await page.locator('#messages').evaluate((element) => {
|
||||||
|
element.scrollTop = 0
|
||||||
|
})
|
||||||
|
await page.screenshot({
|
||||||
|
path: testInfo.outputPath('structured-archive-1440.png'),
|
||||||
|
fullPage: true
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '系统 / 其他', exact: true }).click()
|
||||||
|
await expect(page.locator('.message')).toHaveCount(6)
|
||||||
|
await expect(page.getByText('我给你发了一个红包')).toBeVisible()
|
||||||
|
await expect(page.getByText('收到转账¥1000.00元')).toBeVisible()
|
||||||
|
await expect(page.getByText('通话时长 2分15秒')).toBeVisible()
|
||||||
|
const systemNotices = page.locator('.message.system')
|
||||||
|
await expect(systemNotices).toHaveCount(3)
|
||||||
|
const recallNotices = systemNotices.filter({ hasText: '撤回了一条消息' })
|
||||||
|
await expect(recallNotices).toHaveCount(2)
|
||||||
|
await expect(recallNotices.locator('.avatar')).toHaveCount(0)
|
||||||
|
await expect(recallNotices.locator('.sender').first()).toBeHidden()
|
||||||
|
const locationNotice = systemNotices.filter({ hasText: '位置共享已经结束' })
|
||||||
|
await expect(locationNotice).toHaveCount(1)
|
||||||
|
await expect(locationNotice.locator('.avatar')).toHaveCount(0)
|
||||||
|
const recallAlignment = await recallNotices.first().evaluate((element) => {
|
||||||
|
const messageRect = element.getBoundingClientRect()
|
||||||
|
const bubbleRect = element.querySelector('.bubble')!.getBoundingClientRect()
|
||||||
|
return Math.abs(
|
||||||
|
messageRect.left + messageRect.width / 2 - (bubbleRect.left + bubbleRect.width / 2)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
expect(recallAlignment).toBeLessThan(1)
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 })
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||||
|
).toBe(true)
|
||||||
|
await page.screenshot({
|
||||||
|
path: testInfo.outputPath('structured-archive-390.png'),
|
||||||
|
fullPage: true
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
rmSync(outputDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
readdirSync,
|
readdirSync,
|
||||||
rmSync,
|
rmSync,
|
||||||
statSync,
|
statSync,
|
||||||
|
unlinkSync,
|
||||||
|
utimesSync,
|
||||||
writeFileSync
|
writeFileSync
|
||||||
} from 'fs'
|
} from 'fs'
|
||||||
import { tmpdir } from 'os'
|
import { tmpdir } from 'os'
|
||||||
@@ -22,6 +24,8 @@ const state = vi.hoisted(() => ({
|
|||||||
videoPath: '',
|
videoPath: '',
|
||||||
messages: [] as Message[],
|
messages: [] as Message[],
|
||||||
messagesByUser: {} as Record<string, Message[]>,
|
messagesByUser: {} as Record<string, Message[]>,
|
||||||
|
exportReads: [] as string[],
|
||||||
|
voiceLookups: [] as number[],
|
||||||
videoLookups: [] as {
|
videoLookups: [] as {
|
||||||
createTime?: number
|
createTime?: number
|
||||||
duration?: number
|
duration?: number
|
||||||
@@ -46,8 +50,15 @@ vi.mock('../../src/main/services/chat-service', () => ({
|
|||||||
listMessages: () => structuredClone(state.messages),
|
listMessages: () => structuredClone(state.messages),
|
||||||
listMessagesAsync: async (userMd5: string) =>
|
listMessagesAsync: async (userMd5: string) =>
|
||||||
structuredClone(state.messagesByUser[userMd5] || state.messages),
|
structuredClone(state.messagesByUser[userMd5] || state.messages),
|
||||||
|
listMessagesForExport: async (userMd5: string) => {
|
||||||
|
state.exportReads.push(userMd5)
|
||||||
|
return structuredClone(state.messagesByUser[userMd5] || state.messages)
|
||||||
|
},
|
||||||
getChatDb: () => ({
|
getChatDb: () => ({
|
||||||
getWcdb4Client: () => ({ getAccountRoot: () => state.accountRoot })
|
getWcdb4Client: () => ({
|
||||||
|
getAccountRoot: () => state.accountRoot,
|
||||||
|
getUsernameByMd5: (userMd5: string) => `wxid_${userMd5}`
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
getContactAvatars: () => ({}),
|
getContactAvatars: () => ({}),
|
||||||
getSelfAccountInfoAsync: async () => ({
|
getSelfAccountInfoAsync: async () => ({
|
||||||
@@ -69,6 +80,7 @@ vi.mock('../../src/main/voice-service', () => ({
|
|||||||
_sessionId: string,
|
_sessionId: string,
|
||||||
localId: number
|
localId: number
|
||||||
): Promise<{ success: boolean; data?: string; error?: string }> {
|
): Promise<{ success: boolean; data?: string; error?: string }> {
|
||||||
|
state.voiceLookups.push(localId)
|
||||||
return localId === 1
|
return localId === 1
|
||||||
? {
|
? {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -187,6 +199,8 @@ describe('media export flow', () => {
|
|||||||
state.imageLookups = []
|
state.imageLookups = []
|
||||||
state.videoLookups = []
|
state.videoLookups = []
|
||||||
state.messagesByUser = {}
|
state.messagesByUser = {}
|
||||||
|
state.exportReads = []
|
||||||
|
state.voiceLookups = []
|
||||||
const fileMonth = join(state.accountRoot, 'msg', 'file', '2026-08')
|
const fileMonth = join(state.accountRoot, 'msg', 'file', '2026-08')
|
||||||
mkdirSync(fileMonth, { recursive: true })
|
mkdirSync(fileMonth, { recursive: true })
|
||||||
writeFileSync(join(fileMonth, '测试附件.txt'), '附件内容')
|
writeFileSync(join(fileMonth, '测试附件.txt'), '附件内容')
|
||||||
@@ -272,7 +286,7 @@ describe('media export flow', () => {
|
|||||||
expect(html).toContain('<script src="data/messages.js"></script>')
|
expect(html).toContain('<script src="data/messages.js"></script>')
|
||||||
expect(voice.voiceDataUrl).toMatch(/^voices\/voice_[0-9a-f]{16}\.wav$/)
|
expect(voice.voiceDataUrl).toMatch(/^voices\/voice_[0-9a-f]{16}\.wav$/)
|
||||||
expect(video.exportMediaUrl).toMatch(/^media\/video_[0-9a-f]{16}\.mp4$/)
|
expect(video.exportMediaUrl).toMatch(/^media\/video_[0-9a-f]{16}\.mp4$/)
|
||||||
expect(file.exportMediaUrl).toMatch(/^media\/file_[0-9a-f]{16}_测试附件\.txt$/)
|
expect(file.exportMediaUrl).toMatch(/^files\/file_[0-9a-f]{16}_测试附件\.txt$/)
|
||||||
expect(missingVoice.exportMediaError).toBe('语音文件缺失:本地未找到语音数据')
|
expect(missingVoice.exportMediaError).toBe('语音文件缺失:本地未找到语音数据')
|
||||||
expect(state.imageLookups[0]).toMatchObject({
|
expect(state.imageLookups[0]).toMatchObject({
|
||||||
allowThumbnail: false,
|
allowThumbnail: false,
|
||||||
@@ -288,6 +302,42 @@ describe('media export flow', () => {
|
|||||||
height: 630
|
height: 630
|
||||||
})
|
})
|
||||||
expect(progress.length).toBeGreaterThan(0)
|
expect(progress.length).toBeGreaterThan(0)
|
||||||
|
expect(state.exportReads).toEqual(['fixture-user'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps one-to-one sender sides and fills both display names', async () => {
|
||||||
|
const { runExport } = await import('../../src/main/export-service')
|
||||||
|
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
|
||||||
|
state.messages = [
|
||||||
|
message({ id: 'peer-message', content: '对方消息', isSender: false, name: '', senderId: '' }),
|
||||||
|
message({ id: 'self-message', content: '我的消息', isSender: true, name: '', senderId: '' })
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = await runExport(
|
||||||
|
{
|
||||||
|
jobId: 'one-to-one-identity',
|
||||||
|
targets: [target('jamie', 'Jamie')],
|
||||||
|
format: 'html',
|
||||||
|
outputName: 'one-to-one-identity',
|
||||||
|
kinds: ['text'],
|
||||||
|
includeMedia: false
|
||||||
|
},
|
||||||
|
win as never
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.success, result.error).toBe(true)
|
||||||
|
const archive = readArchive(result.outputPath!)
|
||||||
|
expect(
|
||||||
|
archive.messages.map(({ id, isSender, name, senderId }) => ({
|
||||||
|
id,
|
||||||
|
isSender,
|
||||||
|
name,
|
||||||
|
senderId
|
||||||
|
}))
|
||||||
|
).toEqual([
|
||||||
|
{ id: 'peer-message', isSender: false, name: 'Jamie', senderId: 'wxid_jamie' },
|
||||||
|
{ id: 'self-message', isSender: true, name: '濑岛田井卫', senderId: 'a969409112' }
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('incrementally merges the same HTML archive, deduplicates messages, and keeps old media', async () => {
|
it('incrementally merges the same HTML archive, deduplicates messages, and keeps old media', async () => {
|
||||||
@@ -316,6 +366,19 @@ describe('media export flow', () => {
|
|||||||
expect(first.success).toBe(true)
|
expect(first.success).toBe(true)
|
||||||
const firstArchive = readArchive(first.outputPath!)
|
const firstArchive = readArchive(first.outputPath!)
|
||||||
const oldVoiceUrl = firstArchive.messages.find((item) => item.id === 'voice-old')!.voiceDataUrl
|
const oldVoiceUrl = firstArchive.messages.find((item) => item.id === 'voice-old')!.voiceDataUrl
|
||||||
|
const outputDir = dirname(first.outputPath!)
|
||||||
|
firstArchive.messages.find((item) => item.id === 'voice-old')!.img =
|
||||||
|
'data:image/jpeg;base64,bGVnYWN5LWlubGluZS1hdmF0YXI='
|
||||||
|
writeFileSync(
|
||||||
|
join(outputDir, 'data', 'messages.js'),
|
||||||
|
`window.__WECHAT_EXPORT__ = ${JSON.stringify(firstArchive)};\n`,
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
writeFileSync(join(outputDir, 'voices', 'voice_orphan.wav'), 'orphan voice')
|
||||||
|
writeFileSync(join(outputDir, 'media', 'image_orphan.png'), 'orphan image')
|
||||||
|
writeFileSync(join(outputDir, 'media', 'file_orphan.txt'), 'legacy orphan file')
|
||||||
|
writeFileSync(join(outputDir, 'files', 'file_orphan.txt'), 'orphan file')
|
||||||
|
writeFileSync(join(outputDir, 'avatars', 'avatar_orphan.png'), 'orphan avatar')
|
||||||
|
|
||||||
state.messages = [
|
state.messages = [
|
||||||
message({ id: 'text-old', content: '同一条消息已更新', createTime: 1_785_549_660 }),
|
message({ id: 'text-old', content: '同一条消息已更新', createTime: 1_785_549_660 }),
|
||||||
@@ -344,9 +407,80 @@ describe('media export flow', () => {
|
|||||||
expect(secondArchive.messages.find((item) => item.id === 'voice-old')?.voiceDataUrl).toBe(
|
expect(secondArchive.messages.find((item) => item.id === 'voice-old')?.voiceDataUrl).toBe(
|
||||||
oldVoiceUrl
|
oldVoiceUrl
|
||||||
)
|
)
|
||||||
|
expect(secondArchive.messages.every((item) => item.img == null)).toBe(true)
|
||||||
|
expect(existsSync(join(outputDir, oldVoiceUrl!))).toBe(true)
|
||||||
|
expect(existsSync(join(outputDir, 'voices', 'voice_orphan.wav'))).toBe(false)
|
||||||
|
expect(existsSync(join(outputDir, 'media', 'image_orphan.png'))).toBe(false)
|
||||||
|
expect(existsSync(join(outputDir, 'media', 'file_orphan.txt'))).toBe(false)
|
||||||
|
expect(existsSync(join(outputDir, 'files', 'file_orphan.txt'))).toBe(false)
|
||||||
|
expect(existsSync(join(outputDir, 'avatars', 'avatar_orphan.png'))).toBe(false)
|
||||||
expect(existsSync(join(dirname(second.outputPath!), 'data', 'messages.js.bak'))).toBe(true)
|
expect(existsSync(join(dirname(second.outputPath!), 'data', 'messages.js.bak'))).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reuses unchanged resources and retries only missing or unresolved media', async () => {
|
||||||
|
const { runExport } = await import('../../src/main/export-service')
|
||||||
|
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
|
||||||
|
const request = {
|
||||||
|
targets: [target('fixture-user', '资源复用会话')],
|
||||||
|
format: 'html' as const,
|
||||||
|
outputName: 'resource-reuse-fixture',
|
||||||
|
kinds: ['voice', 'image', 'video', 'file'] as const,
|
||||||
|
includeMedia: true,
|
||||||
|
keepMissing: true
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = await runExport(
|
||||||
|
{ ...request, jobId: 'resource-reuse-first', kinds: [...request.kinds] },
|
||||||
|
win as never
|
||||||
|
)
|
||||||
|
expect(first.success, first.error).toBe(true)
|
||||||
|
const firstArchive = readArchive(first.outputPath!)
|
||||||
|
const outputDir = dirname(first.outputPath!)
|
||||||
|
const voicePath = join(
|
||||||
|
outputDir,
|
||||||
|
firstArchive.messages.find((item) => item.id === 'voice-ok')!.voiceDataUrl!
|
||||||
|
)
|
||||||
|
const imagePath = join(
|
||||||
|
outputDir,
|
||||||
|
firstArchive.messages.find((item) => item.id === 'image')!.exportMediaUrl!
|
||||||
|
)
|
||||||
|
const videoPath = join(
|
||||||
|
outputDir,
|
||||||
|
firstArchive.messages.find((item) => item.id === 'video')!.exportMediaUrl!
|
||||||
|
)
|
||||||
|
const filePath = join(
|
||||||
|
outputDir,
|
||||||
|
firstArchive.messages.find((item) => item.id === 'file')!.exportMediaUrl!
|
||||||
|
)
|
||||||
|
const oldTimestamp = new Date(1_000_000)
|
||||||
|
utimesSync(videoPath, oldTimestamp, oldTimestamp)
|
||||||
|
utimesSync(filePath, oldTimestamp, oldTimestamp)
|
||||||
|
|
||||||
|
const second = await runExport(
|
||||||
|
{ ...request, jobId: 'resource-reuse-second', kinds: [...request.kinds] },
|
||||||
|
win as never
|
||||||
|
)
|
||||||
|
expect(second.success, second.error).toBe(true)
|
||||||
|
expect(state.imageLookups).toHaveLength(1)
|
||||||
|
expect(state.videoLookups).toHaveLength(1)
|
||||||
|
expect(state.voiceLookups).toEqual([1, 2, 2])
|
||||||
|
expect(statSync(videoPath).mtimeMs).toBe(oldTimestamp.getTime())
|
||||||
|
expect(statSync(filePath).mtimeMs).toBe(oldTimestamp.getTime())
|
||||||
|
|
||||||
|
unlinkSync(voicePath)
|
||||||
|
unlinkSync(imagePath)
|
||||||
|
const third = await runExport(
|
||||||
|
{ ...request, jobId: 'resource-reuse-third', kinds: [...request.kinds] },
|
||||||
|
win as never
|
||||||
|
)
|
||||||
|
expect(third.success, third.error).toBe(true)
|
||||||
|
expect(state.imageLookups).toHaveLength(2)
|
||||||
|
expect(state.videoLookups).toHaveLength(1)
|
||||||
|
expect(state.voiceLookups).toEqual([1, 2, 2, 1, 2])
|
||||||
|
expect(existsSync(voicePath)).toBe(true)
|
||||||
|
expect(existsSync(imagePath)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps copied videos writable and can replace a legacy read-only video incrementally', async () => {
|
it('keeps copied videos writable and can replace a legacy read-only video incrementally', async () => {
|
||||||
const { runExport } = await import('../../src/main/export-service')
|
const { runExport } = await import('../../src/main/export-service')
|
||||||
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
|
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
|
||||||
@@ -440,10 +574,11 @@ describe('media export flow', () => {
|
|||||||
['beta', 'beta-later', 150],
|
['beta', 'beta-later', 150],
|
||||||
['alpha', 'alpha-later', 200]
|
['alpha', 'alpha-later', 200]
|
||||||
])
|
])
|
||||||
|
expect(state.exportReads).toEqual(['alpha', 'beta'])
|
||||||
const imagePaths = archive.messages
|
const imagePaths = archive.messages
|
||||||
.filter((item) => item.id === 'same-id')
|
.filter((item) => item.id === 'same-id')
|
||||||
.map((item) => item.exportMediaUrl)
|
.map((item) => item.exportMediaUrl)
|
||||||
expect(new Set(imagePaths).size).toBe(2)
|
expect(new Set(imagePaths).size).toBe(1)
|
||||||
for (const imagePath of imagePaths) {
|
for (const imagePath of imagePaths) {
|
||||||
expect(existsSync(join(dirname(result.outputPath!), imagePath!))).toBe(true)
|
expect(existsSync(join(dirname(result.outputPath!), imagePath!))).toBe(true)
|
||||||
}
|
}
|
||||||
@@ -495,9 +630,15 @@ describe('media export flow', () => {
|
|||||||
expect(firstSize).toBeGreaterThan(0)
|
expect(firstSize).toBeGreaterThan(0)
|
||||||
expect(readFileSync(second.outputPath!).subarray(0, 2).toString()).toBe('PK')
|
expect(readFileSync(second.outputPath!).subarray(0, 2).toString()).toBe('PK')
|
||||||
const entries = execFileSync('unzip', ['-Z1', second.outputPath!], { encoding: 'utf8' })
|
const entries = execFileSync('unzip', ['-Z1', second.outputPath!], { encoding: 'utf8' })
|
||||||
|
const htmlPath = join(state.documents, 'WechatExplorer', '导出', 'zip-fixture', 'index.html')
|
||||||
|
const archive = readArchive(htmlPath)
|
||||||
expect(entries).toContain('zip-fixture/index.html')
|
expect(entries).toContain('zip-fixture/index.html')
|
||||||
expect(entries).toContain('zip-fixture/data/messages.js')
|
expect(entries).toContain('zip-fixture/data/messages.js')
|
||||||
expect(entries).toMatch(/zip-fixture\/avatars\/conversation_[0-9a-f]{16}\.png/)
|
const avatarEntries = entries
|
||||||
|
.split('\n')
|
||||||
|
.filter((entry) => /zip-fixture\/avatars\/avatar_[0-9a-f]{16}\.png$/.test(entry))
|
||||||
|
expect(avatarEntries).toHaveLength(1)
|
||||||
|
expect(archive.conversations[0].avatarUrl).toBe(archive.messages[0].exportAvatarUrl)
|
||||||
expect(entries).toMatch(/zip-fixture\/media\/image_[0-9a-f]{16}\.png/)
|
expect(entries).toMatch(/zip-fixture\/media\/image_[0-9a-f]{16}\.png/)
|
||||||
expect(progress.some((args) => (args[1] as { phase?: string })?.phase === 'compressing')).toBe(
|
expect(progress.some((args) => (args[1] as { phase?: string })?.phase === 'compressing')).toBe(
|
||||||
true
|
true
|
||||||
|
|||||||
@@ -148,6 +148,97 @@ describe('export media', () => {
|
|||||||
dom.window.close()
|
dom.window.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('locates every filtered message kind in all messages, including outside the latest window', () => {
|
||||||
|
const html = renderExportPage('定位消息')
|
||||||
|
const dom = new JSDOM(html, { runScripts: 'outside-only' })
|
||||||
|
const categorized: Message[] = [
|
||||||
|
{
|
||||||
|
...messageForArchive('target-text', 'fixture', '定位消息', '目标文字', 1),
|
||||||
|
type: '普通文本'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...messageForArchive('target-media', 'fixture', '定位消息', '', 2),
|
||||||
|
type: '图片',
|
||||||
|
exportMediaType: 'image',
|
||||||
|
exportMediaUrl: 'media/target.jpg'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...messageForArchive('target-voice', 'fixture', '定位消息', '', 3),
|
||||||
|
type: '语音',
|
||||||
|
voiceDataUrl: 'voices/target.wav'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...messageForArchive('target-file', 'fixture', '定位消息', '', 4),
|
||||||
|
type: '文件',
|
||||||
|
exportMediaType: 'file',
|
||||||
|
exportMediaUrl: 'files/target.pdf'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...messageForArchive('target-share', 'fixture', '定位消息', '', 5),
|
||||||
|
type: '分享',
|
||||||
|
contentData: { type: 'share', typeVal: '5', title: '目标分享' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...messageForArchive('target-system', 'fixture', '定位消息', '目标系统消息', 6),
|
||||||
|
from: 'system',
|
||||||
|
type: '系统消息',
|
||||||
|
contentData: { type: 'system', content: '目标系统消息' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
const laterMessages = Array.from({ length: EXPORT_PAGE_SIZE }, (_, index) => ({
|
||||||
|
...messageForArchive(
|
||||||
|
`later-${index}`,
|
||||||
|
'fixture',
|
||||||
|
'定位消息',
|
||||||
|
`稍后消息-${index}`,
|
||||||
|
100 + index
|
||||||
|
),
|
||||||
|
type: '普通文本'
|
||||||
|
}))
|
||||||
|
Object.assign(dom.window, {
|
||||||
|
__WECHAT_EXPORT__: {
|
||||||
|
version: 1,
|
||||||
|
sourceId: 'fixture',
|
||||||
|
name: '定位消息',
|
||||||
|
messages: [...categorized, ...laterMessages]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
dom.window.eval(inlineScriptOf(html))
|
||||||
|
|
||||||
|
expect(dom.window.document.querySelectorAll('.locate-all')).toHaveLength(0)
|
||||||
|
for (const kind of ['media', 'voice', 'file', 'share', 'system']) {
|
||||||
|
const filterButton = dom.window.document.querySelector(`[data-kind="${kind}"]`) as HTMLElement
|
||||||
|
filterButton.click()
|
||||||
|
const locateButton = dom.window.document.querySelector('.locate-all') as HTMLElement
|
||||||
|
expect(locateButton?.getAttribute('aria-label')).toBe('定位到聊天位置')
|
||||||
|
expect(locateButton?.querySelector('.locate-icon')?.textContent).toBe('⌖')
|
||||||
|
expect(locateButton?.querySelector('.locate-label')?.textContent).toBe('定位到聊天位置')
|
||||||
|
locateButton.click()
|
||||||
|
expect(
|
||||||
|
dom.window.document.querySelector('[data-kind="all"]')?.classList.contains('active')
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
dom.window.document.querySelector('.message.located')?.getAttribute('data-index')
|
||||||
|
).toBe(String(categorized.findIndex((message) => kindOfFixture(message) === kind)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const textFilter = dom.window.document.querySelector('[data-kind="text"]') as HTMLElement
|
||||||
|
textFilter.click()
|
||||||
|
const search = dom.window.document.querySelector('#query') as HTMLInputElement
|
||||||
|
search.value = '目标文字'
|
||||||
|
search.dispatchEvent(new dom.window.Event('input'))
|
||||||
|
;(dom.window.document.querySelector('.locate-all') as HTMLElement).click()
|
||||||
|
expect(
|
||||||
|
dom.window.document.querySelector('[data-kind="all"]')?.classList.contains('active')
|
||||||
|
).toBe(true)
|
||||||
|
expect(dom.window.document.querySelector('.message.located')?.getAttribute('data-index')).toBe(
|
||||||
|
'0'
|
||||||
|
)
|
||||||
|
expect(dom.window.document.querySelector('.message.located')?.textContent).toContain('目标文字')
|
||||||
|
dom.window.close()
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps relative media, file download, quote, and missing-media renderers', () => {
|
it('keeps relative media, file download, quote, and missing-media renderers', () => {
|
||||||
const html = renderExportPage('媒体档案')
|
const html = renderExportPage('媒体档案')
|
||||||
|
|
||||||
@@ -190,3 +281,12 @@ function messageForArchive(
|
|||||||
exportConversationName: conversationName
|
exportConversationName: conversationName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function kindOfFixture(message: Message): string {
|
||||||
|
if (message.exportMediaType === 'image') return 'media'
|
||||||
|
if (message.voiceDataUrl) return 'voice'
|
||||||
|
if (message.exportMediaType === 'file') return 'file'
|
||||||
|
if (message.contentData?.type === 'share') return 'share'
|
||||||
|
if (message.contentData?.type === 'system') return 'system'
|
||||||
|
return 'text'
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import crypto from 'crypto'
|
||||||
|
import { mkdirSync, mkdtempSync, writeFileSync } from 'fs'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { Message } from '../../src/shared/types'
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({ userData: '' }))
|
||||||
|
|
||||||
|
vi.mock('electron', () => ({
|
||||||
|
app: { getPath: () => state.userData }
|
||||||
|
}))
|
||||||
|
|
||||||
|
const message = (createTime: number, serverId?: string): Message => ({
|
||||||
|
id: `message-${createTime}`,
|
||||||
|
from: 'user',
|
||||||
|
isSender: false,
|
||||||
|
type: '普通文本',
|
||||||
|
datetime: new Date(createTime * 1000).toISOString(),
|
||||||
|
content: String(createTime),
|
||||||
|
img: '',
|
||||||
|
name: 'Jamie',
|
||||||
|
sessionId: 'fixture-user',
|
||||||
|
localId: 1,
|
||||||
|
serverId,
|
||||||
|
createTime
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('recall archive message identity', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
state.userData = mkdtempSync(join(tmpdir(), 'wxe-recall-identity-'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not collapse messages whose local ids repeat across database shards', async () => {
|
||||||
|
const accountRoot = '/fixture/account'
|
||||||
|
const sessionMd5 = 'fixture-session'
|
||||||
|
const archiveDir = join(state.userData, 'recall-archive')
|
||||||
|
const archiveName = crypto
|
||||||
|
.createHash('sha1')
|
||||||
|
.update(`${process.platform}:${accountRoot}`)
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 16)
|
||||||
|
mkdirSync(archiveDir, { recursive: true })
|
||||||
|
writeFileSync(
|
||||||
|
join(archiveDir, `${archiveName}.json`),
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
accountRoot,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
sessions: {
|
||||||
|
[sessionMd5]: {
|
||||||
|
username: 'fixture-user',
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
messages: [],
|
||||||
|
recalls: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const { configureRecallArchive, mergeRecallArchiveMessages, messageIdentity } =
|
||||||
|
await import('../../src/main/services/recall-archive-service')
|
||||||
|
configureRecallArchive(accountRoot)
|
||||||
|
const oldMessage = message(1_731_327_263)
|
||||||
|
const newMessage = message(1_765_000_000)
|
||||||
|
|
||||||
|
expect(messageIdentity(oldMessage)).not.toBe(messageIdentity(newMessage))
|
||||||
|
expect(mergeRecallArchiveMessages(sessionMd5, [oldMessage, newMessage])).toEqual([
|
||||||
|
oldMessage,
|
||||||
|
newMessage
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefers the globally unique server id when one is available', async () => {
|
||||||
|
const { messageIdentity } = await import('../../src/main/services/recall-archive-service')
|
||||||
|
|
||||||
|
expect(messageIdentity(message(1_731_327_263, 'server-2024'))).toBe('server:server-2024')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { Wcdb4Client, type Wcdb4Message } from '../../src/main/wcdb4-client'
|
import { Wcdb4Client, type Wcdb4Message } from '../../src/main/wcdb4-client'
|
||||||
|
|
||||||
const message = (id: string, year: number): Wcdb4Message => ({
|
const message = (id: string, year: number, serverId = `server-${id}`): Wcdb4Message => ({
|
||||||
mesLocalID: id,
|
mesLocalID: id,
|
||||||
serverId: `server-${id}`,
|
serverId,
|
||||||
mesDes: 0,
|
mesDes: 0,
|
||||||
messageType: '1',
|
messageType: '1',
|
||||||
msgCreateTime: String(Math.floor(Date.UTC(year, 0, 1) / 1000)),
|
msgCreateTime: String(Math.floor(Date.UTC(year, 0, 1) / 1000)),
|
||||||
@@ -12,6 +12,21 @@ const message = (id: string, year: number): Wcdb4Message => ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('WCDB message shard pagination', () => {
|
describe('WCDB message shard pagination', () => {
|
||||||
|
it('keeps messages whose local ids repeat across database shards', async () => {
|
||||||
|
const cursor = vi.fn(async () => [
|
||||||
|
message('1', 2024, 'server-2024'),
|
||||||
|
message('1', 2025, 'server-2025')
|
||||||
|
])
|
||||||
|
const client = Object.assign(Object.create(Wcdb4Client.prototype), {
|
||||||
|
getMessagesByCursorAsync: cursor
|
||||||
|
}) as Wcdb4Client
|
||||||
|
|
||||||
|
const result = await client.getMessagesAsync('fixture@chatroom')
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect(result.map((item) => item.serverId)).toEqual(['server-2024', 'server-2025'])
|
||||||
|
})
|
||||||
|
|
||||||
it('merges cursor and all-store rows for a bounded cross-year page', async () => {
|
it('merges cursor and all-store rows for a bounded cross-year page', async () => {
|
||||||
const cursor = vi.fn(async () => [message('2025', 2025)])
|
const cursor = vi.fn(async () => [message('2025', 2025)])
|
||||||
const tableScan = vi.fn(async () => [message('2017', 2017), message('2025', 2025)])
|
const tableScan = vi.fn(async () => [message('2017', 2017), message('2025', 2025)])
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { WechatDb, type WechatMessage } from '../../src/main/wechat-db'
|
||||||
|
|
||||||
|
describe('WechatDb normalized messages', () => {
|
||||||
|
it('keeps normalized identity fields when raw table columns conflict', async () => {
|
||||||
|
const normalized = {
|
||||||
|
mesLocalID: '1',
|
||||||
|
serverId: 'server-1',
|
||||||
|
mesDes: 0,
|
||||||
|
messageType: '1',
|
||||||
|
msgCreateTime: '1731327263',
|
||||||
|
msgContent: 'fixture',
|
||||||
|
sender: 'wxid_self',
|
||||||
|
senderNickname: 'Nanin',
|
||||||
|
raw: {
|
||||||
|
mesDes: 1,
|
||||||
|
sender: '',
|
||||||
|
senderNickname: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const client = { getMessagesAsync: vi.fn(async () => [normalized]) }
|
||||||
|
const db = Object.assign(Object.create(WechatDb.prototype), {
|
||||||
|
wcdb4Client: client,
|
||||||
|
chatMd5ToUsername: new Map([['fixture-md5', 'fixture-user']]),
|
||||||
|
ensureChatTableMapping: vi.fn()
|
||||||
|
}) as WechatDb
|
||||||
|
|
||||||
|
const start = Math.floor(new Date(2024, 10, 11).getTime() / 1000)
|
||||||
|
const end = Math.floor(new Date(2024, 11, 1).getTime() / 1000)
|
||||||
|
const messages = await db.getUserMessagesForExport('fixture-md5', start, end)
|
||||||
|
|
||||||
|
expect(messages[0]).toMatchObject({
|
||||||
|
mesDes: 0,
|
||||||
|
sender: 'wxid_self',
|
||||||
|
senderNickname: 'Nanin'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('scans without time bounds, then filters, deduplicates and sorts in application code', async () => {
|
||||||
|
const row = (id: string, createTime: number): WechatMessage => ({
|
||||||
|
mesLocalID: id,
|
||||||
|
serverId: `server-${id}`,
|
||||||
|
mesDes: 0,
|
||||||
|
messageType: '1',
|
||||||
|
msgCreateTime: String(createTime),
|
||||||
|
msgContent: id,
|
||||||
|
raw: {}
|
||||||
|
})
|
||||||
|
const start = Math.floor(new Date(2025, 0, 1).getTime() / 1000)
|
||||||
|
const end = Math.floor(new Date(2025, 0, 4).getTime() / 1000)
|
||||||
|
const shardBoundaryMessage = row('jan-2-boundary', start + 32 * 60 * 60)
|
||||||
|
const getMessagesAsync = vi.fn(async () => [
|
||||||
|
row('before-range', start - 1),
|
||||||
|
row('newest', start + 48 * 60 * 60),
|
||||||
|
shardBoundaryMessage,
|
||||||
|
{ ...shardBoundaryMessage },
|
||||||
|
row('after-range', end + 1)
|
||||||
|
])
|
||||||
|
const db = Object.assign(Object.create(WechatDb.prototype), {
|
||||||
|
wcdb4Client: { getMessagesAsync },
|
||||||
|
chatMd5ToUsername: new Map([['fixture-md5', 'fixture-user']]),
|
||||||
|
ensureChatTableMapping: vi.fn()
|
||||||
|
}) as WechatDb
|
||||||
|
|
||||||
|
const messages = await db.getUserMessagesForExport('fixture-md5', start, end)
|
||||||
|
|
||||||
|
expect(getMessagesAsync).toHaveBeenCalledOnce()
|
||||||
|
expect(getMessagesAsync).toHaveBeenCalledWith('fixture-user')
|
||||||
|
expect(messages.map((message) => message.mesLocalID)).toEqual(['jan-2-boundary', 'newest'])
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user