diff --git a/frontend/assets/css/tailwind.css b/frontend/assets/css/tailwind.css index 3bce7b4..d913f32 100644 --- a/frontend/assets/css/tailwind.css +++ b/frontend/assets/css/tailwind.css @@ -786,6 +786,128 @@ @apply px-3 py-3 border-b border-gray-100; } + /* 时间侧边栏(按日期定位) */ + .time-sidebar { + @apply w-[420px] h-full flex flex-col bg-white border-l border-gray-200 flex-shrink-0; + } + + .time-sidebar-header { + @apply flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-gray-50; + } + + .time-sidebar-title { + @apply flex items-center gap-2 text-sm font-medium text-gray-800; + } + + .time-sidebar-close { + @apply p-1.5 text-gray-500 hover:text-gray-700 hover:bg-gray-200 rounded-md transition-colors; + } + + .time-sidebar-body { + @apply flex-1 overflow-y-auto min-h-0; + } + + .time-sidebar-status { + @apply px-4 py-2 text-xs text-gray-600 border-b border-gray-100; + } + + .time-sidebar-status-error { + @apply text-red-600; + } + + .calendar-header { + @apply flex items-center justify-between px-4 py-3; + } + + .calendar-nav-btn { + @apply p-1.5 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed; + } + + .calendar-month-label { + @apply text-sm font-medium text-gray-800; + } + + .calendar-month-label-selects { + @apply flex items-center gap-2; + } + + .calendar-ym-select { + @apply text-xs px-2 py-1 rounded-md border border-gray-200 bg-white text-gray-800 hover:border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#03C160]/20 disabled:opacity-60 disabled:cursor-not-allowed; + } + + .calendar-weekdays { + @apply grid grid-cols-7 gap-1 px-4 pt-1; + } + + .calendar-weekday { + @apply text-[11px] text-gray-400 text-center py-1; + } + + .calendar-grid { + @apply grid grid-cols-7 gap-1 px-4 pb-4; + } + + .calendar-day { + @apply h-9 rounded-md flex items-center justify-center text-xs font-medium transition-colors border border-gray-200 bg-white disabled:cursor-not-allowed; + } + + .calendar-day-outside { + @apply bg-transparent border-transparent; + } + + .calendar-day-empty { + @apply bg-gray-100 text-gray-400 border-gray-100; + } + + .calendar-day-selected { + /* Keep background as-is (heatmap), but emphasize with a ring/outline. */ + box-shadow: 0 0 0 2px rgba(3, 193, 96, 0.85); + border-color: rgba(3, 193, 96, 0.95) !important; + } + + .calendar-day-l1 { + background: rgba(3, 193, 96, 0.12); + border-color: rgba(3, 193, 96, 0.18); + color: #065f46; + } + + .calendar-day-l2 { + background: rgba(3, 193, 96, 0.24); + border-color: rgba(3, 193, 96, 0.28); + color: #065f46; + } + + .calendar-day-l3 { + background: rgba(3, 193, 96, 0.38); + border-color: rgba(3, 193, 96, 0.40); + color: #064e3b; + } + + .calendar-day-l4 { + background: rgba(3, 193, 96, 0.55); + border-color: rgba(3, 193, 96, 0.55); + color: #053d2e; + } + + .calendar-day-l1:hover, + .calendar-day-l2:hover, + .calendar-day-l3:hover, + .calendar-day-l4:hover { + filter: brightness(0.98); + } + + .calendar-day-number { + @apply select-none; + } + + .time-sidebar-actions { + @apply px-4 pb-4; + } + + .time-sidebar-action-btn { + @apply w-full text-xs px-3 py-2 rounded-md bg-[#03C160] text-white hover:bg-[#02a650] transition-colors disabled:opacity-60 disabled:cursor-not-allowed; + } + /* 整合搜索框样式 */ .search-input-combined { @apply flex items-center bg-white border-2 border-gray-200 rounded-lg overflow-hidden transition-all duration-200; diff --git a/frontend/composables/useApi.js b/frontend/composables/useApi.js index f30f7d3..956e6e1 100644 --- a/frontend/composables/useApi.js +++ b/frontend/composables/useApi.js @@ -180,6 +180,46 @@ export const useApi = () => { return await request(url) } + // 聊天记录日历热力图:某月每日消息数 + const getChatMessageDailyCounts = async (params = {}) => { + const query = new URLSearchParams() + if (params && params.account) query.set('account', params.account) + if (params && params.username) query.set('username', params.username) + if (params && params.year != null) query.set('year', String(params.year)) + if (params && params.month != null) query.set('month', String(params.month)) + const url = '/chat/messages/daily_counts' + (query.toString() ? `?${query.toString()}` : '') + return await request(url) + } + + // 聊天记录定位锚点:某日第一条 / 会话最早一条 + const getChatMessageAnchor = async (params = {}) => { + const query = new URLSearchParams() + if (params && params.account) query.set('account', params.account) + if (params && params.username) query.set('username', params.username) + if (params && params.kind) query.set('kind', String(params.kind)) + if (params && params.date) query.set('date', String(params.date)) + const url = '/chat/messages/anchor' + (query.toString() ? `?${query.toString()}` : '') + return await request(url) + } + + // 解析嵌套合并转发聊天记录(通过 server_id) + const resolveNestedChatHistory = async (params = {}) => { + const query = new URLSearchParams() + if (params && params.account) query.set('account', params.account) + if (params && params.server_id != null) query.set('server_id', String(params.server_id)) + const url = '/chat/chat_history/resolve' + (query.toString() ? `?${query.toString()}` : '') + return await request(url) + } + + // 解析卡片/小程序等 App 消息(通过 server_id) + const resolveAppMsg = async (params = {}) => { + const query = new URLSearchParams() + if (params && params.account) query.set('account', params.account) + if (params && params.server_id != null) query.set('server_id', String(params.server_id)) + const url = '/chat/appmsg/resolve' + (query.toString() ? `?${query.toString()}` : '') + return await request(url) + } + // 朋友圈时间线 const listSnsTimeline = async (params = {}) => { const query = new URLSearchParams() @@ -295,6 +335,7 @@ export const useApi = () => { output_dir: data.output_dir == null ? null : String(data.output_dir || '').trim(), allow_process_key_extract: !!data.allow_process_key_extract, download_remote_media: !!data.download_remote_media, + html_page_size: data.html_page_size != null ? Number(data.html_page_size) : 1000, privacy_mode: !!data.privacy_mode, file_name: data.file_name || null } @@ -408,6 +449,10 @@ export const useApi = () => { buildChatSearchIndex, listChatSearchSenders, getChatMessagesAround, + getChatMessageDailyCounts, + getChatMessageAnchor, + resolveNestedChatHistory, + resolveAppMsg, listSnsTimeline, listSnsMediaCandidates, saveSnsMediaPicks, diff --git a/frontend/pages/chat/[[username]].vue b/frontend/pages/chat/[[username]].vue index 6d600f5..44ff0af 100644 --- a/frontend/pages/chat/[[username]].vue +++ b/frontend/pages/chat/[[username]].vue @@ -271,7 +271,16 @@

- {{ contact.unreadCount > 0 ? `[${contact.unreadCount > 99 ? '99+' : contact.unreadCount}条] ` : '' }}{{ contact.lastMessage }} + + {{ seg.content }} + +

@@ -328,6 +337,19 @@ + + + + + + + + +
+ {{ timeSidebarError }} +
+
+ 加载中... + 本月 {{ timeSidebarTotal }} 条消息,{{ timeSidebarActiveDays }} 天有聊天 +
+ +
+
{{ w }}
+
+ +
+ +
+ +
+ +
+ + + +
@@ -1211,7 +1358,7 @@
预览
+ +
+
+
+
{{ win.title || (win.kind === 'link' ? '链接' : '聊天记录') }}
+ +
+ +
+ + + + + +
+
+
+
-
-
{{ chatHistoryModalTitle || '合并消息' }}
+
+
+ +
{{ chatHistoryModalTitle || '聊天记录' }}
+
-
+
没有可显示的聊天记录
@@ -1256,7 +1690,7 @@
头像
{{ (rec.senderDisplayName || rec.sourcename || '?').charAt(0) }} @@ -1286,9 +1723,67 @@
+ +
+
+
{{ rec.title || '聊天记录' }}
+
+
+ {{ line }} +
+
+
+
+ 聊天记录 +
+
+ + + +
@@ -1413,7 +1908,7 @@
@@ -1451,7 +1946,7 @@
-
+
@@ -1531,6 +2026,18 @@
仅 HTML 生效;会在导出时尝试下载远程缩略图并写入 ZIP(已做安全限制)。隐私模式下自动忽略。
+ +
+
每页消息数
+ +
推荐 1000;0=单文件(打开大聊天可能很卡)
+
@@ -1793,6 +2300,7 @@ definePageMeta({ import { useApi } from '~/composables/useApi' import { parseTextWithEmoji } from '~/utils/wechat-emojis' import { DESKTOP_SETTING_AUTO_REALTIME_KEY, readLocalBoolSetting } from '~/utils/desktop-settings' +import { heatColor } from '~/utils/wrapped/heatmap' import { useChatAccountsStore } from '~/stores/chatAccounts' import { useChatRealtimeStore } from '~/stores/chatRealtime' import { usePrivacyStore } from '~/stores/privacy' @@ -2028,7 +2536,7 @@ const messageTypeFilterOptions = [ { value: 'emoji', label: '表情' }, { value: 'video', label: '视频' }, { value: 'voice', label: '语音' }, - { value: 'chatHistory', label: '合并消息' }, + { value: 'chatHistory', label: '聊天记录' }, { value: 'transfer', label: '转账' }, { value: 'redPacket', label: '红包' }, { value: 'file', label: '文件' }, @@ -2488,18 +2996,161 @@ const getMessageSearchHitAvatarInitial = (hit) => { // 搜索定位上下文(避免破坏正常分页) const searchContext = ref({ active: false, + kind: 'search', // search | date | first + label: '', username: '', anchorId: '', anchorIndex: -1, + hasMoreBefore: false, + hasMoreAfter: false, + loadingBefore: false, + loadingAfter: false, savedMessages: null, savedMeta: null }) const highlightMessageId = ref('') let highlightMessageTimer = null +const searchContextBannerText = computed(() => { + if (!searchContext.value?.active) return '' + const kind = String(searchContext.value.kind || 'search') + if (kind === 'date') { + const label = String(searchContext.value.label || '').trim() + return label ? `已定位到 ${label}(上下文模式)` : '已定位到指定日期(上下文模式)' + } + if (kind === 'first') { + return '已定位到会话顶部(上下文模式)' + } + return '已定位到搜索结果(上下文模式)' +}) + // 回到最新按钮 const showJumpToBottom = ref(false) +// 时间侧边栏(按日期定位) +const timeSidebarOpen = ref(false) +const timeSidebarYear = ref(null) +const timeSidebarMonth = ref(null) // 1-12 +const timeSidebarCounts = ref({}) // { 'YYYY-MM-DD': count } +const timeSidebarMax = ref(0) +const timeSidebarTotal = ref(0) +const timeSidebarLoading = ref(false) +const timeSidebarError = ref('') +const timeSidebarSelectedDate = ref('') // YYYY-MM-DD (current/selected day) +// Simple in-memory cache per (account|username|YYYY-MM) +const timeSidebarCache = ref({}) +const timeSidebarWeekdays = ['一', '二', '三', '四', '五', '六', '日'] + +const timeSidebarMonthLabel = computed(() => { + const y = Number(timeSidebarYear.value || 0) + const m = Number(timeSidebarMonth.value || 0) + if (!y || !m) return '' + return `${y}年${m}月` +}) + +const timeSidebarYearOptions = computed(() => { + // WeChat history normally starts after 2011, but keep a broader range for safety. + const nowY = new Date().getFullYear() + const minY = 2000 + const maxY = Math.max(nowY, Number(timeSidebarYear.value || 0) || nowY) + const years = [] + for (let y = maxY; y >= minY; y--) years.push(y) + return years +}) + +const timeSidebarActiveDays = computed(() => { + const counts = timeSidebarCounts.value || {} + const keys = Object.keys(counts) + return keys.length +}) + +const _pad2 = (n) => String(n).padStart(2, '0') + +const _dateStrFromEpochSeconds = (ts) => { + const t = Number(ts || 0) + if (!t) return '' + try { + const d = new Date(t * 1000) + return `${d.getFullYear()}-${_pad2(d.getMonth() + 1)}-${_pad2(d.getDate())}` + } catch { + return '' + } +} + +// Calendar heatmap color: reuse Wrapped heat palette, but bucket to Wrapped-like legend levels +// so ">=1 message" is always visibly tinted (instead of being almost white when max is huge). +const _calendarHeatColor = (count, maxV) => { + const v = Math.max(0, Number(count || 0)) + const m = Math.max(0, Number(maxV || 0)) + if (!(v > 0)) return '' + if (!(m > 0)) return heatColor(1, 1) + const levels = 6 + const ratio = Math.max(0, Math.min(1, v / m)) + const level = Math.min(levels, Math.max(1, Math.ceil(ratio * levels))) + const valueForLevel = Math.max(1, Math.round(level * (m / levels))) + return heatColor(valueForLevel, m) +} + +const timeSidebarCalendarCells = computed(() => { + const y = Number(timeSidebarYear.value || 0) + const m = Number(timeSidebarMonth.value || 0) // 1-12 + if (!y || !m) return [] + + const daysInMonth = new Date(y, m, 0).getDate() + const firstDow = new Date(y, m - 1, 1).getDay() // 0=Sun..6=Sat + const offset = (firstDow + 6) % 7 // Monday=0 + + const maxV = Math.max(0, Number(timeSidebarMax.value || 0)) + const counts = timeSidebarCounts.value || {} + const selected = String(timeSidebarSelectedDate.value || '').trim() + + const out = [] + for (let i = 0; i < 42; i++) { + const dayNum = i - offset + 1 + const inMonth = dayNum >= 1 && dayNum <= daysInMonth + if (!inMonth) { + out.push({ + key: `e:${y}-${m}:${i}`, + day: '', + dateStr: '', + count: 0, + disabled: true, + className: 'calendar-day-outside', + style: null, + title: '' + }) + continue + } + + const dateStr = `${y}-${_pad2(m)}-${_pad2(dayNum)}` + const count = Math.max(0, Number(counts[dateStr] || 0)) + const disabled = count <= 0 + + const style = !disabled + ? { backgroundColor: _calendarHeatColor(count, Math.max(maxV, count)) } + : null + + const className = [ + disabled ? 'calendar-day-empty' : '', + (selected && dateStr === selected) ? 'calendar-day-selected' : '' + ].filter(Boolean).join(' ') + + out.push({ + key: dateStr, + day: String(dayNum), + dateStr, + count, + disabled, + // NOTE: heatmap bg color is applied via inline style (reusing Wrapped heatmap palette). + // Dynamic class names like `calendar-day-l${level}` may be purged by Tailwind and lead to no bg color. + className, + style, + title: `${dateStr}:${count} 条` + }) + } + return out +}) + // 导出(离线 zip) const exportModalOpen = ref(false) const isExportCreating = ref(false) @@ -2509,13 +3160,14 @@ const exportError = ref('') const exportScope = ref('current') // current | selected | all | groups | singles const exportFormat = ref('json') // json | txt | html const exportDownloadRemoteMedia = ref(true) +const exportHtmlPageSize = ref(1000) // <=0 means single-file HTML (may be slow for huge chats) const exportMessageTypeOptions = [ { value: 'text', label: '文本' }, { value: 'image', label: '图片' }, { value: 'emoji', label: '表情' }, { value: 'video', label: '视频' }, { value: 'voice', label: '语音' }, - { value: 'chatHistory', label: '合并消息' }, + { value: 'chatHistory', label: '聊天记录' }, { value: 'transfer', label: '转账' }, { value: 'redPacket', label: '红包' }, { value: 'file', label: '文件' }, @@ -2609,6 +3261,17 @@ const contactProfileResolvedAlias = computed(() => { return String(contactProfileData.value?.alias || '').trim() }) +const contactProfileResolvedGender = computed(() => { + const value = contactProfileData.value?.gender + if (value == null || value === '') return '' + const n = Number(value) + if (!Number.isFinite(n)) return '' + if (n === 1) return '男' + if (n === 2) return '女' + if (n === 0) return '未知' + return String(n) +}) + const contactProfileResolvedRegion = computed(() => { return String(contactProfileData.value?.region || '').trim() }) @@ -2617,6 +3280,10 @@ const contactProfileResolvedRemark = computed(() => { return String(contactProfileData.value?.remark || '').trim() }) +const contactProfileResolvedSignature = computed(() => { + return String(contactProfileData.value?.signature || '').trim() +}) + const contactProfileResolvedSource = computed(() => { return String(contactProfileData.value?.source || '').trim() }) @@ -2901,8 +3568,10 @@ const fetchContactProfile = async (options = {}) => { avatar: avatarFallback || selectedContact.value?.avatar || '', nickname: '', alias: '', + gender: null, region: '', remark: '', + signature: '', source: '', sourceScene: null, } @@ -2914,8 +3583,10 @@ const fetchContactProfile = async (options = {}) => { avatar: avatarFallback || selectedContact.value?.avatar || '', nickname: '', alias: '', + gender: null, region: '', remark: '', + signature: '', source: '', sourceScene: null, } @@ -2954,8 +3625,10 @@ const onMessageAvatarMouseEnter = async (message) => { avatar: senderAvatar, nickname: '', alias: '', + gender: null, region: '', remark: '', + signature: '', source: '', sourceScene: null, } @@ -3009,6 +3682,28 @@ watch( } ) +watch( + () => selectedContact.value?.username, + async () => { + if (!timeSidebarOpen.value) return + // When switching conversations with the time sidebar open, re-initialize month and refetch counts. + const { year, month } = _pickTimeSidebarInitialYearMonth() + timeSidebarYear.value = year + timeSidebarMonth.value = month + + const list = messages.value || [] + const last = Array.isArray(list) && list.length ? list[list.length - 1] : null + const ds = _dateStrFromEpochSeconds(Number(last?.createTime || 0)) + if (ds) { + await _applyTimeSidebarSelectedDate(ds, { syncMonth: false }) + } else { + timeSidebarSelectedDate.value = '' + } + + await loadTimeSidebarMonth({ year, month, force: false }) + } +) + watch( () => selectedAccount.value, () => { @@ -3122,6 +3817,7 @@ const startChatExport = async () => { include_media: includeMedia, media_kinds: mediaKinds, download_remote_media: exportFormat.value === 'html' && !!exportDownloadRemoteMedia.value, + html_page_size: Math.max(0, Math.floor(Number(exportHtmlPageSize.value || 1000))), output_dir: isDesktopExportRuntime() ? String(exportFolder.value || '').trim() : null, privacy_mode: !!privacyMode.value, file_name: exportFileName.value || null @@ -3369,16 +4065,19 @@ const openMediaContextMenu = (e, message, kind) => { const copyTextToClipboard = async (text) => { if (!process.client) return false - if (typeof text !== 'string') return false + + const t = String(text ?? '').trim() + if (!t) return false try { - await navigator.clipboard.writeText(text) + await navigator.clipboard.writeText(t) return true } catch {} + // Fallback for insecure contexts / old browsers. try { const el = document.createElement('textarea') - el.value = text + el.value = t el.setAttribute('readonly', 'true') el.style.position = 'fixed' el.style.left = '-9999px' @@ -3387,7 +4086,12 @@ const copyTextToClipboard = async (text) => { el.select() const ok = document.execCommand('copy') document.body.removeChild(el) - return ok + if (ok) return true + } catch {} + + try { + window.prompt('复制内容:', t) + return true } catch { return false } @@ -3530,6 +4234,164 @@ const closeMessageSearch = () => { messageSearchDebounceTimer = null } +let timeSidebarReqId = 0 + +const closeTimeSidebar = () => { + timeSidebarOpen.value = false + timeSidebarError.value = '' +} + +const _timeSidebarCacheKey = ({ account, username, year, month }) => { + const acc = String(account || '').trim() + const u = String(username || '').trim() + const y = Number(year || 0) + const m = Number(month || 0) + return `${acc}|${u}|${y}-${_pad2(m)}` +} + +const _applyTimeSidebarMonthData = (data) => { + const counts = (data && typeof data.counts === 'object' && !Array.isArray(data.counts)) ? data.counts : {} + timeSidebarCounts.value = counts + timeSidebarMax.value = Math.max(0, Number(data?.max || 0)) + timeSidebarTotal.value = Math.max(0, Number(data?.total || 0)) +} + +const loadTimeSidebarMonth = async ({ year, month, force } = {}) => { + if (!selectedAccount.value) return + if (!selectedContact.value?.username) return + + const y = Number(year || timeSidebarYear.value || 0) + const m = Number(month || timeSidebarMonth.value || 0) + if (!y || !m) return + + timeSidebarYear.value = y + timeSidebarMonth.value = m + + const key = _timeSidebarCacheKey({ + account: selectedAccount.value, + username: selectedContact.value.username, + year: y, + month: m + }) + + if (!force) { + const cached = timeSidebarCache.value[key] + if (cached) { + timeSidebarError.value = '' + _applyTimeSidebarMonthData(cached) + return + } + } + + const reqId = ++timeSidebarReqId + const api = useApi() + timeSidebarLoading.value = true + timeSidebarError.value = '' + + try { + const resp = await api.getChatMessageDailyCounts({ + account: selectedAccount.value, + username: selectedContact.value.username, + year: y, + month: m + }) + if (reqId !== timeSidebarReqId) return + if (String(resp?.status || '') !== 'success') { + throw new Error(String(resp?.message || '加载日历失败')) + } + + const data = { + counts: resp?.counts || {}, + max: Number(resp?.max || 0), + total: Number(resp?.total || 0) + } + + _applyTimeSidebarMonthData(data) + timeSidebarCache.value = { ...timeSidebarCache.value, [key]: data } + } catch (e) { + if (reqId !== timeSidebarReqId) return + timeSidebarError.value = e?.message || '加载日历失败' + _applyTimeSidebarMonthData({ counts: {}, max: 0, total: 0 }) + } finally { + if (reqId === timeSidebarReqId) { + timeSidebarLoading.value = false + } + } +} + +const _pickTimeSidebarInitialYearMonth = () => { + const list = messages.value || [] + const last = Array.isArray(list) && list.length ? list[list.length - 1] : null + const ts = Number(last?.createTime || 0) + const d = ts ? new Date(ts * 1000) : new Date() + return { year: d.getFullYear(), month: d.getMonth() + 1 } +} + +const _applyTimeSidebarSelectedDate = async (dateStr, { syncMonth } = {}) => { + const ds = String(dateStr || '').trim() + if (!ds) return + if (timeSidebarSelectedDate.value !== ds) { + timeSidebarSelectedDate.value = ds + } + if (!syncMonth || !timeSidebarOpen.value) return + + const parts = ds.split('-') + const y = Number(parts?.[0] || 0) + const m = Number(parts?.[1] || 0) + if (!y || !m) return + + if (Number(timeSidebarYear.value || 0) !== y || Number(timeSidebarMonth.value || 0) !== m) { + timeSidebarYear.value = y + timeSidebarMonth.value = m + // Fire and forget; request id guard + cache inside loadTimeSidebarMonth will handle racing. + await loadTimeSidebarMonth({ year: y, month: m, force: false }) + } +} + +const toggleTimeSidebar = async () => { + timeSidebarOpen.value = !timeSidebarOpen.value + if (!timeSidebarOpen.value) return + closeMessageSearch() + + const { year, month } = _pickTimeSidebarInitialYearMonth() + timeSidebarYear.value = year + timeSidebarMonth.value = month + + // Default selected day: current viewport's latest loaded message day (usually "latest"). + const list = messages.value || [] + const last = Array.isArray(list) && list.length ? list[list.length - 1] : null + const ds = _dateStrFromEpochSeconds(Number(last?.createTime || 0)) + if (ds) await _applyTimeSidebarSelectedDate(ds, { syncMonth: false }) + + await loadTimeSidebarMonth({ year, month, force: false }) +} + +const prevTimeSidebarMonth = async () => { + const y0 = Number(timeSidebarYear.value || 0) + const m0 = Number(timeSidebarMonth.value || 0) + if (!y0 || !m0) return + const y = m0 === 1 ? (y0 - 1) : y0 + const m = m0 === 1 ? 12 : (m0 - 1) + await loadTimeSidebarMonth({ year: y, month: m, force: false }) +} + +const nextTimeSidebarMonth = async () => { + const y0 = Number(timeSidebarYear.value || 0) + const m0 = Number(timeSidebarMonth.value || 0) + if (!y0 || !m0) return + const y = m0 === 12 ? (y0 + 1) : y0 + const m = m0 === 12 ? 1 : (m0 + 1) + await loadTimeSidebarMonth({ year: y, month: m, force: false }) +} + +const onTimeSidebarYearMonthChange = async () => { + if (!timeSidebarOpen.value) return + const y = Number(timeSidebarYear.value || 0) + const m = Number(timeSidebarMonth.value || 0) + if (!y || !m) return + await loadTimeSidebarMonth({ year: y, month: m, force: false }) +} + const ensureMessageSearchScopeValid = () => { if (messageSearchScope.value === 'conversation' && !selectedContact.value) { messageSearchScope.value = 'global' @@ -3540,6 +4402,7 @@ const toggleMessageSearch = async () => { messageSearchOpen.value = !messageSearchOpen.value ensureMessageSearchScopeValid() if (!messageSearchOpen.value) return + closeTimeSidebar() await nextTick() try { messageSearchInputRef.value?.focus?.() @@ -3727,9 +4590,15 @@ const exitSearchContext = async () => { searchContext.value = { active: false, + kind: 'search', + label: '', username: '', anchorId: '', anchorIndex: -1, + hasMoreBefore: false, + hasMoreAfter: false, + loadingBefore: false, + loadingAfter: false, savedMessages: null, savedMeta: null } @@ -3758,14 +4627,26 @@ const locateSearchHit = async (hit) => { if (!searchContext.value?.active) { searchContext.value = { active: true, + kind: 'search', + label: '', username: targetUsername, anchorId: String(hit.id), anchorIndex: -1, + hasMoreBefore: true, + hasMoreAfter: true, + loadingBefore: false, + loadingAfter: false, savedMessages: allMessages.value[targetUsername] || [], savedMeta: messagesMeta.value[targetUsername] || null } } else { + searchContext.value.kind = 'search' + searchContext.value.label = '' searchContext.value.anchorId = String(hit.id) + searchContext.value.hasMoreBefore = true + searchContext.value.hasMoreAfter = true + searchContext.value.loadingBefore = false + searchContext.value.loadingAfter = false } try { @@ -3793,6 +4674,284 @@ const locateSearchHit = async (hit) => { } } +const locateByAnchorId = async ({ targetUsername, anchorId, kind, label } = {}) => { + if (!process.client) return + if (!selectedAccount.value) return + const u = String(targetUsername || selectedContact.value?.username || '').trim() + const anchor = String(anchorId || '').trim() + if (!u || !anchor) return + + const targetContact = contacts.value.find((c) => c?.username === u) + if (targetContact && selectedContact.value?.username !== u) { + await selectContact(targetContact, { skipLoadMessages: true }) + } + + if (searchContext.value?.active && searchContext.value.username !== u) { + await exitSearchContext() + } + + const kindNorm = String(kind || 'search').trim() || 'search' + const labelNorm = String(label || '').trim() + const hasMoreBeforeInit = kindNorm === 'first' ? false : true + + if (!searchContext.value?.active) { + searchContext.value = { + active: true, + kind: kindNorm, + label: labelNorm, + username: u, + anchorId: anchor, + anchorIndex: -1, + hasMoreBefore: hasMoreBeforeInit, + hasMoreAfter: true, + loadingBefore: false, + loadingAfter: false, + savedMessages: allMessages.value[u] || [], + savedMeta: messagesMeta.value[u] || null + } + } else { + searchContext.value.kind = kindNorm + searchContext.value.label = labelNorm + searchContext.value.anchorId = anchor + searchContext.value.username = u + searchContext.value.hasMoreBefore = hasMoreBeforeInit + searchContext.value.hasMoreAfter = true + searchContext.value.loadingBefore = false + searchContext.value.loadingAfter = false + } + + try { + const api = useApi() + const resp = await api.getChatMessagesAround({ + account: selectedAccount.value, + username: u, + anchor_id: anchor, + before: 35, + after: 35 + }) + + const raw = resp?.messages || [] + const mapped = raw.map(normalizeMessage) + allMessages.value = { ...allMessages.value, [u]: mapped } + messagesMeta.value = { ...messagesMeta.value, [u]: { total: mapped.length, hasMore: false } } + + searchContext.value.anchorId = String(resp?.anchorId || anchor) + searchContext.value.anchorIndex = Number(resp?.anchorIndex ?? -1) + + const ok = await scrollToMessageId(searchContext.value.anchorId) + if (ok) flashMessage(searchContext.value.anchorId) + } catch (e) { + window.alert(e?.message || '定位失败') + } +} + +const locateByDate = async (dateStr) => { + if (!process.client) return + if (!selectedAccount.value) return + if (!selectedContact.value?.username) return + + const ds = String(dateStr || '').trim() + if (!ds) return + await _applyTimeSidebarSelectedDate(ds, { syncMonth: true }) + + try { + const api = useApi() + const resp = await api.getChatMessageAnchor({ + account: selectedAccount.value, + username: selectedContact.value.username, + kind: 'day', + date: ds + }) + const status = String(resp?.status || '') + const anchorId = String(resp?.anchorId || '').trim() + if (status !== 'success' || !anchorId) { + window.alert('当日暂无聊天记录') + return + } + await locateByAnchorId({ targetUsername: selectedContact.value.username, anchorId, kind: 'date', label: ds }) + } catch (e) { + window.alert(e?.message || '定位失败') + } +} + +const jumpToConversationFirst = async () => { + if (!process.client) return + if (!selectedAccount.value) return + if (!selectedContact.value?.username) return + + try { + const api = useApi() + const resp = await api.getChatMessageAnchor({ + account: selectedAccount.value, + username: selectedContact.value.username, + kind: 'first' + }) + const status = String(resp?.status || '') + const anchorId = String(resp?.anchorId || '').trim() + if (status !== 'success' || !anchorId) { + window.alert('暂无聊天记录') + return + } + const ds = _dateStrFromEpochSeconds(Number(resp?.createTime || 0)) + if (ds) await _applyTimeSidebarSelectedDate(ds, { syncMonth: true }) + await locateByAnchorId({ targetUsername: selectedContact.value.username, anchorId, kind: 'first', label: '' }) + } catch (e) { + window.alert(e?.message || '定位失败') + } +} + +const onTimeSidebarDayClick = async (cell) => { + if (!cell || cell.disabled) return + const ds = String(cell.dateStr || '').trim() + if (!ds) return + await locateByDate(ds) +} + +const _mergeContextMessages = (username, nextList) => { + const u = String(username || '').trim() + if (!u) return + const list = Array.isArray(nextList) ? nextList : [] + allMessages.value = { ...allMessages.value, [u]: list } + // Keep meta aligned; context mode doesn't rely on hasMore from meta. + const prevMeta = messagesMeta.value[u] || null + messagesMeta.value = { + ...messagesMeta.value, + [u]: { + total: Math.max(Number(prevMeta?.total || 0), list.length), + hasMore: false + } + } +} + +const loadMoreSearchContextAfter = async () => { + if (!process.client) return + if (!selectedAccount.value) return + if (!searchContext.value?.active) return + if (searchContext.value.loadingAfter) return + if (!searchContext.value.hasMoreAfter) return + + const u = String(searchContext.value.username || selectedContact.value?.username || '').trim() + if (!u) return + const existing = allMessages.value[u] || [] + const last = Array.isArray(existing) && existing.length ? existing[existing.length - 1] : null + const anchorId = String(last?.id || '').trim() + if (!anchorId) { + searchContext.value.hasMoreAfter = false + return + } + + const ctxUsername = u + searchContext.value.loadingAfter = true + try { + const api = useApi() + const resp = await api.getChatMessagesAround({ + account: selectedAccount.value, + username: ctxUsername, + anchor_id: anchorId, + before: 0, + after: messagePageSize + }) + + if (!searchContext.value?.active || String(searchContext.value.username || '').trim() !== ctxUsername) return + + const raw = resp?.messages || [] + const mapped = raw.map(normalizeMessage) + + const existingIds = new Set(existing.map((m) => String(m?.id || ''))) + const appended = [] + for (const m of mapped) { + const id = String(m?.id || '').trim() + if (!id) continue + if (existingIds.has(id)) continue + existingIds.add(id) + appended.push(m) + } + + if (!appended.length) { + searchContext.value.hasMoreAfter = false + return + } + + _mergeContextMessages(ctxUsername, [...existing, ...appended]) + } catch (e) { + window.alert(e?.message || '加载更多消息失败') + } finally { + if (searchContext.value?.active && String(searchContext.value.username || '').trim() === ctxUsername) { + searchContext.value.loadingAfter = false + } + } +} + +const loadMoreSearchContextBefore = async () => { + if (!process.client) return + if (!selectedAccount.value) return + if (!searchContext.value?.active) return + if (searchContext.value.loadingBefore) return + if (!searchContext.value.hasMoreBefore) return + + const u = String(searchContext.value.username || selectedContact.value?.username || '').trim() + if (!u) return + const existing = allMessages.value[u] || [] + const first = Array.isArray(existing) && existing.length ? existing[0] : null + const anchorId = String(first?.id || '').trim() + if (!anchorId) { + searchContext.value.hasMoreBefore = false + return + } + + const c = messageContainerRef.value + const beforeScrollHeight = c ? c.scrollHeight : 0 + const beforeScrollTop = c ? c.scrollTop : 0 + + const ctxUsername = u + searchContext.value.loadingBefore = true + try { + const api = useApi() + const resp = await api.getChatMessagesAround({ + account: selectedAccount.value, + username: ctxUsername, + anchor_id: anchorId, + before: messagePageSize, + after: 0 + }) + + if (!searchContext.value?.active || String(searchContext.value.username || '').trim() !== ctxUsername) return + + const raw = resp?.messages || [] + const mapped = raw.map(normalizeMessage) + + const existingIds = new Set(existing.map((m) => String(m?.id || ''))) + const prepended = [] + for (const m of mapped) { + const id = String(m?.id || '').trim() + if (!id) continue + if (existingIds.has(id)) continue + existingIds.add(id) + prepended.push(m) + } + + if (!prepended.length) { + searchContext.value.hasMoreBefore = false + return + } + + _mergeContextMessages(ctxUsername, [...prepended, ...existing]) + + await nextTick() + const c2 = messageContainerRef.value + if (c2) { + const afterScrollHeight = c2.scrollHeight + c2.scrollTop = beforeScrollTop + (afterScrollHeight - beforeScrollHeight) + } + } catch (e) { + window.alert(e?.message || '加载更多消息失败') + } finally { + if (searchContext.value?.active && String(searchContext.value.username || '').trim() === ctxUsername) { + searchContext.value.loadingBefore = false + } + } +} + const onSearchHitClick = async (hit, idx) => { messageSearchSelectedIndex.value = Number(idx || 0) await locateSearchHit(hit) @@ -4243,6 +5402,12 @@ const loadSessionsForSelectedAccount = async () => { selectedContact.value = null closeMessageSearch() + closeTimeSidebar() + timeSidebarYear.value = null + timeSidebarMonth.value = null + _applyTimeSidebarMonthData({ counts: {}, max: 0, total: 0 }) + timeSidebarError.value = '' + timeSidebarSelectedDate.value = '' messageSearchResults.value = [] messageSearchOffset.value = 0 messageSearchHasMore.value = false @@ -4252,9 +5417,15 @@ const loadSessionsForSelectedAccount = async () => { messageSearchSelectedIndex.value = -1 searchContext.value = { active: false, + kind: 'search', + label: '', username: '', anchorId: '', anchorIndex: -1, + hasMoreBefore: false, + hasMoreAfter: false, + loadingBefore: false, + loadingAfter: false, savedMessages: null, savedMeta: null } @@ -4394,7 +5565,12 @@ const normalizeMessage = (msg) => { const fromUsername = String(msg.fromUsername || '').trim() const fromAvatar = fromUsername ? `${mediaBase}/api/chat/avatar?account=${encodeURIComponent(selectedAccount.value || '')}&username=${encodeURIComponent(fromUsername)}` - : '' + : (() => { + // App/web link shares may not provide `fromUsername` (sourceusername), so we don't have a WeChat avatar. + // Fall back to a best-effort website favicon fetched via backend. + const href = String(msg.url || '').trim() + return href ? `${mediaBase}/api/chat/media/favicon?url=${encodeURIComponent(href)}` : '' + })() const localEmojiUrl = msg.emojiMd5 ? `${mediaBase}/api/chat/media/emoji?account=${encodeURIComponent(selectedAccount.value || '')}&md5=${encodeURIComponent(msg.emojiMd5)}&username=${encodeURIComponent(selectedContact.value?.username || '')}` : '' const localImageUrl = (() => { @@ -4634,11 +5810,130 @@ const getChatHistoryPreviewLines = (message) => { return raw.split(/\r?\n/).map((x) => x.trim()).filter(Boolean).slice(0, 4) } -// 合并转发聊天记录弹窗 +// 浮动窗口:合并消息 / 链接卡片(支持同时打开多个,且可拖动) +const floatingWindows = ref([]) +let floatingWindowSeq = 0 +let floatingWindowZ = 70 +const floatingDragState = { id: '', offsetX: 0, offsetY: 0 } + +const clampNumber = (n, min, max) => Math.min(max, Math.max(min, n)) +const getFloatingWindowById = (id) => { + const list = Array.isArray(floatingWindows.value) ? floatingWindows.value : [] + return list.find((w) => String(w?.id || '') === String(id || '')) || null +} + +const focusFloatingWindow = (id) => { + const w = getFloatingWindowById(id) + if (!w) return + floatingWindowZ += 1 + w.zIndex = floatingWindowZ +} + +const closeFloatingWindow = (id) => { + const key = String(id || '') + floatingWindows.value = (Array.isArray(floatingWindows.value) ? floatingWindows.value : []).filter((w) => String(w?.id || '') !== key) + if (floatingDragState.id && String(floatingDragState.id) === key) { + floatingDragState.id = '' + } +} + +const closeTopFloatingWindow = () => { + const list = Array.isArray(floatingWindows.value) ? floatingWindows.value : [] + if (!list.length) return + const top = list.reduce((acc, cur) => (Number(cur?.zIndex || 0) >= Number(acc?.zIndex || 0) ? cur : acc), list[0]) + if (top?.id) closeFloatingWindow(top.id) +} + +const openFloatingWindow = (payload) => { + if (!process.client) return null + const w0 = Number(payload?.width || 0) > 0 ? Number(payload.width) : 560 + const h0 = Number(payload?.height || 0) > 0 ? Number(payload.height) : 560 + const margin = 12 + const vpW = Math.max(320, window.innerWidth || 0) + const vpH = Math.max(240, window.innerHeight || 0) + const n = (Array.isArray(floatingWindows.value) ? floatingWindows.value.length : 0) + const dx = 24 * (n % 6) + const dy = 24 * (n % 6) + const x0 = payload?.x != null ? Number(payload.x) : Math.round((vpW - w0) / 2 + dx) + const y0 = payload?.y != null ? Number(payload.y) : Math.round((vpH - h0) / 2 + dy) + + floatingWindowSeq += 1 + floatingWindowZ += 1 + const win = { + id: String(payload?.id || `fw_${Date.now()}_${floatingWindowSeq}`), + kind: String(payload?.kind || 'chatHistory'), + title: String(payload?.title || ''), + zIndex: floatingWindowZ, + x: clampNumber(x0, margin, Math.max(margin, vpW - w0 - margin)), + y: clampNumber(y0, margin, Math.max(margin, vpH - h0 - margin)), + width: w0, + height: h0, + // custom data per kind + info: payload?.info || null, + records: Array.isArray(payload?.records) ? payload.records : [], + url: String(payload?.url || ''), + content: String(payload?.content || ''), + preview: String(payload?.preview || ''), + from: String(payload?.from || ''), + fromAvatar: String(payload?.fromAvatar || ''), + loading: !!payload?.loading, + } + floatingWindows.value = [...(Array.isArray(floatingWindows.value) ? floatingWindows.value : []), win] + // Return the reactive proxy from the state array; otherwise mutating the raw object won't trigger re-renders + // (the UI would only update after an unrelated reactive change such as focusing the window). + return getFloatingWindowById(win.id) || win +} + +const startFloatingWindowDrag = (id, e) => { + if (!process.client) return + const w = getFloatingWindowById(id) + if (!w) return + focusFloatingWindow(id) + const ev = e?.touches?.[0] || e + const cx = Number(ev?.clientX || 0) + const cy = Number(ev?.clientY || 0) + floatingDragState.id = String(id || '') + floatingDragState.offsetX = cx - Number(w.x || 0) + floatingDragState.offsetY = cy - Number(w.y || 0) + try { e?.preventDefault?.() } catch {} +} + +const onFloatingWindowMouseMove = (e) => { + if (!process.client) return + const id = String(floatingDragState.id || '') + if (!id) return + const w = getFloatingWindowById(id) + if (!w) return + const ev = e?.touches?.[0] || e + const cx = Number(ev?.clientX || 0) + const cy = Number(ev?.clientY || 0) + const margin = 8 + const vpW = Math.max(320, window.innerWidth || 0) + const vpH = Math.max(240, window.innerHeight || 0) + const nx = cx - Number(floatingDragState.offsetX || 0) + const ny = cy - Number(floatingDragState.offsetY || 0) + w.x = clampNumber(nx, margin, Math.max(margin, vpW - Number(w.width || 0) - margin)) + w.y = clampNumber(ny, margin, Math.max(margin, vpH - Number(w.height || 0) - margin)) +} + +const onFloatingWindowMouseUp = () => { + floatingDragState.id = '' +} + +// Legacy modal state kept only so the old template block compiles (we now use floating windows instead). const chatHistoryModalVisible = ref(false) const chatHistoryModalTitle = ref('') const chatHistoryModalRecords = ref([]) const chatHistoryModalInfo = ref({ isChatRoom: false }) +const chatHistoryModalStack = ref([]) +const goBackChatHistoryModal = () => {} +const closeChatHistoryModal = () => { + chatHistoryModalVisible.value = false + chatHistoryModalTitle.value = '' + chatHistoryModalRecords.value = [] + chatHistoryModalInfo.value = { isChatRoom: false } + chatHistoryModalStack.value = [] +} const isMaybeMd5 = (value) => /^[0-9a-f]{32}$/i.test(String(value || '').trim()) const pickFirstMd5 = (...values) => { @@ -4650,6 +5945,10 @@ const pickFirstMd5 = (...values) => { } const normalizeChatHistoryUrl = (value) => String(value || '').trim().replace(/\s+/g, '') +const stripWeChatInvisible = (value) => { + // WeChat sometimes uses invisible filler characters like U+3164 (Hangul Filler) for "empty". + return String(value || '').replace(/[\u3164\u2800]/g, '').trim() +} const parseChatHistoryRecord = (recordItemXml) => { if (!process.client) return { info: null, items: [] } @@ -4674,28 +5973,102 @@ const parseChatHistoryRecord = (recordItemXml) => { const getText = (node, tag) => { try { - const el = node.getElementsByTagName(tag)?.[0] + if (!node) return '' + const els = Array.from(node.getElementsByTagName(tag) || []) + const direct = els.find((el) => el && el.parentNode === node) + const el = direct || els[0] return String(el?.textContent || '').trim() } catch { return '' } } + const getDirectChildXml = (node, tag) => { + try { + if (!node) return '' + const children = Array.from(node.children || []) + const el = children.find((c) => String(c?.tagName || '').toLowerCase() === String(tag || '').toLowerCase()) + if (!el) return '' + // If the child is a plain text/CDATA wrapper that contains another XML document, prefer that raw string. + const raw = String(el.textContent || '').trim() + if (raw && raw.startsWith('<') && raw.endsWith('>')) return raw + + // Otherwise serialize the element (nested recorditem may be provided as real XML nodes). + if (typeof XMLSerializer !== 'undefined') { + return new XMLSerializer().serializeToString(el) + } + } catch {} + return '' + } + + const getAnyXml = (node, tag) => { + try { + if (!node) return '' + const els = Array.from(node.getElementsByTagName(tag) || []) + const direct = els.find((el) => el && el.parentNode === node) + const el = direct || els[0] + if (!el) return '' + + const raw = String(el.textContent || '').trim() + if (raw && raw.startsWith('<') && raw.endsWith('>')) return raw + if (typeof XMLSerializer !== 'undefined') return new XMLSerializer().serializeToString(el) + } catch {} + return '' + } + + const sameTag = (el, tag) => String(el?.tagName || '').toLowerCase() === String(tag || '').toLowerCase() + + const closestAncestorByTag = (node, tag) => { + const lower = String(tag || '').toLowerCase() + let cur = node + while (cur) { + if (cur.nodeType === 1 && String(cur.tagName || '').toLowerCase() === lower) return cur + cur = cur.parentNode + } + return null + } + const root = doc?.documentElement const isChatRoom = String(getText(root, 'isChatRoom') || '').trim() === '1' const title = getText(root, 'title') const desc = getText(root, 'desc') || getText(root, 'info') - const items = Array.from(doc.getElementsByTagName('dataitem') || []) - const parsed = items.map((node, idx) => { - const datatype = String(node.getAttribute('datatype') || '').trim() - const dataid = String(node.getAttribute('dataid') || '').trim() || String(idx) + const datalist = (() => { + try { + const all = Array.from(doc.getElementsByTagName('datalist') || []) + // Prefer the datalist belonging to the top-level recorditem to avoid flattening nested records. + const top = root ? all.find((el) => closestAncestorByTag(el, 'recorditem') === root) : null + return top || all[0] || null + } catch { + return null + } + })() + const datalistCount = (() => { + try { + if (!datalist) return 0 + const v = String(datalist.getAttribute('count') || '').trim() + return Math.max(0, parseInt(v, 10) || 0) + } catch { + return 0 + } + })() + + const itemNodes = (() => { + if (datalist) return Array.from(datalist.children || []).filter((el) => sameTag(el, 'dataitem')) + // Some recordItem payloads omit the wrapper. + return Array.from(root?.children || []).filter((el) => sameTag(el, 'dataitem')) + })() + + const parsed = itemNodes.map((node, idx) => { + const datatype = String(node.getAttribute('datatype') || getText(node, 'datatype') || '').trim() + const dataid = String(node.getAttribute('dataid') || getText(node, 'dataid') || '').trim() || String(idx) const sourcename = getText(node, 'sourcename') const sourcetime = getText(node, 'sourcetime') const sourceheadurl = normalizeChatHistoryUrl(getText(node, 'sourceheadurl')) const datatitle = getText(node, 'datatitle') const datadesc = getText(node, 'datadesc') + const link = normalizeChatHistoryUrl(getText(node, 'link') || getText(node, 'dataurl') || getText(node, 'url')) const datafmt = getText(node, 'datafmt') const duration = getText(node, 'duration') @@ -4703,12 +6076,13 @@ const parseChatHistoryRecord = (recordItemXml) => { const thumbfullmd5 = getText(node, 'thumbfullmd5') const md5 = getText(node, 'md5') || getText(node, 'emoticonmd5') || getText(node, 'emojiMd5') const fromnewmsgid = getText(node, 'fromnewmsgid') - const srcMsgLocalid = getText(node, 'srcMsgLocalid') + const srcMsgLocalid = getText(node, 'srcMsgLocalid') || getText(node, 'srcMsgLocalId') const srcMsgCreateTime = getText(node, 'srcMsgCreateTime') const cdnurlstring = normalizeChatHistoryUrl(getText(node, 'cdnurlstring')) const encrypturlstring = normalizeChatHistoryUrl(getText(node, 'encrypturlstring')) const externurl = normalizeChatHistoryUrl(getText(node, 'externurl')) const aeskey = getText(node, 'aeskey') + const nestedRecordItem = getAnyXml(node, 'recorditem') || getDirectChildXml(node, 'recorditem') || getText(node, 'recorditem') let content = datatitle || datadesc if (!content) { @@ -4724,7 +6098,11 @@ const parseChatHistoryRecord = (recordItemXml) => { const imageFormats = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'heic', 'heif']) let renderType = 'text' - if (datatype === '4' || String(duration || '').trim() || fmt === 'mp4') { + if (datatype === '17') { + renderType = 'chatHistory' + } else if (datatype === '5' || link) { + renderType = 'link' + } else if (datatype === '4' || String(duration || '').trim() || fmt === 'mp4') { renderType = 'video' } else if (datatype === '47' || datatype === '37') { renderType = 'emoji' @@ -4740,6 +6118,26 @@ const parseChatHistoryRecord = (recordItemXml) => { renderType = 'emoji' } + let outTitle = '' + let outUrl = '' + let recordItem = '' + if (renderType === 'chatHistory') { + outTitle = datatitle || content || '聊天记录' + content = datadesc || '' + recordItem = nestedRecordItem + } else if (renderType === 'link') { + outTitle = datatitle || content || '' + outUrl = link || externurl || '' + const cleanDesc = stripWeChatInvisible(datadesc) + const cleanTitle = stripWeChatInvisible(outTitle) + // Keep card description only when it's not a filler placeholder and not identical to the title. + if (!cleanDesc || (cleanTitle && cleanDesc === cleanTitle)) { + content = '' + } else { + content = String(datadesc || '').trim() + } + } + return { id: dataid, datatype, @@ -4759,12 +6157,15 @@ const parseChatHistoryRecord = (recordItemXml) => { externurl, aeskey, renderType, + title: outTitle, + recordItem, + url: outUrl, content } }) return { - info: { isChatRoom, title, desc }, + info: { isChatRoom, title, desc, count: datalistCount }, items: parsed } } @@ -4787,7 +6188,52 @@ const normalizeChatHistoryRecordItem = (rec) => { out.senderAvatar = normalizeChatHistoryUrl(out.sourceheadurl) out.fullTime = String(out.sourcetime || '').trim() - if (out.renderType === 'video') { + if (out.renderType === 'link') { + const linkUrl = String(out.url || out.externurl || '').trim() + out.url = linkUrl + out.from = String(out.from || '').trim() + const previewCandidates = [] + + // Some link cards store thumbnails with a "file_id" naming scheme: local_id_create_time. + const fileId = (() => { + const lid = parseInt(String(out.srcMsgLocalid || '').trim(), 10) || 0 + const ct = parseInt(String(out.srcMsgCreateTime || '').trim(), 10) || 0 + if (lid > 0 && ct > 0) return `${lid}_${ct}` + return '' + })() + if (fileId) { + previewCandidates.push( + `${mediaBase}/api/chat/media/image?account=${account}&file_id=${encodeURIComponent(fileId)}&username=${username}` + ) + } + + // Fallback: some records still carry md5-ish fields. + out.previewMd5 = pickFirstMd5(out.fullmd5, out.thumbfullmd5, out.md5) + const srcServerId = String(out.fromnewmsgid || '').trim() + if (out.previewMd5) { + const previewParts = [ + `account=${account}`, + `md5=${encodeURIComponent(out.previewMd5)}`, + srcServerId ? `server_id=${encodeURIComponent(srcServerId)}` : '', + `username=${username}` + ].filter(Boolean) + previewCandidates.push(`${mediaBase}/api/chat/media/image?${previewParts.join('&')}`) + } + + out._linkPreviewCandidates = previewCandidates + out._linkPreviewCandidateIndex = 0 + out._linkPreviewError = false + out.preview = previewCandidates[0] || '' + + const fromUsername = String(out.fromUsername || '').trim() + out.fromUsername = fromUsername + out.fromAvatar = fromUsername + ? `${mediaBase}/api/chat/avatar?account=${account}&username=${encodeURIComponent(fromUsername)}` + : (linkUrl ? `${mediaBase}/api/chat/media/favicon?url=${encodeURIComponent(linkUrl)}` : '') + out._fromAvatarLast = out.fromAvatar + out._fromAvatarImgOk = false + out._fromAvatarImgError = false + } else if (out.renderType === 'video') { out.videoMd5 = pickFirstMd5(out.fullmd5, out.md5) out.videoThumbMd5 = pickFirstMd5(out.thumbfullmd5) out.videoDuration = String(out.duration || '').trim() @@ -4952,6 +6398,45 @@ const onChatHistoryVideoThumbError = (rec) => { rec._videoThumbError = true } +const onChatHistoryLinkPreviewError = (rec) => { + if (!rec) return + const candidates = rec._linkPreviewCandidates + if (!Array.isArray(candidates) || candidates.length <= 1) { + rec._linkPreviewError = true + return + } + + const cur = Math.max(0, Number(rec._linkPreviewCandidateIndex || 0)) + const next = cur + 1 + if (next < candidates.length) { + rec._linkPreviewCandidateIndex = next + rec.preview = candidates[next] + rec._linkPreviewError = false + return + } + rec._linkPreviewError = true +} + +const onChatHistoryFromAvatarLoad = (rec) => { + try { + if (rec) { + rec._fromAvatarImgOk = true + rec._fromAvatarImgError = false + rec._fromAvatarLast = String(rec.fromAvatar || '').trim() + } + } catch {} +} + +const onChatHistoryFromAvatarError = (rec) => { + try { + if (rec) { + rec._fromAvatarImgOk = false + rec._fromAvatarImgError = true + rec._fromAvatarLast = String(rec.fromAvatar || '').trim() + } + } catch {} +} + const onChatHistoryQuoteThumbError = (rec) => { if (!rec || !rec.quote) return const candidates = rec._quoteThumbCandidates @@ -4991,33 +6476,301 @@ const openChatHistoryQuote = (rec) => { } } -const openChatHistoryModal = (message) => { - if (!process.client) return - chatHistoryModalTitle.value = String(message?.title || '合并消息') - - const recordItem = String(message?.recordItem || '').trim() - const parsed = parseChatHistoryRecord(recordItem) - chatHistoryModalInfo.value = parsed?.info || { isChatRoom: false } - const records = parsed?.items - chatHistoryModalRecords.value = Array.isArray(records) ? enhanceChatHistoryRecords(records.map(normalizeChatHistoryRecordItem)) : [] - - if (!chatHistoryModalRecords.value.length) { - // 降级:使用摘要内容按行展示 - const lines = String(message?.content || '').trim().split(/\r?\n/).map((x) => x.trim()).filter(Boolean) - chatHistoryModalInfo.value = { isChatRoom: false } - chatHistoryModalRecords.value = lines.map((line, idx) => normalizeChatHistoryRecordItem({ id: String(idx), datatype: '1', sourcename: '', sourcetime: '', content: line, renderType: 'text' })) +const isChatHistoryRecordItemIncomplete = (recordItemXml) => { + const recordItem = String(recordItemXml || '').trim() + if (!recordItem) return true + try { + const parsed = parseChatHistoryRecord(recordItem) + const got = Array.isArray(parsed?.items) ? parsed.items.length : 0 + const expect = Math.max(0, parseInt(String(parsed?.info?.count || '0'), 10) || 0) + if (expect > 0 && got < expect) return true + if (got <= 0) return true + } catch { + return true } - - chatHistoryModalVisible.value = true - document.body.style.overflow = 'hidden' + return false } -const closeChatHistoryModal = () => { - chatHistoryModalVisible.value = false - chatHistoryModalTitle.value = '' - chatHistoryModalRecords.value = [] - chatHistoryModalInfo.value = { isChatRoom: false } - document.body.style.overflow = previewImageUrl.value ? 'hidden' : '' +const buildChatHistoryWindowPayload = (payload) => { + const title0 = String(payload?.title || '聊天记录') + const content0 = String(payload?.content || '') + const recordItem0 = String(payload?.recordItem || '').trim() + const parsed = parseChatHistoryRecord(recordItem0) + const info0 = parsed?.info || { isChatRoom: false, count: 0 } + const items = Array.isArray(parsed?.items) ? parsed.items : [] + let records0 = items.length ? enhanceChatHistoryRecords(items.map(normalizeChatHistoryRecordItem)) : [] + if (!records0.length) { + // 降级:使用摘要内容按行展示 + const lines = content0.trim().split(/\r?\n/).map((x) => x.trim()).filter(Boolean) + records0 = lines.map((line, idx) => normalizeChatHistoryRecordItem({ + id: String(idx), + datatype: '1', + sourcename: '', + sourcetime: '', + content: line, + renderType: 'text' + })) + } + return { title0, content0, recordItem0, info0, records0 } +} + +const openChatHistoryModal = (message) => { + if (!process.client) return + const { title0, content0, recordItem0, info0, records0 } = buildChatHistoryWindowPayload(message) + const win = openFloatingWindow({ + kind: 'chatHistory', + title: title0 || '聊天记录', + info: info0, + records: records0, + width: 560, + height: Math.round(Math.max(420, (window.innerHeight || 700) * 0.78)), + }) + if (!win) return + // Pre-resolve link cards inside this chat history so they render like WeChat (source/app name, etc). + try { resolveChatHistoryLinkRecords(win) } catch {} + // Root chatHistory messages usually carry the full recordItem already; no further resolve here. +} + +const openNestedChatHistory = (rec) => { + if (!process.client) return + const title0 = String(rec?.title || '聊天记录') + const content0 = String(rec?.content || '') + const recordItem0 = String(rec?.recordItem || '').trim() + const sid = String(rec?.fromnewmsgid || '').trim() + + const { info0, records0 } = buildChatHistoryWindowPayload({ title: title0, content: content0, recordItem: recordItem0 }) + const win = openFloatingWindow({ + kind: 'chatHistory', + title: title0 || '聊天记录', + info: info0, + records: records0, + width: 560, + height: Math.round(Math.max(420, (window.innerHeight || 700) * 0.78)), + loading: false, + }) + if (!win) return + try { resolveChatHistoryLinkRecords(win) } catch {} + + if (!sid) return + if (!selectedAccount.value) return + if (rec && rec._nestedResolving) return + + if (!isChatHistoryRecordItemIncomplete(recordItem0)) return + rec._nestedResolving = true + win.loading = true + + ;(async () => { + try { + const api = useApi() + const resp = await api.resolveNestedChatHistory({ + account: selectedAccount.value, + server_id: sid, + }) + const resolved = String(resp?.recordItem || '').trim() + if (!resolved) return + win.title = String(resp?.title || title0 || '聊天记录') + const parsed = parseChatHistoryRecord(resolved) + win.info = parsed?.info || { isChatRoom: false, count: 0 } + const items = Array.isArray(parsed?.items) ? parsed.items : [] + win.records = items.length ? enhanceChatHistoryRecords(items.map(normalizeChatHistoryRecordItem)) : [] + if (!win.records.length) { + const lines = String(resp?.content || content0 || '').trim().split(/\r?\n/).map((x) => x.trim()).filter(Boolean) + win.info = { isChatRoom: false, count: 0 } + win.records = lines.map((line, idx) => normalizeChatHistoryRecordItem({ id: String(idx), datatype: '1', sourcename: '', sourcetime: '', content: line, renderType: 'text' })) + } + try { resolveChatHistoryLinkRecords(win) } catch {} + } catch {} + finally { + win.loading = false + try { rec._nestedResolving = false } catch {} + } + })() +} + +const getChatHistoryLinkFromText = (rec) => { + const from0 = String(rec?.from || '').trim() + if (from0) return from0 + const u = String(rec?.url || '').trim() + if (!u) return '' + try { return new URL(u).hostname || '' } catch { return '' } +} + +const getChatHistoryLinkFromAvatarText = (rec) => { + const t = String(getChatHistoryLinkFromText(rec) || '').trim() + return t ? (Array.from(t)[0] || '') : '' +} + +const openUrlInBrowser = (url) => { + const u = String(url || '').trim() + if (!u) return + try { window.open(u, '_blank', 'noopener,noreferrer') } catch {} +} + +const resolveChatHistoryLinkRecord = async (rec) => { + if (!process.client) return null + if (!rec) return null + if (!selectedAccount.value) return null + const sid = String(rec?.fromnewmsgid || '').trim() + if (!sid) return null + if (rec._linkResolving) return null + rec._linkResolving = true + try { + const api = useApi() + const resp = await api.resolveAppMsg({ + account: selectedAccount.value, + server_id: sid, + }) + if (resp && typeof resp === 'object') { + const title = String(resp.title || '').trim() + const content = String(resp.content || '').trim() + const url = String(resp.url || '').trim() + const from = String(resp.from || '').trim() + const mediaBase = process.client ? 'http://localhost:8000' : '' + const normalizePreviewUrl = (u) => { + const raw = String(u || '').trim() + if (!raw) return '' + if (/^\/api\/chat\/media\//i.test(raw) || /^blob:/i.test(raw) || /^data:/i.test(raw)) return raw + if (!/^https?:\/\//i.test(raw)) return '' + try { + const host = new URL(raw).hostname.toLowerCase() + if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) { + return `${mediaBase}/api/chat/media/proxy_image?url=${encodeURIComponent(raw)}` + } + } catch {} + return raw + } + if (title) rec.title = title + if (content && !stripWeChatInvisible(rec.content)) rec.content = content + if (url) rec.url = url + if (from) rec.from = from + if (resp.linkStyle) rec.linkStyle = String(resp.linkStyle || '').trim() + if (resp.linkType) rec.linkType = String(resp.linkType || '').trim() + + const fromUsername = String(resp.fromUsername || '').trim() + if (fromUsername) rec.fromUsername = fromUsername + const fromAvatarUrl = fromUsername + ? `${mediaBase}/api/chat/avatar?account=${encodeURIComponent(selectedAccount.value || '')}&username=${encodeURIComponent(fromUsername)}` + : (url ? `${mediaBase}/api/chat/media/favicon?url=${encodeURIComponent(url)}` : '') + if (fromAvatarUrl) { + const last = String(rec._fromAvatarLast || '').trim() + rec.fromAvatar = fromAvatarUrl + if (String(fromAvatarUrl).trim() !== last) { + rec._fromAvatarLast = String(fromAvatarUrl).trim() + rec._fromAvatarImgOk = false + rec._fromAvatarImgError = false + } + } + + const style0 = String(resp.linkStyle || '').trim() + const thumb0 = String(resp.thumbUrl || '').trim() + const cover0 = String(resp.coverUrl || '').trim() + const picked = style0 === 'cover' ? (cover0 || thumb0) : (thumb0 || cover0) + const previewResolved = normalizePreviewUrl(picked) + if (previewResolved) { + const curPreview = String(rec.preview || '').trim() + const candidates0 = Array.isArray(rec._linkPreviewCandidates) ? rec._linkPreviewCandidates.slice() : [] + if (curPreview && !candidates0.includes(curPreview)) candidates0.push(curPreview) + if (!candidates0.includes(previewResolved)) candidates0.push(previewResolved) + rec._linkPreviewCandidates = candidates0 + if (!curPreview || rec._linkPreviewError) { + rec.preview = previewResolved + rec._linkPreviewCandidateIndex = candidates0.indexOf(previewResolved) + rec._linkPreviewError = false + } + } + return resp + } + } catch {} + finally { + try { rec._linkResolving = false } catch {} + } + return null +} + +const resolveChatHistoryLinkRecords = (win) => { + if (!process.client) return + const records = Array.isArray(win?.records) ? win.records : [] + const targets = records.filter((r) => { + if (!r) return false + if (String(r.renderType || '') !== 'link') return false + if (!String(r.fromnewmsgid || '').trim()) return false + const fromMissing = String(r.from || '').trim() === '' + const previewMissing = !String(r.preview || '').trim() + const urlMissing = !String(r.url || '').trim() + const fromAvatarMissing = !String(r.fromAvatar || '').trim() + return fromMissing || previewMissing || urlMissing || fromAvatarMissing + }) + if (!targets.length) return + // Resolve sequentially to avoid spamming the backend. + ;(async () => { + for (const r of targets.slice(0, 12)) { + await resolveChatHistoryLinkRecord(r) + } + })() +} + +const openChatHistoryLinkWindow = (rec) => { + if (!process.client) return + const title0 = String(rec?.title || rec?.content || '链接').trim() + const url0 = String(rec?.url || '').trim() + const preview0 = String(rec?.preview || '').trim() + const from0 = String(rec?.from || '').trim() + const fromAvatar0 = String(rec?.fromAvatar || '').trim() + const needResolve = !!String(rec?.fromnewmsgid || '').trim() && (!url0 || !from0 || !preview0 || !fromAvatar0) + const win = openFloatingWindow({ + kind: 'link', + title: title0 || '链接', + url: url0, + content: String(rec?.content || '').trim(), + preview: preview0, + from: from0, + fromAvatar: fromAvatar0, + width: 520, + height: 420, + loading: needResolve, + }) + if (!win) return + focusFloatingWindow(win.id) + try { + win._linkPreviewCandidates = Array.isArray(rec?._linkPreviewCandidates) ? rec._linkPreviewCandidates.slice() : (preview0 ? [preview0] : []) + win._linkPreviewCandidateIndex = Math.max(0, Number(rec?._linkPreviewCandidateIndex || 0)) + win._linkPreviewError = false + } catch {} + try { + win._fromAvatarLast = fromAvatar0 + win._fromAvatarImgOk = false + win._fromAvatarImgError = false + } catch {} + + if (needResolve) { + // Fill missing fields lazily so the card footer matches WeChat. + ;(async () => { + const resp = await resolveChatHistoryLinkRecord(rec) + if (resp && win) { + win.title = String(rec?.title || title0 || '链接').trim() + win.url = String(rec?.url || url0 || '').trim() + win.content = String(rec?.content || '').trim() + win.from = String(rec?.from || '').trim() + const nextPreview = String(rec?.preview || '').trim() + if (nextPreview) win.preview = nextPreview + const nextFromAvatar = String(rec?.fromAvatar || '').trim() + if (nextFromAvatar) { + win.fromAvatar = nextFromAvatar + win._fromAvatarLast = nextFromAvatar + win._fromAvatarImgOk = false + win._fromAvatarImgError = false + } + try { + win._linkPreviewCandidates = Array.isArray(rec?._linkPreviewCandidates) ? rec._linkPreviewCandidates.slice() : (win.preview ? [win.preview] : []) + win._linkPreviewCandidateIndex = Math.max(0, Number(rec?._linkPreviewCandidateIndex || 0)) + win._linkPreviewError = false + } catch {} + } + if (win) win.loading = false + })() + } else { + win.loading = false + } } const onGlobalClick = (e) => { @@ -5032,6 +6785,7 @@ const onGlobalClick = (e) => { } const openMessageSearch = async () => { + closeTimeSidebar() messageSearchOpen.value = true ensureMessageSearchScopeValid() await nextTick() @@ -5056,6 +6810,7 @@ const onGlobalKeyDown = (e) => { if (key === 'Escape') { if (contextMenu.value.visible) closeContextMenu() if (previewImageUrl.value) closeImagePreview() + if (Array.isArray(floatingWindows.value) && floatingWindows.value.length) closeTopFloatingWindow() if (chatHistoryModalVisible.value) closeChatHistoryModal() if (contactProfileCardOpen.value) { clearContactProfileHoverHideTimer() @@ -5063,6 +6818,7 @@ const onGlobalKeyDown = (e) => { } if (messageSearchSenderDropdownOpen.value) closeMessageSearchSenderDropdown() if (messageSearchOpen.value) closeMessageSearch() + if (timeSidebarOpen.value) closeTimeSidebar() if (searchContext.value?.active) exitSearchContext() } } @@ -5071,12 +6827,22 @@ onMounted(() => { if (!process.client) return document.addEventListener('click', onGlobalClick) document.addEventListener('keydown', onGlobalKeyDown) + document.addEventListener('mousemove', onFloatingWindowMouseMove) + document.addEventListener('mouseup', onFloatingWindowMouseUp) + document.addEventListener('touchmove', onFloatingWindowMouseMove) + document.addEventListener('touchend', onFloatingWindowMouseUp) + document.addEventListener('touchcancel', onFloatingWindowMouseUp) }) onUnmounted(() => { if (!process.client) return document.removeEventListener('click', onGlobalClick) document.removeEventListener('keydown', onGlobalKeyDown) + document.removeEventListener('mousemove', onFloatingWindowMouseMove) + document.removeEventListener('mouseup', onFloatingWindowMouseUp) + document.removeEventListener('touchmove', onFloatingWindowMouseMove) + document.removeEventListener('touchend', onFloatingWindowMouseUp) + document.removeEventListener('touchcancel', onFloatingWindowMouseUp) clearContactProfileHoverHideTimer() stopSessionListResize() if (messageSearchDebounceTimer) clearTimeout(messageSearchDebounceTimer) @@ -5421,12 +7187,82 @@ watch( const autoLoadReady = ref(true) +let timeSidebarScrollSyncRaf = null +const syncTimeSidebarSelectedDateFromScroll = () => { + if (!process.client) return + if (!timeSidebarOpen.value) return + if (!selectedContact.value) return + + const c = messageContainerRef.value + if (!c) return + + if (timeSidebarScrollSyncRaf) return + timeSidebarScrollSyncRaf = requestAnimationFrame(() => { + timeSidebarScrollSyncRaf = null + try { + const containerRect = c.getBoundingClientRect() + const targetY = containerRect.top + 24 + const els = c.querySelectorAll?.('[data-msg-id][data-create-time]') || [] + if (!els || !els.length) return + + let chosen = null + for (const el of els) { + const r = el.getBoundingClientRect?.() + if (!r) continue + if (r.bottom >= targetY) { + chosen = el + break + } + } + if (!chosen) chosen = els[els.length - 1] + const ts = Number(chosen?.getAttribute?.('data-create-time') || 0) + const ds = _dateStrFromEpochSeconds(ts) + if (!ds) return + // Don't await inside rAF; keep scroll handler snappy. + _applyTimeSidebarSelectedDate(ds, { syncMonth: true }) + } catch {} + }) +} + +const contextAutoLoadTopReady = ref(true) +const contextAutoLoadBottomReady = ref(true) + +const onMessageScrollInContextMode = async () => { + const c = messageContainerRef.value + if (!c) return + if (!searchContext.value?.active) return + + const distBottom = c.scrollHeight - c.scrollTop - c.clientHeight + + // Reset "ready" gates when user scrolls away from edges. + if (c.scrollTop > 160) contextAutoLoadTopReady.value = true + if (distBottom > 160) contextAutoLoadBottomReady.value = true + + if (c.scrollTop <= 60 && contextAutoLoadTopReady.value && searchContext.value.hasMoreBefore && !searchContext.value.loadingBefore) { + contextAutoLoadTopReady.value = false + await loadMoreSearchContextBefore() + return + } + + if (distBottom <= 80 && contextAutoLoadBottomReady.value && searchContext.value.hasMoreAfter && !searchContext.value.loadingAfter) { + contextAutoLoadBottomReady.value = false + await loadMoreSearchContextAfter() + } +} + const onMessageScroll = async () => { const c = messageContainerRef.value if (!c) return updateJumpToBottomState() if (!selectedContact.value) return - if (searchContext.value?.active) return + + // Keep the time sidebar selection in sync with the current viewport. + syncTimeSidebarSelectedDateFromScroll() + + if (searchContext.value?.active) { + await onMessageScrollInContextMode() + return + } if (c.scrollTop > 120) { autoLoadReady.value = true @@ -5452,6 +7288,10 @@ const LinkCard = defineComponent({ variant: { type: String, default: 'default' } }, setup(props) { + const fromAvatarImgOk = ref(false) + const fromAvatarImgError = ref(false) + const lastFromAvatarUrl = ref('') + const getFromText = () => { const raw = String(props.from || '').trim() if (raw) return raw @@ -5476,16 +7316,41 @@ const LinkCard = defineComponent({ const fromAvatarUrl = String(props.fromAvatar || '').trim() const isCoverVariant = String(props.variant || '').trim() === 'cover' + // Props may change when switching accounts/chats; reset load state per URL. + if (fromAvatarUrl !== lastFromAvatarUrl.value) { + lastFromAvatarUrl.value = fromAvatarUrl + fromAvatarImgOk.value = false + fromAvatarImgError.value = false + } + + const showFromAvatarImg = Boolean(fromAvatarUrl) && !fromAvatarImgError.value + const showFromAvatarText = (!fromAvatarUrl) || (!fromAvatarImgOk.value) + const fromAvatarStyle = fromAvatarImgOk.value + ? { + background: isCoverVariant ? 'rgba(255, 255, 255, 0.92)' : '#fff', + color: 'transparent' + } + : null + const onFromAvatarLoad = () => { + fromAvatarImgOk.value = true + fromAvatarImgError.value = false + } + const onFromAvatarError = () => { + fromAvatarImgOk.value = false + fromAvatarImgError.value = true + } + if (isCoverVariant) { const fromRow = h('div', { class: 'wechat-link-cover-from' }, [ - h('div', { class: 'wechat-link-cover-from-avatar', 'aria-hidden': 'true' }, [ - fromAvatarText || '\u200B', - fromAvatarUrl ? h('img', { + h('div', { class: 'wechat-link-cover-from-avatar', style: fromAvatarStyle, 'aria-hidden': 'true' }, [ + showFromAvatarText ? (fromAvatarText || '\u200B') : null, + showFromAvatarImg ? h('img', { src: fromAvatarUrl, alt: '', class: 'wechat-link-cover-from-avatar-img', referrerpolicy: 'no-referrer', - onError: (e) => { try { e?.target && (e.target.style.display = 'none') } catch {} } + onLoad: onFromAvatarLoad, + onError: onFromAvatarError }) : null ].filter(Boolean)), h('div', { class: 'wechat-link-cover-from-name' }, fromText || '\u200B') @@ -5574,14 +7439,15 @@ const LinkCard = defineComponent({ ]) : null ].filter(Boolean)), h('div', { class: 'wechat-link-from' }, [ - h('div', { class: 'wechat-link-from-avatar', 'aria-hidden': 'true' }, [ - fromAvatarText || '\u200B', - fromAvatarUrl ? h('img', { + h('div', { class: 'wechat-link-from-avatar', style: fromAvatarStyle, 'aria-hidden': 'true' }, [ + showFromAvatarText ? (fromAvatarText || '\u200B') : null, + showFromAvatarImg ? h('img', { src: fromAvatarUrl, alt: '', class: 'wechat-link-from-avatar-img', referrerpolicy: 'no-referrer', - onError: (e) => { try { e?.target && (e.target.style.display = 'none') } catch {} } + onLoad: onFromAvatarLoad, + onError: onFromAvatarError }) : null ].filter(Boolean)), h('div', { class: 'wechat-link-from-name' }, fromText || '\u200B') diff --git a/src/wechat_decrypt_tool/chat_export_service.py b/src/wechat_decrypt_tool/chat_export_service.py index 50eba58..efc8025 100644 --- a/src/wechat_decrypt_tool/chat_export_service.py +++ b/src/wechat_decrypt_tool/chat_export_service.py @@ -541,6 +541,11 @@ body { background: #EDEDED; } .wce-chat-title { font-size: 16px; font-weight: 500; color: #111827; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .wce-filter-select { font-size: 12px; padding: 6px 8px; border: 0; border-radius: 8px; background: transparent; color: #374151; } .wce-message-container { flex: 1; overflow: auto; padding: 16px; min-height: 0; } +.wce-pager { display: flex; align-items: center; justify-content: center; gap: 12px; padding: 6px 0 12px; } +.wce-pager-btn { font-size: 12px; padding: 6px 10px; border-radius: 8px; border: 1px solid #e5e7eb; background: #fff; color: #374151; cursor: pointer; } +.wce-pager-btn:hover { background: #f9fafb; } +.wce-pager-btn:disabled { opacity: 0.6; cursor: not-allowed; } +.wce-pager-status { font-size: 12px; color: #6b7280; } /* Single session item (middle column). */ .wce-session-item { display: flex; align-items: center; gap: 12px; padding: 0 12px; height: 80px; border-bottom: 1px solid #f3f4f6; background: #DEDEDE; text-decoration: none; color: inherit; } @@ -838,6 +843,140 @@ _HTML_EXPORT_JS = r""" return obj } + const readPageMeta = () => { + const el = document.getElementById('wcePageMeta') + const obj = safeJsonParse(el ? el.textContent : '') + if (!obj || typeof obj !== 'object') return null + return obj + } + + const initPagedMessageLoading = () => { + const meta = readPageMeta() + if (!meta) return + + const totalPages = Number(meta.totalPages || 0) + if (!Number.isFinite(totalPages) || totalPages <= 1) return + + const initialPage = Number(meta.initialPage || totalPages || 1) + const padWidth = Number(meta.padWidth || 0) || 0 + const prefix = String(meta.pageFilePrefix || 'pages/page-') + const suffix = String(meta.pageFileSuffix || '.js') + + const container = document.getElementById('messageContainer') + const list = document.getElementById('wceMessageList') || container + const pager = document.getElementById('wcePager') + const btn = document.getElementById('wceLoadPrevBtn') + const status = document.getElementById('wceLoadPrevStatus') + if (!container || !list || !pager || !btn) return + + try { pager.style.display = '' } catch {} + + const loaded = new Set() + loaded.add(initialPage) + let nextPage = initialPage - 1 + let loading = false + + const setStatus = (text) => { + try { if (status) status.textContent = String(text || '') } catch {} + } + + const updateUi = (overrideText) => { + if (overrideText != null) { + setStatus(overrideText) + try { btn.disabled = false } catch {} + return + } + if (nextPage < 1) { + setStatus('已到底') + try { btn.disabled = true } catch {} + return + } + if (loading) { + setStatus('加载中...') + try { btn.disabled = true } catch {} + return + } + setStatus('点击加载更早消息') + try { btn.disabled = false } catch {} + } + + const pageSrc = (n) => { + const num = padWidth > 0 ? String(n).padStart(padWidth, '0') : String(n) + return prefix + num + suffix + } + + window.__WCE_PAGE_QUEUE__ = window.__WCE_PAGE_QUEUE__ || [] + window.__WCE_PAGE_LOADED__ = (pageNo, html) => { + const n = Number(pageNo) + if (!Number.isFinite(n) || n < 1) return + if (loaded.has(n)) return + loaded.add(n) + + try { + const prevH = container.scrollHeight + const prevTop = container.scrollTop + list.insertAdjacentHTML('afterbegin', String(html || '')) + const newH = container.scrollHeight + container.scrollTop = prevTop + (newH - prevH) + } catch { + try { list.insertAdjacentHTML('afterbegin', String(html || '')) } catch {} + } + + loading = false + nextPage = n - 1 + try { applyMessageTypeFilter() } catch {} + try { updateSessionMessageCount() } catch {} + updateUi() + } + + // Flush any queued pages (should be rare, but keeps behavior robust). + try { + const q = window.__WCE_PAGE_QUEUE__ + if (Array.isArray(q) && q.length) { + const items = q.slice(0) + q.length = 0 + items.forEach((it) => { + try { + if (it && it.length >= 2) window.__WCE_PAGE_LOADED__(it[0], it[1]) + } catch {} + }) + } + } catch {} + + const requestLoad = () => { + if (loading) return + if (nextPage < 1) return + const n = nextPage + + loading = true + updateUi() + + const s = document.createElement('script') + s.async = true + s.src = pageSrc(n) + s.onerror = () => { + loading = false + updateUi('加载失败,可重试') + } + try { document.body.appendChild(s) } catch { + loading = false + updateUi('加载失败,可重试') + } + } + + btn.addEventListener('click', () => requestLoad()) + + let lastScrollAt = 0 + container.addEventListener('scroll', () => { + const now = Date.now() + if (now - lastScrollAt < 200) return + lastScrollAt = now + if (container.scrollTop < 120) requestLoad() + }) + + updateUi() + } + const isMaybeMd5 = (value) => /^[0-9a-f]{32}$/i.test(String(value || '').trim()) const pickFirstMd5 = (...values) => { for (const v of values) { @@ -926,28 +1065,90 @@ _HTML_EXPORT_JS = r""" const getText = (node, tag) => { try { - const el = node.getElementsByTagName(tag)?.[0] + if (!node) return '' + const els = Array.from(node.getElementsByTagName(tag) || []) + const direct = els.find((el) => el && el.parentNode === node) + const el = direct || els[0] return String(el?.textContent || '').trim() } catch { return '' } } + const getDirectChildXml = (node, tag) => { + try { + if (!node) return '' + const children = Array.from(node.children || []) + const el = children.find((c) => String(c?.tagName || '').toLowerCase() === String(tag || '').toLowerCase()) + if (!el) return '' + + const raw = String(el.textContent || '').trim() + if (raw && raw.startsWith('<') && raw.endsWith('>')) return raw + + if (typeof XMLSerializer !== 'undefined') { + return new XMLSerializer().serializeToString(el) + } + } catch {} + return '' + } + + const getAnyXml = (node, tag) => { + try { + if (!node) return '' + const els = Array.from(node.getElementsByTagName(tag) || []) + const direct = els.find((el) => el && el.parentNode === node) + const el = direct || els[0] + if (!el) return '' + + const raw = String(el.textContent || '').trim() + if (raw && raw.startsWith('<') && raw.endsWith('>')) return raw + if (typeof XMLSerializer !== 'undefined') return new XMLSerializer().serializeToString(el) + } catch {} + return '' + } + + const sameTag = (el, tag) => String(el?.tagName || '').toLowerCase() === String(tag || '').toLowerCase() + + const closestAncestorByTag = (node, tag) => { + const lower = String(tag || '').toLowerCase() + let cur = node + while (cur) { + if (cur.nodeType === 1 && String(cur.tagName || '').toLowerCase() === lower) return cur + cur = cur.parentNode + } + return null + } + const root = doc?.documentElement const isChatRoom = String(getText(root, 'isChatRoom') || '').trim() === '1' const title = getText(root, 'title') const desc = getText(root, 'desc') || getText(root, 'info') - const items = Array.from(doc.getElementsByTagName('dataitem') || []) - const parsed = items.map((node, idx) => { - const datatype = String(node.getAttribute('datatype') || '').trim() - const dataid = String(node.getAttribute('dataid') || '').trim() || String(idx) + const datalist = (() => { + try { + const all = Array.from(doc.getElementsByTagName('datalist') || []) + const top = root ? all.find((el) => closestAncestorByTag(el, 'recorditem') === root) : null + return top || all[0] || null + } catch { + return null + } + })() + + const itemNodes = (() => { + if (datalist) return Array.from(datalist.children || []).filter((el) => sameTag(el, 'dataitem')) + return Array.from(root?.children || []).filter((el) => sameTag(el, 'dataitem')) + })() + + const parsed = itemNodes.map((node, idx) => { + const datatype = String(node.getAttribute('datatype') || getText(node, 'datatype') || '').trim() + const dataid = String(node.getAttribute('dataid') || getText(node, 'dataid') || '').trim() || String(idx) const sourcename = getText(node, 'sourcename') const sourcetime = getText(node, 'sourcetime') const sourceheadurl = normalizeChatHistoryUrl(getText(node, 'sourceheadurl')) const datatitle = getText(node, 'datatitle') const datadesc = getText(node, 'datadesc') + const link = normalizeChatHistoryUrl(getText(node, 'link') || getText(node, 'dataurl') || getText(node, 'url')) const datafmt = getText(node, 'datafmt') const duration = getText(node, 'duration') @@ -961,6 +1162,7 @@ _HTML_EXPORT_JS = r""" const fromnewmsgid = getText(node, 'fromnewmsgid') const srcMsgLocalid = getText(node, 'srcMsgLocalid') const srcMsgCreateTime = getText(node, 'srcMsgCreateTime') + const nestedRecordItem = getAnyXml(node, 'recorditem') || getDirectChildXml(node, 'recorditem') || getText(node, 'recorditem') let content = datatitle || datadesc if (!content) { @@ -975,7 +1177,11 @@ _HTML_EXPORT_JS = r""" const imageFormats = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'heic', 'heif']) let renderType = 'text' - if (datatype === '4' || String(duration || '').trim() || fmt === 'mp4') { + if (datatype === '17') { + renderType = 'chatHistory' + } else if (datatype === '5' || link) { + renderType = 'link' + } else if (datatype === '4' || String(duration || '').trim() || fmt === 'mp4') { renderType = 'video' } else if (datatype === '47' || datatype === '37') { renderType = 'emoji' @@ -990,6 +1196,23 @@ _HTML_EXPORT_JS = r""" renderType = 'emoji' } + let outTitle = '' + let outUrl = '' + let recordItem = '' + if (renderType === 'chatHistory') { + outTitle = datatitle || content || '聊天记录' + content = datadesc || '' + recordItem = nestedRecordItem + } else if (renderType === 'link') { + outTitle = datatitle || content || '' + outUrl = link || externurl || '' + // datadesc can be an invisible filler; only keep as description when meaningful. + const cleanDesc = String(datadesc || '').replace(/[\\u3164\\u2800]/g, '').trim() + const cleanTitle = String(outTitle || '').replace(/[\\u3164\\u2800]/g, '').trim() + if (!cleanDesc || (cleanTitle && cleanDesc === cleanTitle)) content = '' + else content = String(datadesc || '').trim() + } + return { id: dataid, datatype, @@ -1009,6 +1232,9 @@ _HTML_EXPORT_JS = r""" srcMsgLocalid, srcMsgCreateTime, renderType, + title: outTitle, + recordItem, + url: outUrl, content } }) @@ -1028,15 +1254,64 @@ _HTML_EXPORT_JS = r""" if (!modal || !titleEl || !closeBtn || !emptyEl || !listEl) return const mediaIndex = readMediaIndex() + let historyStack = [] + let currentState = null + let backBtn = null + + const updateBackVisibility = () => { + if (!backBtn) return + const show = Array.isArray(historyStack) && historyStack.length > 0 + try { backBtn.classList.toggle('hidden', !show) } catch {} + } + + // Add a back button next to the title (created at runtime to avoid changing the HTML template). + try { + const header = titleEl.parentElement + if (header) { + const wrap = document.createElement('div') + wrap.className = 'flex items-center gap-2 min-w-0' + + backBtn = document.createElement('button') + backBtn.type = 'button' + backBtn.className = 'p-2 rounded hover:bg-black/5 flex-shrink-0 hidden' + try { backBtn.setAttribute('aria-label', '返回') } catch {} + try { backBtn.setAttribute('title', '返回') } catch {} + backBtn.innerHTML = '' + + header.insertBefore(wrap, titleEl) + wrap.appendChild(backBtn) + wrap.appendChild(titleEl) + } + } catch {} const close = () => { try { modal.classList.add('hidden') } catch {} try { modal.style.display = 'none' } catch {} try { modal.setAttribute('aria-hidden', 'true') } catch {} try { document.body.style.overflow = '' } catch {} - try { titleEl.textContent = '合并消息' } catch {} + try { titleEl.textContent = '聊天记录' } catch {} try { listEl.textContent = '' } catch {} try { emptyEl.style.display = '' } catch {} + historyStack = [] + currentState = null + updateBackVisibility() + } + + const buildChatHistoryState = (payload) => { + const title = String(payload?.title || '聊天记录').trim() || '聊天记录' + const xml = String(payload?.recordItem || '').trim() + const parsed = parseChatHistoryRecord(xml) + const info = (parsed && parsed.info) ? parsed.info : { isChatRoom: false } + let records = (parsed && Array.isArray(parsed.items)) ? parsed.items : [] + + if (!records.length) { + const lines = Array.isArray(payload?.fallbackLines) + ? payload.fallbackLines + : String(payload?.content || '').trim().split(/\r?\n/).map((x) => String(x || '').trim()).filter(Boolean) + records = lines.map((line, idx) => ({ id: String(idx), renderType: 'text', content: line, sourcename: '', sourcetime: '' })) + } + + return { title, info, records } } const renderRecordRow = (rec, info) => { @@ -1102,7 +1377,123 @@ _HTML_EXPORT_JS = r""" const serverId = String(rec?.fromnewmsgid || '').trim() const serverMd5 = resolveServerMd5(mediaIndex, serverId) - if (rt === 'video') { + if (rt === 'chatHistory') { + const card = document.createElement('div') + card.className = 'wechat-chat-history-card wechat-special-card msg-radius' + + const chBody = document.createElement('div') + chBody.className = 'wechat-chat-history-body' + + const chTitle = document.createElement('div') + chTitle.className = 'wechat-chat-history-title' + chTitle.textContent = String(rec?.title || '聊天记录') + chBody.appendChild(chTitle) + + const raw = String(rec?.content || '').trim() + const lines = raw ? raw.split(/\r?\n/).map((x) => String(x || '').trim()).filter(Boolean).slice(0, 4) : [] + if (lines.length) { + const preview = document.createElement('div') + preview.className = 'wechat-chat-history-preview' + for (const line of lines) { + const el = document.createElement('div') + el.className = 'wechat-chat-history-line' + el.textContent = line + preview.appendChild(el) + } + chBody.appendChild(preview) + } + + card.appendChild(chBody) + + const bottom = document.createElement('div') + bottom.className = 'wechat-chat-history-bottom' + const label = document.createElement('span') + label.textContent = '聊天记录' + bottom.appendChild(label) + card.appendChild(bottom) + + const nestedXml = String(rec?.recordItem || '').trim() + if (nestedXml) { + card.classList.add('cursor-pointer') + card.addEventListener('click', (ev) => { + try { ev.preventDefault() } catch {} + try { ev.stopPropagation() } catch {} + openNestedChatHistory(rec) + }) + } + + body.appendChild(card) + } else if (rt === 'link') { + const href = normalizeChatHistoryUrl(rec?.url) || normalizeChatHistoryUrl(rec?.externurl) + const heading = String(rec?.title || '').trim() || content || href || '链接' + const desc = String(rec?.content || '').trim() + + const thumbMd5 = pickFirstMd5(rec?.fullmd5, rec?.thumbfullmd5, rec?.md5) + let previewUrl = resolveMd5Any(mediaIndex, thumbMd5) + if (!previewUrl && serverMd5) previewUrl = resolveMd5Any(mediaIndex, serverMd5) + if (!previewUrl) previewUrl = resolveRemoteAny(mediaIndex, rec?.externurl, rec?.cdnurlstring, rec?.encrypturlstring) + + const card = document.createElement(href ? 'a' : 'div') + card.className = 'wechat-link-card wechat-special-card msg-radius cursor-pointer' + if (href) { + card.href = href + card.target = '_blank' + card.rel = 'noreferrer noopener' + } + try { card.style.textDecoration = 'none' } catch {} + try { card.style.outline = 'none' } catch {} + + const linkContent = document.createElement('div') + linkContent.className = 'wechat-link-content' + + const linkInfo = document.createElement('div') + linkInfo.className = 'wechat-link-info' + const titleEl = document.createElement('div') + titleEl.className = 'wechat-link-title' + titleEl.textContent = heading + linkInfo.appendChild(titleEl) + if (desc) { + const descEl = document.createElement('div') + descEl.className = 'wechat-link-desc' + descEl.textContent = desc + linkInfo.appendChild(descEl) + } + linkContent.appendChild(linkInfo) + + if (previewUrl) { + const thumb = document.createElement('div') + thumb.className = 'wechat-link-thumb' + const img = document.createElement('img') + img.src = previewUrl + img.alt = heading || '链接预览' + img.className = 'wechat-link-thumb-img' + try { img.referrerPolicy = 'no-referrer' } catch {} + thumb.appendChild(img) + linkContent.appendChild(thumb) + } + + card.appendChild(linkContent) + + const fromRow = document.createElement('div') + fromRow.className = 'wechat-link-from' + const fromText = (() => { + const f0 = String(rec?.from || '').trim() + if (f0) return f0 + try { return href ? (new URL(href).hostname || '') : '' } catch { return '' } + })() + const fromAvatarText = fromText ? (Array.from(fromText)[0] || '') : '' + const fromAvatar = document.createElement('div') + fromAvatar.className = 'wechat-link-from-avatar' + fromAvatar.textContent = fromAvatarText || '\u200B' + const fromName = document.createElement('div') + fromName.className = 'wechat-link-from-name' + fromName.textContent = fromText || '\u200B' + fromRow.appendChild(fromAvatar) + fromRow.appendChild(fromName) + card.appendChild(fromRow) + + body.appendChild(card) + } else if (rt === 'video') { const videoMd5 = pickFirstMd5(rec?.fullmd5, rec?.md5) const thumbMd5 = pickFirstMd5(rec?.thumbfullmd5) || videoMd5 let videoUrl = resolveMd5Any(mediaIndex, videoMd5) @@ -1202,20 +1593,11 @@ _HTML_EXPORT_JS = r""" return row } - const openFromCard = (card) => { - const title = String(card?.getAttribute('data-title') || '合并消息').trim() || '合并消息' - const b64 = String(card?.getAttribute('data-record-item-b64') || '').trim() - const xml = decodeBase64Utf8(b64) - const parsed = parseChatHistoryRecord(xml) - const info = (parsed && parsed.info) ? parsed.info : { isChatRoom: false } - let records = (parsed && Array.isArray(parsed.items)) ? parsed.items : [] - - if (!records.length) { - const lines = Array.from(card.querySelectorAll('.wechat-chat-history-line') || []) - .map((el) => String(el?.textContent || '').trim()) - .filter(Boolean) - records = lines.map((line, idx) => ({ id: String(idx), renderType: 'text', content: line, sourcename: '', sourcetime: '' })) - } + const applyChatHistoryState = (state) => { + currentState = state + const title = String(state?.title || '聊天记录').trim() || '聊天记录' + const info = state?.info || { isChatRoom: false } + const records = Array.isArray(state?.records) ? state.records : [] try { titleEl.textContent = title } catch {} try { listEl.textContent = '' } catch {} @@ -1231,6 +1613,45 @@ _HTML_EXPORT_JS = r""" } } + updateBackVisibility() + } + + const openNestedChatHistory = (rec) => { + const xml = String(rec?.recordItem || '').trim() + if (!xml) return + if (currentState) { + historyStack = [...historyStack, currentState] + } + const state = buildChatHistoryState({ + title: String(rec?.title || '聊天记录'), + recordItem: xml, + content: String(rec?.content || ''), + }) + applyChatHistoryState(state) + } + + if (backBtn) { + backBtn.addEventListener('click', (ev) => { + try { ev.preventDefault() } catch {} + if (!Array.isArray(historyStack) || !historyStack.length) return + const prev = historyStack[historyStack.length - 1] + historyStack = historyStack.slice(0, -1) + applyChatHistoryState(prev) + }) + } + + const openFromCard = (card) => { + const title = String(card?.getAttribute('data-title') || '聊天记录').trim() || '聊天记录' + const b64 = String(card?.getAttribute('data-record-item-b64') || '').trim() + const xml = decodeBase64Utf8(b64) + const lines = Array.from(card.querySelectorAll('.wechat-chat-history-line') || []) + .map((el) => String(el?.textContent || '').trim()) + .filter(Boolean) + + historyStack = [] + const state = buildChatHistoryState({ title, recordItem: xml, fallbackLines: lines }) + applyChatHistoryState(state) + try { modal.classList.remove('hidden') } catch {} try { modal.style.display = 'flex' } catch {} try { modal.setAttribute('aria-hidden', 'false') } catch {} @@ -1269,6 +1690,7 @@ _HTML_EXPORT_JS = r""" initSessionSearch() initVoicePlayback() initChatHistoryModal() + initPagedMessageLoading() const select = document.getElementById('messageTypeFilter') if (select) { @@ -1469,6 +1891,7 @@ class ChatExportManager: output_dir: Optional[str], allow_process_key_extract: bool, download_remote_media: bool, + html_page_size: int = 1000, privacy_mode: bool, file_name: Optional[str], ) -> ExportJob: @@ -1493,6 +1916,7 @@ class ChatExportManager: "outputDir": str(output_dir or "").strip(), "allowProcessKeyExtract": bool(allow_process_key_extract), "downloadRemoteMedia": bool(download_remote_media), + "htmlPageSize": int(html_page_size) if int(html_page_size or 0) > 0 else int(html_page_size or 0), "privacyMode": bool(privacy_mode), "fileName": str(file_name or "").strip(), }, @@ -1544,6 +1968,12 @@ class ChatExportManager: allow_process_key_extract = bool(opts.get("allowProcessKeyExtract")) download_remote_media = bool(opts.get("downloadRemoteMedia")) privacy_mode = bool(opts.get("privacyMode")) + try: + html_page_size = int(opts.get("htmlPageSize") or 1000) + except Exception: + html_page_size = 1000 + if html_page_size < 0: + html_page_size = 0 media_kinds_raw = opts.get("mediaKinds") or [] media_kinds: list[MediaKind] = [] @@ -1898,6 +2328,7 @@ class ChatExportManager: session_items=session_items, download_remote_media=remote_download_enabled, remote_written=remote_written, + html_page_size=html_page_size, start_time=st, end_time=et, want_types=want_types, @@ -2045,6 +2476,7 @@ class ChatExportManager: "mediaKinds": media_kinds, "allowProcessKeyExtract": allow_process_key_extract, "downloadRemoteMedia": bool(download_remote_media), + "htmlPageSize": int(html_page_size) if export_format == "html" else None, "privacyMode": privacy_mode, }, "stats": { @@ -3110,6 +3542,7 @@ def _write_conversation_html( session_items: list[dict[str, Any]], download_remote_media: bool, remote_written: dict[str, str], + html_page_size: int = 1000, start_time: Optional[int], end_time: Optional[int], want_types: Optional[set[str]], @@ -3499,7 +3932,7 @@ def _write_conversation_html( ("emoji", "表情"), ("video", "视频"), ("voice", "语音"), - ("chatHistory", "合并消息"), + ("chatHistory", "聊天记录"), ("transfer", "转账"), ("redPacket", "红包"), ("file", "文件"), @@ -3509,10 +3942,46 @@ def _write_conversation_html( ("voip", "通话"), ] + page_size = 0 + try: + page_size = int(html_page_size or 0) + except Exception: + page_size = 0 + if page_size < 0: + page_size = 0 + # NOTE: write to a temp file first to avoid zip interleaving writes. with tempfile.TemporaryDirectory(prefix="wechat_chat_export_") as tmp_dir: tmp_path = Path(tmp_dir) / "messages.html" - with open(tmp_path, "w", encoding="utf-8", newline="\n") as tw: + pages_frag_dir = Path(tmp_dir) / "pages_fragments" + page_frag_paths: list[Path] = [] + paged_old_page_paths: list[Path] = [] + paged_total_pages = 1 + paged_pad_width = 4 + with open(tmp_path, "w", encoding="utf-8", newline="\n") as hw: + class _WriteProxy: + def __init__(self, default_target): + self._default = default_target + self._target = default_target + + def set_target(self, target) -> None: + self._target = target or self._default + + def write(self, s: str) -> Any: + return self._target.write(s) + + def flush(self) -> None: + try: + if self._target is not self._default: + self._target.flush() + except Exception: + pass + try: + self._default.flush() + except Exception: + pass + + tw = _WriteProxy(hw) tw.write("\n") tw.write('\n') tw.write("\n") @@ -3688,6 +4157,55 @@ def _write_conversation_html( tw.write("
\n") tw.write('
\n') + tw.write(' \n") + tw.write('
\n') + + page_fp = None + page_fp_path: Optional[Path] = None + page_no = 1 + page_msg_count = 0 + + def _open_page_fp() -> Any: + nonlocal page_fp, page_fp_path + pages_frag_dir.mkdir(parents=True, exist_ok=True) + page_fp_path = pages_frag_dir / f"page_{page_no}.htmlfrag" + page_fp = open(page_fp_path, "w", encoding="utf-8", newline="\n") + return page_fp + + def _close_page_fp() -> None: + nonlocal page_fp, page_fp_path + if page_fp is None: + page_fp_path = None + return + try: + page_fp.flush() + except Exception: + pass + try: + page_fp.close() + except Exception: + pass + if page_fp_path is not None: + page_frag_paths.append(page_fp_path) + page_fp = None + page_fp_path = None + tw.set_target(hw) + + def _mark_exported() -> None: + nonlocal exported, page_no, page_msg_count + exported += 1 + with lock: + job.progress.messages_exported += 1 + job.progress.current_conversation_messages_exported = exported + if page_size > 0: + page_msg_count += 1 + if page_msg_count >= page_size: + _close_page_fp() + page_no += 1 + page_msg_count = 0 sender_alias_map: dict[str, int] = {} prev_ts = 0 @@ -3755,6 +4273,11 @@ def _write_conversation_html( if ts and ((prev_ts == 0) or (abs(ts - prev_ts) >= 300)): show_divider = True + if page_size > 0: + if page_fp is None: + _open_page_fp() + tw.set_target(page_fp) + if show_divider: divider_text = _format_session_time(ts) if divider_text: @@ -3770,10 +4293,7 @@ def _write_conversation_html( tw.write(f'
{esc_text(msg.get("content") or "")}
\n') tw.write("
\n") tw.write("
\n") - exported += 1 - with lock: - job.progress.messages_exported += 1 - job.progress.current_conversation_messages_exported = exported + _mark_exported() if ts: prev_ts = ts continue @@ -4186,7 +4706,7 @@ def _write_conversation_html( tw.write("
\n") elif rt == "chatHistory": - title = str(msg.get("title") or "").strip() or "合并消息" + title = str(msg.get("title") or "").strip() or "聊天记录" record_item = str(msg.get("recordItem") or "").strip() record_item_b64 = "" if record_item: @@ -4260,7 +4780,7 @@ def _write_conversation_html( tw.write(f'
{esc_text(line)}
\n') tw.write("
\n") tw.write("
\n") - tw.write('
合并消息
\n') + tw.write('
聊天记录
\n') tw.write("
\n") elif rt == "transfer": received = is_transfer_received(msg) @@ -4328,17 +4848,55 @@ def _write_conversation_html( tw.write("
\n") tw.write("
\n") - exported += 1 - with lock: - job.progress.messages_exported += 1 - job.progress.current_conversation_messages_exported = exported + _mark_exported() if ts: prev_ts = ts if scanned % 500 == 0 and job.cancel_requested: raise _JobCancelled() + if page_size > 0: + _close_page_fp() + paged_total_pages = max(1, len(page_frag_paths)) + paged_pad_width = max(4, len(str(paged_total_pages))) + if page_frag_paths: + paged_old_page_paths = list(page_frag_paths[:-1]) + tw.set_target(hw) + try: + tw.write(page_frag_paths[-1].read_text(encoding="utf-8")) + except Exception: + try: + tw.write(page_frag_paths[-1].read_text(encoding="utf-8", errors="ignore")) + except Exception: + pass + else: + paged_old_page_paths = [] + tw.set_target(hw) + + # Close message list + container + tw.set_target(hw) + tw.write("
\n") tw.write("
\n") + + if page_size > 0 and paged_total_pages > 1: + page_meta = { + "schemaVersion": 1, + "pageSize": int(page_size), + "totalPages": int(paged_total_pages), + "initialPage": int(paged_total_pages), + "totalMessages": int(exported), + "padWidth": int(paged_pad_width), + "pageFilePrefix": "pages/page-", + "pageFileSuffix": ".js", + "inlinedPages": [int(paged_total_pages)], + } + try: + page_meta_payload = json.dumps(page_meta, ensure_ascii=False) + except Exception: + page_meta_payload = "{}" + page_meta_payload = page_meta_payload.replace("{page_meta_payload}\n') + tw.write("
\n") tw.write("
\n") tw.write("
\n") @@ -4357,7 +4915,7 @@ def _write_conversation_html( ) tw.write(' \n") @@ -4377,6 +4935,39 @@ def _write_conversation_html( zf.write(str(tmp_path), arcname) + if page_size > 0 and paged_old_page_paths: + for page_no, frag_path in enumerate(paged_old_page_paths, start=1): + try: + frag_text = frag_path.read_text(encoding="utf-8") + except Exception: + try: + frag_text = frag_path.read_text(encoding="utf-8", errors="ignore") + except Exception: + frag_text = "" + + try: + frag_json = json.dumps(frag_text, ensure_ascii=False) + except Exception: + frag_json = json.dumps("", ensure_ascii=False) + + num = str(page_no).zfill(int(paged_pad_width or 4)) + arc_js = f"{conv_dir}/pages/page-{num}.js" + js_payload = ( + "(() => {\n" + f" const pageNo = {int(page_no)};\n" + f" const html = {frag_json};\n" + " try {\n" + " const fn = window.__WCE_PAGE_LOADED__;\n" + " if (typeof fn === 'function') fn(pageNo, html);\n" + " else {\n" + " const q = (window.__WCE_PAGE_QUEUE__ = window.__WCE_PAGE_QUEUE__ || []);\n" + " q.push([pageNo, html]);\n" + " }\n" + " } catch {}\n" + "})();\n" + ) + zf.writestr(arc_js, js_payload) + return exported diff --git a/src/wechat_decrypt_tool/routers/chat.py b/src/wechat_decrypt_tool/routers/chat.py index c896066..b34f746 100644 --- a/src/wechat_decrypt_tool/routers/chat.py +++ b/src/wechat_decrypt_tool/routers/chat.py @@ -5,6 +5,7 @@ import asyncio import json import time import threading +from datetime import datetime, timedelta from os import scandir from pathlib import Path from typing import Any, Optional @@ -452,6 +453,33 @@ def _resolve_decrypted_message_table(account_dir: Path, username: str) -> Option return None +def _local_month_range_epoch_seconds(*, year: int, month: int) -> tuple[int, int]: + """Return [start, end) range as epoch seconds for local time month boundaries. + + Notes: + - Uses local midnight boundaries (not +86400 * days) to stay DST-safe. + - Returned timestamps are integers (seconds). + """ + + start = datetime(int(year), int(month), 1) + if int(month) == 12: + end = datetime(int(year) + 1, 1, 1) + else: + end = datetime(int(year), int(month) + 1, 1) + return int(start.timestamp()), int(end.timestamp()) + + +def _local_day_range_epoch_seconds(*, date_str: str) -> tuple[int, int, str]: + """Return [start, end) range as epoch seconds for local date boundaries. + + Returns the normalized `YYYY-MM-DD` date string as the 3rd element. + """ + + d0 = datetime.strptime(str(date_str or "").strip(), "%Y-%m-%d") + d1 = d0 + timedelta(days=1) + return int(d0.timestamp()), int(d1.timestamp()), d0.strftime("%Y-%m-%d") + + def _pick_message_db_for_new_table(account_dir: Path, username: str) -> Optional[Path]: """Pick a target decrypted sqlite db to place a new Msg_ table. @@ -3126,6 +3154,27 @@ def _postprocess_full_messages( base_url + f"/api/chat/media/video?account={quote(account_dir.name)}&file_id={quote(video_file_id)}&username={quote(username)}" ) + elif rt == "link": + # Some appmsg link cards (notably Bilibili shares) carry a non-HTTP `` payload + # (often an ASN.1-ish hex blob). The actual preview image is typically saved as: + # msg/attach/{md5(conv_username)}/.../Img/{local_id}_{create_time}_t.dat + # Expose it via the existing image endpoint using file_id. + thumb_url = str(m.get("thumbUrl") or "").strip() + if thumb_url and (not thumb_url.lower().startswith(("http://", "https://"))): + try: + lid = int(m.get("localId") or 0) + except Exception: + lid = 0 + try: + ct = int(m.get("createTime") or 0) + except Exception: + ct = 0 + if lid > 0 and ct > 0: + file_id = f"{lid}_{ct}" + m["thumbUrl"] = ( + base_url + + f"/api/chat/media/image?account={quote(account_dir.name)}&file_id={quote(file_id)}&username={quote(username)}" + ) elif rt == "voice": if str(m.get("serverId") or ""): sid = int(m.get("serverId") or 0) @@ -4090,6 +4139,182 @@ def _collect_chat_messages( return merged, has_more_any, sender_usernames, quote_usernames, pat_usernames +@router.get("/api/chat/messages/daily_counts", summary="获取某月每日消息数(热力图)") +def get_chat_message_daily_counts( + username: str, + year: int, + month: int, + account: Optional[str] = None, +): + username = str(username or "").strip() + if not username: + raise HTTPException(status_code=400, detail="Missing username.") + try: + y = int(year) + m = int(month) + except Exception: + raise HTTPException(status_code=400, detail="Invalid year or month.") + if m < 1 or m > 12: + raise HTTPException(status_code=400, detail="Invalid month.") + + try: + start_ts, end_ts = _local_month_range_epoch_seconds(year=y, month=m) + except Exception: + raise HTTPException(status_code=400, detail="Invalid year or month.") + + account_dir = _resolve_account_dir(account) + db_paths = _iter_message_db_paths(account_dir) + + counts: dict[str, int] = {} + + for db_path in db_paths: + conn = sqlite3.connect(str(db_path)) + try: + try: + table_name = _resolve_msg_table_name(conn, username) + if not table_name: + continue + quoted_table = _quote_ident(table_name) + rows = conn.execute( + "SELECT strftime('%Y-%m-%d', CAST(create_time AS INTEGER), 'unixepoch', 'localtime') AS day, " + "COUNT(*) AS c " + f"FROM {quoted_table} " + "WHERE CAST(create_time AS INTEGER) >= ? AND CAST(create_time AS INTEGER) < ? " + "GROUP BY day", + (int(start_ts), int(end_ts)), + ).fetchall() + for day, c in rows: + k = str(day or "").strip() + if not k: + continue + try: + vv = int(c or 0) + except Exception: + vv = 0 + if vv <= 0: + continue + counts[k] = int(counts.get(k, 0)) + vv + except Exception: + continue + finally: + conn.close() + + total = int(sum(int(v) for v in counts.values())) if counts else 0 + max_count = int(max(counts.values())) if counts else 0 + + return { + "status": "success", + "account": account_dir.name, + "username": username, + "year": int(y), + "month": int(m), + "counts": counts, + "total": total, + "max": max_count, + } + + +@router.get("/api/chat/messages/anchor", summary="获取定位锚点(某日第一条/会话顶部)") +def get_chat_message_anchor( + username: str, + kind: str, + account: Optional[str] = None, + date: Optional[str] = None, +): + username = str(username or "").strip() + if not username: + raise HTTPException(status_code=400, detail="Missing username.") + + kind_norm = str(kind or "").strip().lower() + if kind_norm not in {"day", "first"}: + raise HTTPException(status_code=400, detail="Invalid kind.") + + date_norm: Optional[str] = None + start_ts: Optional[int] = None + end_ts: Optional[int] = None + if kind_norm == "day": + if not date: + raise HTTPException(status_code=400, detail="Missing date.") + try: + start_ts, end_ts, date_norm = _local_day_range_epoch_seconds(date_str=str(date)) + except Exception: + raise HTTPException(status_code=400, detail="Invalid date.") + + account_dir = _resolve_account_dir(account) + db_paths = _iter_message_db_paths(account_dir) + + best_key: Optional[tuple[int, int, int]] = None + best_anchor_id = "" + best_create_time = 0 + + for db_path in db_paths: + conn = sqlite3.connect(str(db_path)) + try: + try: + table_name = _resolve_msg_table_name(conn, username) + if not table_name: + continue + quoted_table = _quote_ident(table_name) + + if kind_norm == "first": + row = conn.execute( + "SELECT local_id, CAST(create_time AS INTEGER) AS create_time, " + "COALESCE(CAST(sort_seq AS INTEGER), 0) AS sort_seq " + f"FROM {quoted_table} " + "ORDER BY CAST(create_time AS INTEGER) ASC, COALESCE(CAST(sort_seq AS INTEGER), 0) ASC, local_id ASC " + "LIMIT 1" + ).fetchone() + else: + row = conn.execute( + "SELECT local_id, CAST(create_time AS INTEGER) AS create_time, " + "COALESCE(CAST(sort_seq AS INTEGER), 0) AS sort_seq " + f"FROM {quoted_table} " + "WHERE CAST(create_time AS INTEGER) >= ? AND CAST(create_time AS INTEGER) < ? " + "ORDER BY CAST(create_time AS INTEGER) ASC, COALESCE(CAST(sort_seq AS INTEGER), 0) ASC, local_id ASC " + "LIMIT 1", + (int(start_ts or 0), int(end_ts or 0)), + ).fetchone() + + if not row: + continue + try: + local_id = int(row[0] or 0) + create_time = int(row[1] or 0) + sort_seq = int(row[2] or 0) + except Exception: + continue + if local_id <= 0: + continue + + key = (int(create_time), int(sort_seq), int(local_id)) + if (best_key is None) or (key < best_key): + best_key = key + best_create_time = int(create_time) + best_anchor_id = f"{db_path.stem}:{table_name}:{local_id}" + except Exception: + continue + finally: + conn.close() + + if not best_anchor_id: + return { + "status": "empty", + "anchorId": "", + } + + resp: dict[str, Any] = { + "status": "success", + "account": account_dir.name, + "username": username, + "kind": kind_norm, + "anchorId": best_anchor_id, + "createTime": int(best_create_time), + } + if date_norm is not None: + resp["date"] = date_norm + return resp + + @router.get("/api/chat/messages", summary="获取会话消息列表") def list_chat_messages( request: Request, @@ -5055,6 +5280,23 @@ def list_chat_messages( base_url + f"/api/chat/media/video?account={quote(account_dir.name)}&file_id={quote(video_file_id)}&username={quote(username)}" ) + elif rt == "link": + thumb_url = str(m.get("thumbUrl") or "").strip() + if thumb_url and (not thumb_url.lower().startswith(("http://", "https://"))): + try: + lid = int(m.get("localId") or 0) + except Exception: + lid = 0 + try: + ct = int(m.get("createTime") or 0) + except Exception: + ct = 0 + if lid > 0 and ct > 0: + file_id = f"{lid}_{ct}" + m["thumbUrl"] = ( + base_url + + f"/api/chat/media/image?account={quote(account_dir.name)}&file_id={quote(file_id)}&username={quote(username)}" + ) elif rt == "voice": if str(m.get("serverId") or ""): sid = int(m.get("serverId") or 0) @@ -5955,6 +6197,7 @@ async def get_chat_messages_around( raise HTTPException(status_code=400, detail="Missing username.") if not anchor_id: raise HTTPException(status_code=400, detail="Missing anchor_id.") + if before < 0: before = 0 if after < 0: @@ -5967,7 +6210,7 @@ async def get_chat_messages_around( parts = str(anchor_id).split(":", 2) if len(parts) != 3: raise HTTPException(status_code=400, detail="Invalid anchor_id.") - anchor_db_stem, anchor_table_name, anchor_local_id_str = parts + anchor_db_stem, anchor_table_name_in, anchor_local_id_str = parts try: anchor_local_id = int(anchor_local_id_str) except Exception: @@ -5980,14 +6223,15 @@ async def get_chat_messages_around( message_resource_db_path = account_dir / "message_resource.db" base_url = str(request.base_url).rstrip("/") - target_db: Optional[Path] = None + anchor_db_path: Optional[Path] = None for p in db_paths: if p.stem == anchor_db_stem: - target_db = p + anchor_db_path = p break - if target_db is None: + if anchor_db_path is None: raise HTTPException(status_code=404, detail="Anchor database not found.") + # Open resource DB once (optional), and reuse for all message DBs. resource_conn: Optional[sqlite3.Connection] = None resource_chat_id: Optional[int] = None try: @@ -6004,200 +6248,378 @@ async def get_chat_messages_around( resource_conn = None resource_chat_id = None - conn = sqlite3.connect(str(target_db)) - conn.row_factory = sqlite3.Row + # Resolve anchor message tuple from its DB. + anchor_ct = 0 + anchor_ss = 0 + anchor_table_name = str(anchor_table_name_in or "").strip() + anchor_row: Optional[sqlite3.Row] = None + anchor_packed_select = "NULL AS packed_info_data, " try: - table_name = str(anchor_table_name).strip() - if not table_name: - raise HTTPException(status_code=404, detail="Anchor table not found.") - - # Normalize table name casing if needed + conn_a = sqlite3.connect(str(anchor_db_path)) + conn_a.row_factory = sqlite3.Row try: - trows = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() - lower_to_actual = {str(x[0]).lower(): str(x[0]) for x in trows if x and x[0]} - table_name = lower_to_actual.get(table_name.lower(), table_name) - except Exception: - pass + if not anchor_table_name: + try: + anchor_table_name = _resolve_msg_table_name(conn_a, username) or "" + except Exception: + anchor_table_name = "" + if not anchor_table_name: + raise HTTPException(status_code=404, detail="Anchor table not found.") - my_wxid = account_dir.name - my_rowid = None - try: - r2 = conn.execute( - "SELECT rowid FROM Name2Id WHERE user_name = ? LIMIT 1", - (my_wxid,), - ).fetchone() - if r2 is not None: - my_rowid = int(r2[0]) - except Exception: - my_rowid = None - - quoted_table = _quote_ident(table_name) - has_packed_info_data = False - try: - cols = conn.execute(f"PRAGMA table_info({quoted_table})").fetchall() - has_packed_info_data = any(str(c[1] or "").strip().lower() == "packed_info_data" for c in cols) - except Exception: - has_packed_info_data = False - packed_select = ( - "m.packed_info_data AS packed_info_data, " if has_packed_info_data else "NULL AS packed_info_data, " - ) - sql_anchor_with_join = ( - "SELECT " - "m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, " - "m.message_content, m.compress_content, " - + packed_select - + "n.user_name AS sender_username " - f"FROM {quoted_table} m " - "LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid " - "WHERE m.local_id = ? " - "LIMIT 1" - ) - sql_anchor_no_join = ( - "SELECT " - "m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, " - "m.message_content, m.compress_content, " - + packed_select - + "'' AS sender_username " - f"FROM {quoted_table} m " - "WHERE m.local_id = ? " - "LIMIT 1" - ) - - conn.text_factory = bytes - - try: - anchor_row = conn.execute(sql_anchor_with_join, (anchor_local_id,)).fetchone() - except Exception: - anchor_row = conn.execute(sql_anchor_no_join, (anchor_local_id,)).fetchone() - - if anchor_row is None: - raise HTTPException(status_code=404, detail="Anchor message not found.") - - anchor_ct = int(anchor_row["create_time"] or 0) - anchor_ss = int(anchor_row["sort_seq"] or 0) if anchor_row["sort_seq"] is not None else 0 - - where_before = ( - "WHERE (" - "m.create_time < ? " - "OR (m.create_time = ? AND COALESCE(m.sort_seq, 0) < ?) " - "OR (m.create_time = ? AND COALESCE(m.sort_seq, 0) = ? AND m.local_id <= ?)" - ")" - ) - where_after = ( - "WHERE (" - "m.create_time > ? " - "OR (m.create_time = ? AND COALESCE(m.sort_seq, 0) > ?) " - "OR (m.create_time = ? AND COALESCE(m.sort_seq, 0) = ? AND m.local_id >= ?)" - ")" - ) - - sql_before_with_join = ( - "SELECT " - "m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, " - "m.message_content, m.compress_content, " - + packed_select - + "n.user_name AS sender_username " - f"FROM {quoted_table} m " - "LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid " - f"{where_before} " - "ORDER BY m.create_time DESC, COALESCE(m.sort_seq, 0) DESC, m.local_id DESC " - "LIMIT ?" - ) - sql_before_no_join = ( - "SELECT " - "m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, " - "m.message_content, m.compress_content, " - + packed_select - + "'' AS sender_username " - f"FROM {quoted_table} m " - f"{where_before} " - "ORDER BY m.create_time DESC, COALESCE(m.sort_seq, 0) DESC, m.local_id DESC " - "LIMIT ?" - ) - - sql_after_with_join = ( - "SELECT " - "m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, " - "m.message_content, m.compress_content, " - + packed_select - + "n.user_name AS sender_username " - f"FROM {quoted_table} m " - "LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid " - f"{where_after} " - "ORDER BY m.create_time ASC, COALESCE(m.sort_seq, 0) ASC, m.local_id ASC " - "LIMIT ?" - ) - sql_after_no_join = ( - "SELECT " - "m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, " - "m.message_content, m.compress_content, " - + packed_select - + "'' AS sender_username " - f"FROM {quoted_table} m " - f"{where_after} " - "ORDER BY m.create_time ASC, COALESCE(m.sort_seq, 0) ASC, m.local_id ASC " - "LIMIT ?" - ) - - params_before = (anchor_ct, anchor_ct, anchor_ss, anchor_ct, anchor_ss, anchor_local_id, int(before) + 1) - params_after = (anchor_ct, anchor_ct, anchor_ss, anchor_ct, anchor_ss, anchor_local_id, int(after) + 1) - - try: - before_rows = conn.execute(sql_before_with_join, params_before).fetchall() - except Exception: - before_rows = conn.execute(sql_before_no_join, params_before).fetchall() - - try: - after_rows = conn.execute(sql_after_with_join, params_after).fetchall() - except Exception: - after_rows = conn.execute(sql_after_no_join, params_after).fetchall() - - seen_ids: set[str] = set() - combined: list[sqlite3.Row] = [] - for rr in list(before_rows) + list(after_rows): - lid = int(rr["local_id"] or 0) - mid = f"{target_db.stem}:{table_name}:{lid}" - if mid in seen_ids: - continue - seen_ids.add(mid) - combined.append(rr) - - merged: list[dict[str, Any]] = [] - sender_usernames: list[str] = [] - quote_usernames: list[str] = [] - pat_usernames: set[str] = set() - is_group = bool(username.endswith("@chatroom")) - - _append_full_messages_from_rows( - merged=merged, - sender_usernames=sender_usernames, - quote_usernames=quote_usernames, - pat_usernames=pat_usernames, - rows=combined, - db_path=target_db, - table_name=table_name, - username=username, - account_dir=account_dir, - is_group=is_group, - my_rowid=my_rowid, - resource_conn=resource_conn, - resource_chat_id=resource_chat_id, - ) - - return_messages = merged - finally: - conn.close() - if resource_conn is not None: + # Normalize table name casing if needed try: - resource_conn.close() + trows = conn_a.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() + lower_to_actual = {str(x[0]).lower(): str(x[0]) for x in trows if x and x[0]} + anchor_table_name = lower_to_actual.get(anchor_table_name.lower(), anchor_table_name) except Exception: pass + quoted_table_a = _quote_ident(anchor_table_name) + has_packed_info_data = False + try: + cols = conn_a.execute(f"PRAGMA table_info({quoted_table_a})").fetchall() + has_packed_info_data = any(str(c[1] or "").strip().lower() == "packed_info_data" for c in cols) + except Exception: + has_packed_info_data = False + anchor_packed_select = ( + "m.packed_info_data AS packed_info_data, " if has_packed_info_data else "NULL AS packed_info_data, " + ) + + sql_anchor_with_join = ( + "SELECT " + "m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, " + "m.message_content, m.compress_content, " + + anchor_packed_select + + "n.user_name AS sender_username " + f"FROM {quoted_table_a} m " + "LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid " + "WHERE m.local_id = ? " + "LIMIT 1" + ) + sql_anchor_no_join = ( + "SELECT " + "m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, " + "m.message_content, m.compress_content, " + + anchor_packed_select + + "'' AS sender_username " + f"FROM {quoted_table_a} m " + "WHERE m.local_id = ? " + "LIMIT 1" + ) + + conn_a.text_factory = bytes + try: + anchor_row = conn_a.execute(sql_anchor_with_join, (anchor_local_id,)).fetchone() + except Exception: + anchor_row = conn_a.execute(sql_anchor_no_join, (anchor_local_id,)).fetchone() + + if anchor_row is None: + raise HTTPException(status_code=404, detail="Anchor message not found.") + + anchor_ct = int(anchor_row["create_time"] or 0) + anchor_ss = int(anchor_row["sort_seq"] or 0) if anchor_row["sort_seq"] is not None else 0 + finally: + conn_a.close() + finally: + pass + + anchor_id_canon = f"{anchor_db_stem}:{anchor_table_name}:{anchor_local_id}" + + merged: list[dict[str, Any]] = [] + sender_usernames_all: list[str] = [] + quote_usernames_all: list[str] = [] + pat_usernames_all: set[str] = set() + is_group = bool(username.endswith("@chatroom")) + + for db_path in db_paths: + conn: Optional[sqlite3.Connection] = None + try: + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + + table_name = "" + if db_path.stem == anchor_db_stem: + table_name = anchor_table_name + else: + try: + table_name = _resolve_msg_table_name(conn, username) or "" + except Exception: + table_name = "" + if not table_name: + continue + + my_wxid = account_dir.name + my_rowid = None + try: + r2 = conn.execute( + "SELECT rowid FROM Name2Id WHERE user_name = ? LIMIT 1", + (my_wxid,), + ).fetchone() + if r2 is not None: + my_rowid = int(r2[0]) + except Exception: + my_rowid = None + + quoted_table = _quote_ident(table_name) + has_packed_info_data = False + try: + cols = conn.execute(f"PRAGMA table_info({quoted_table})").fetchall() + has_packed_info_data = any(str(c[1] or "").strip().lower() == "packed_info_data" for c in cols) + except Exception: + has_packed_info_data = False + packed_select = ( + "m.packed_info_data AS packed_info_data, " if has_packed_info_data else "NULL AS packed_info_data, " + ) + + # Stable cross-db ordering: (create_time, sort_seq, db_stem, local_id) + stem = db_path.stem + if stem < anchor_db_stem: + tie_before = "1" + tie_before_params: tuple[Any, ...] = () + tie_after = "0" + tie_after_params: tuple[Any, ...] = () + elif stem > anchor_db_stem: + tie_before = "0" + tie_before_params = () + tie_after = "1" + tie_after_params = () + else: + tie_before = "m.local_id < ?" + tie_before_params = (int(anchor_local_id),) + tie_after = "m.local_id > ?" + tie_after_params = (int(anchor_local_id),) + + where_before = ( + "WHERE (" + "m.create_time < ? " + "OR (m.create_time = ? AND COALESCE(m.sort_seq, 0) < ?) " + f"OR (m.create_time = ? AND COALESCE(m.sort_seq, 0) = ? AND {tie_before})" + ")" + ) + where_after = ( + "WHERE (" + "m.create_time > ? " + "OR (m.create_time = ? AND COALESCE(m.sort_seq, 0) > ?) " + f"OR (m.create_time = ? AND COALESCE(m.sort_seq, 0) = ? AND {tie_after})" + ")" + ) + + sql_before_with_join = ( + "SELECT " + "m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, " + "m.message_content, m.compress_content, " + + packed_select + + "n.user_name AS sender_username " + f"FROM {quoted_table} m " + "LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid " + f"{where_before} " + "ORDER BY m.create_time DESC, COALESCE(m.sort_seq, 0) DESC, m.local_id DESC " + "LIMIT ?" + ) + sql_before_no_join = ( + "SELECT " + "m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, " + "m.message_content, m.compress_content, " + + packed_select + + "'' AS sender_username " + f"FROM {quoted_table} m " + f"{where_before} " + "ORDER BY m.create_time DESC, COALESCE(m.sort_seq, 0) DESC, m.local_id DESC " + "LIMIT ?" + ) + + sql_after_with_join = ( + "SELECT " + "m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, " + "m.message_content, m.compress_content, " + + packed_select + + "n.user_name AS sender_username " + f"FROM {quoted_table} m " + "LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid " + f"{where_after} " + "ORDER BY m.create_time ASC, COALESCE(m.sort_seq, 0) ASC, m.local_id ASC " + "LIMIT ?" + ) + sql_after_no_join = ( + "SELECT " + "m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, " + "m.message_content, m.compress_content, " + + packed_select + + "'' AS sender_username " + f"FROM {quoted_table} m " + f"{where_after} " + "ORDER BY m.create_time ASC, COALESCE(m.sort_seq, 0) ASC, m.local_id ASC " + "LIMIT ?" + ) + + # Always fetch anchor row from anchor DB, but don't include anchor itself in before/after queries. + anchor_rows: list[sqlite3.Row] = [] + if db_path.stem == anchor_db_stem: + if anchor_row is None: + raise HTTPException(status_code=404, detail="Anchor message not found.") + anchor_rows = [anchor_row] + + conn.text_factory = bytes + + before_rows: list[sqlite3.Row] = [] + if int(before) > 0: + params_before = ( + int(anchor_ct), + int(anchor_ct), + int(anchor_ss), + int(anchor_ct), + int(anchor_ss), + *tie_before_params, + int(before) + 1, + ) + try: + before_rows = conn.execute(sql_before_with_join, params_before).fetchall() + except Exception: + before_rows = conn.execute(sql_before_no_join, params_before).fetchall() + + after_rows: list[sqlite3.Row] = [] + if int(after) > 0: + params_after = ( + int(anchor_ct), + int(anchor_ct), + int(anchor_ss), + int(anchor_ct), + int(anchor_ss), + *tie_after_params, + int(after) + 1, + ) + try: + after_rows = conn.execute(sql_after_with_join, params_after).fetchall() + except Exception: + after_rows = conn.execute(sql_after_no_join, params_after).fetchall() + + # Dedup rows by message id within this DB. + seen_ids: set[str] = set() + combined: list[sqlite3.Row] = [] + for rr in list(before_rows) + list(anchor_rows) + list(after_rows): + lid = int(rr["local_id"] or 0) + mid = f"{db_path.stem}:{table_name}:{lid}" + if mid in seen_ids: + continue + seen_ids.add(mid) + combined.append(rr) + + if not combined: + continue + + _append_full_messages_from_rows( + merged=merged, + sender_usernames=sender_usernames_all, + quote_usernames=quote_usernames_all, + pat_usernames=pat_usernames_all, + rows=combined, + db_path=db_path, + table_name=table_name, + username=username, + account_dir=account_dir, + is_group=is_group, + my_rowid=my_rowid, + resource_conn=resource_conn, + resource_chat_id=resource_chat_id, + ) + except HTTPException: + raise + except Exception: + # Skip broken DBs / missing tables gracefully. + continue + finally: + if conn is not None: + try: + conn.close() + except Exception: + pass + + if resource_conn is not None: + try: + resource_conn.close() + except Exception: + pass + + # Global dedupe + sort. + if merged: + seen_ids2: set[str] = set() + deduped: list[dict[str, Any]] = [] + for m in merged: + mid = str(m.get("id") or "").strip() + if mid and mid in seen_ids2: + continue + if mid: + seen_ids2.add(mid) + deduped.append(m) + merged = deduped + + def sort_key_global(m: dict[str, Any]) -> tuple[int, int, str, int]: + cts = int(m.get("createTime") or 0) + sseq = int(m.get("sortSeq") or 0) + lid = int(m.get("localId") or 0) + mid = str(m.get("id") or "") + stem2 = "" + try: + stem2 = mid.split(":", 1)[0] if ":" in mid else "" + except Exception: + stem2 = "" + return (cts, sseq, stem2, lid) + + merged.sort(key=sort_key_global, reverse=False) + + anchor_index_all = -1 + for i, m in enumerate(merged): + if str(m.get("id") or "") == str(anchor_id_canon): + anchor_index_all = i + break + if anchor_index_all < 0: + # Fallback: ignore table casing differences when matching anchor. + for i, m in enumerate(merged): + mid = str(m.get("id") or "") + p2 = mid.split(":", 2) + if len(p2) != 3: + continue + if p2[0] != anchor_db_stem: + continue + try: + if int(p2[2] or 0) == int(anchor_local_id): + anchor_index_all = i + break + except Exception: + continue + + if anchor_index_all < 0: + # Should not happen because we always include the anchor row, but keep defensive. + anchor_index_all = 0 + + start = max(0, int(anchor_index_all) - int(before)) + end = min(len(merged), int(anchor_index_all) + int(after) + 1) + return_messages = merged[start:end] + anchor_index = int(anchor_index_all) - start if 0 <= anchor_index_all < len(merged) else -1 + + # Postprocess only the returned window to keep it fast. + sender_usernames_win = [str(m.get("senderUsername") or "").strip() for m in return_messages if str(m.get("senderUsername") or "").strip()] + quote_usernames_win = [str(m.get("quoteUsername") or "").strip() for m in return_messages if str(m.get("quoteUsername") or "").strip()] + pat_usernames_win: set[str] = set() + try: + for m in return_messages: + if int(m.get("type") or 0) != 266287972401: + continue + raw = str(m.get("_rawText") or "") + if not raw: + continue + template = _extract_xml_tag_text(raw, "template") + if not template: + continue + pat_usernames_win.update({mm.group(1) for mm in re.finditer(r"\$\{([^}]+)\}", template) if mm.group(1)}) + except Exception: + pat_usernames_win = set() + _postprocess_full_messages( merged=return_messages, - sender_usernames=sender_usernames, - quote_usernames=quote_usernames, - pat_usernames=pat_usernames, + sender_usernames=sender_usernames_win, + quote_usernames=quote_usernames_win, + pat_usernames=pat_usernames_win, account_dir=account_dir, username=username, base_url=base_url, @@ -6205,24 +6627,235 @@ async def get_chat_messages_around( head_image_db_path=head_image_db_path, ) - def sort_key(m: dict[str, Any]) -> tuple[int, int, int]: - sseq = int(m.get("sortSeq") or 0) - cts = int(m.get("createTime") or 0) - lid = int(m.get("localId") or 0) - return (cts, sseq, lid) - - return_messages.sort(key=sort_key, reverse=False) - anchor_index = -1 - for i, m in enumerate(return_messages): - if str(m.get("id") or "") == str(anchor_id): - anchor_index = i - break - return { "status": "success", "account": account_dir.name, "username": username, - "anchorId": anchor_id, + "anchorId": anchor_id_canon, "anchorIndex": anchor_index, "messages": return_messages, } + + +@router.get("/api/chat/chat_history/resolve", summary="解析嵌套合并转发聊天记录(通过 server_id)") +async def resolve_nested_chat_history( + request: Request, + server_id: int, + account: Optional[str] = None, +): + """Resolve a nested merged-forward chat history item (datatype=17) to its full recordItem XML. + + Some nested records inside a merged-forward recordItem only carry pointers like `fromnewmsgid` (server_id), + while the full recordItem exists in the original app message (local_type=49, appmsg type=19) stored elsewhere. + WeChat can open it by looking up the original message; we do the same here. + """ + if not server_id: + raise HTTPException(status_code=400, detail="Missing server_id.") + + account_dir = _resolve_account_dir(account) + db_paths = _iter_message_db_paths(account_dir) + base_url = str(request.base_url).rstrip("/") + found_appmsg = False + + for db_path in db_paths: + conn: Optional[sqlite3.Connection] = None + try: + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + conn.text_factory = bytes + + try: + table_rows = conn.execute( + # Some DBs use `Msg_...` (capital M). Use LOWER() to keep matching even if + # `PRAGMA case_sensitive_like=ON` is set. + "SELECT name FROM sqlite_master WHERE type='table' AND lower(name) LIKE 'msg_%'" + ).fetchall() + except Exception: + table_rows = [] + + # With `conn.text_factory = bytes`, sqlite_master.name comes back as bytes. + # Decode it to the real table name, otherwise we'd end up querying a non-existent + # table like "b'Msg_...'" and never find the message. + table_names = [_decode_sqlite_text(r[0]).strip() for r in table_rows if r and r[0]] + for table_name in table_names: + quoted = _quote_ident(table_name) + try: + row = conn.execute( + f""" + SELECT local_id, server_id, local_type, create_time, message_content, compress_content + FROM {quoted} + -- WeChat v4 can pack appmsg subtype into the high 32 bits of local_type: + -- local_type = base_type + (app_subtype << 32) + -- so a chatHistory appmsg can be 49 + (19<<32), not exactly 49. + WHERE server_id = ? AND (local_type & 4294967295) = 49 + LIMIT 1 + """, + (int(server_id),), + ).fetchone() + except Exception: + row = None + + if row is None: + continue + + found_appmsg = True + raw_text = _decode_message_content(row["compress_content"], row["message_content"]).strip() + if not raw_text: + continue + + # If the stored payload is a zstd frame but we couldn't decode it into XML, it's + # almost always because the optional `zstandard` dependency isn't installed. + try: + blob = row["message_content"] + if isinstance(blob, memoryview): + blob = blob.tobytes() + if isinstance(blob, (bytes, bytearray)) and bytes(blob).startswith(b"\x28\xb5\x2f\xfd"): + lower = raw_text.lower() + if " str: + """Best-effort favicon URL for a given page URL (origin + /favicon.ico).""" + u = str(page_url or "").strip() + if not u: + return "" + try: + p = urlparse(u) + except Exception: + return "" + if not p.scheme or not p.netloc: + return "" + return f"{p.scheme}://{p.netloc}/favicon.ico" + + +def _resolve_final_url_for_favicon(page_url: str) -> str: + """Resolve final URL for redirects (used for favicon host inference).""" + u = str(page_url or "").strip() + if not u: + return "" + + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + } + + # Prefer HEAD (no body). Some hosts reject HEAD; fall back to GET+stream. + try: + r = requests.head(u, headers=headers, timeout=10, allow_redirects=True) + try: + final = str(getattr(r, "url", "") or "").strip() + return final or u + finally: + try: + r.close() + except Exception: + pass + except Exception: + pass + + try: + r = requests.get(u, headers=headers, timeout=10, allow_redirects=True, stream=True) + try: + final = str(getattr(r, "url", "") or "").strip() + return final or u + finally: + try: + r.close() + except Exception: + pass + except Exception: + return u + + +@router.get("/api/chat/media/favicon", summary="获取网站 favicon(用于链接卡片来源头像)") +async def get_favicon(url: str): + page_url = html.unescape(str(url or "")).strip() + if not page_url: + raise HTTPException(status_code=400, detail="Missing url.") + if not _is_safe_http_url(page_url): + raise HTTPException(status_code=400, detail="Invalid url (only public http/https allowed).") + + # Resolve redirects first (e.g. b23.tv -> www.bilibili.com), so cached favicons are hit early. + final_url = _resolve_final_url_for_favicon(page_url) + candidates: list[str] = [] + for u in (final_url, page_url): + fav = _origin_favicon_url(u) + if fav and fav not in candidates: + candidates.append(fav) + + proxy_account = "_favicon" + max_bytes = 512 * 1024 # favicons should be small; protect against huge downloads. + + for cand in candidates: + if not _is_safe_http_url(cand): + continue + source_url = normalize_avatar_source_url(cand) + + cache_entry = get_avatar_cache_url_entry(proxy_account, source_url) if is_avatar_cache_enabled() else None + cache_file = avatar_cache_entry_file_exists(proxy_account, cache_entry) + if cache_entry and cache_file and avatar_cache_entry_is_fresh(cache_entry): + logger.info(f"[avatar_cache_hit] kind=favicon account={proxy_account} url={source_url}") + touch_avatar_cache_entry(proxy_account, cache_key_for_avatar_url(source_url)) + headers = build_avatar_cache_response_headers(cache_entry) + return FileResponse( + str(cache_file), + media_type=str(cache_entry.get("media_type") or "application/octet-stream"), + headers=headers, + ) + + # Download favicon bytes (best-effort) + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36", + "Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", + } + r = None + try: + r = requests.get(source_url, headers=headers, timeout=20, stream=True, allow_redirects=True) + if int(getattr(r, "status_code", 0) or 0) != 200: + continue + + ct = str((getattr(r, "headers", {}) or {}).get("Content-Type") or "").strip() + try: + cl = int((getattr(r, "headers", {}) or {}).get("content-length") or 0) + except Exception: + cl = 0 + if cl and cl > max_bytes: + raise HTTPException(status_code=413, detail="Remote favicon too large.") + + chunks: list[bytes] = [] + total = 0 + for chunk in r.iter_content(chunk_size=64 * 1024): + if not chunk: + continue + chunks.append(chunk) + total += len(chunk) + if total > max_bytes: + raise HTTPException(status_code=413, detail="Remote favicon too large.") + data = b"".join(chunks) + except HTTPException: + raise + except Exception: + continue + finally: + if r is not None: + try: + r.close() + except Exception: + pass + + if not data: + continue + + payload, media_type, _ext = _detect_media_type_and_ext(data) + if media_type == "application/octet-stream" and ct: + try: + mt = ct.split(";")[0].strip() + if mt.startswith("image/"): + media_type = mt + except Exception: + pass + + if not str(media_type or "").startswith("image/"): + continue + + if is_avatar_cache_enabled(): + entry, out_path = write_avatar_cache_payload( + proxy_account, + source_kind="url", + source_url=source_url, + payload=payload, + media_type=media_type, + ttl_seconds=AVATAR_CACHE_TTL_SECONDS, + ) + if entry and out_path: + logger.info(f"[avatar_cache_download] kind=favicon account={proxy_account} url={source_url}") + headers = build_avatar_cache_response_headers(entry) + return FileResponse(str(out_path), media_type=media_type, headers=headers) + + resp = Response(content=payload, media_type=media_type) + resp.headers["Cache-Control"] = f"public, max-age={AVATAR_CACHE_TTL_SECONDS}" + return resp + + raise HTTPException(status_code=404, detail="favicon not found.") + + @router.post("/api/chat/media/emoji/download", summary="下载表情消息资源到本地 resource") async def download_chat_emoji(req: EmojiDownloadRequest): md5 = str(req.md5 or "").strip().lower() diff --git a/tests/test_chat_export_html_paging.py b/tests/test_chat_export_html_paging.py new file mode 100644 index 0000000..885fd81 --- /dev/null +++ b/tests/test_chat_export_html_paging.py @@ -0,0 +1,221 @@ +import os +import json +import hashlib +import sqlite3 +import sys +import unittest +import zipfile +import importlib +from pathlib import Path +from tempfile import TemporaryDirectory + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + + +class TestChatExportHtmlPaging(unittest.TestCase): + def _reload_export_modules(self): + import wechat_decrypt_tool.app_paths as app_paths + import wechat_decrypt_tool.chat_helpers as chat_helpers + import wechat_decrypt_tool.media_helpers as media_helpers + import wechat_decrypt_tool.chat_export_service as chat_export_service + + importlib.reload(app_paths) + importlib.reload(chat_helpers) + importlib.reload(media_helpers) + importlib.reload(chat_export_service) + return chat_export_service + + def _seed_contact_db(self, path: Path, *, account: str, username: str) -> None: + conn = sqlite3.connect(str(path)) + try: + conn.execute( + """ + CREATE TABLE contact ( + username TEXT, + remark TEXT, + nick_name TEXT, + alias TEXT, + local_type INTEGER, + verify_flag INTEGER, + big_head_url TEXT, + small_head_url TEXT + ) + """ + ) + conn.execute( + """ + CREATE TABLE stranger ( + username TEXT, + remark TEXT, + nick_name TEXT, + alias TEXT, + local_type INTEGER, + verify_flag INTEGER, + big_head_url TEXT, + small_head_url TEXT + ) + """ + ) + conn.execute( + "INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (account, "", "Me", "", 1, 0, "", ""), + ) + conn.execute( + "INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (username, "", "Friend", "", 1, 0, "", ""), + ) + conn.commit() + finally: + conn.close() + + def _seed_session_db(self, path: Path, *, username: str) -> None: + conn = sqlite3.connect(str(path)) + try: + conn.execute( + """ + CREATE TABLE SessionTable ( + username TEXT, + is_hidden INTEGER, + sort_timestamp INTEGER + ) + """ + ) + conn.execute( + "INSERT INTO SessionTable VALUES (?, ?, ?)", + (username, 0, 1735689600), + ) + conn.commit() + finally: + conn.close() + + def _seed_message_db(self, path: Path, *, account: str, username: str, total: int) -> None: + conn = sqlite3.connect(str(path)) + try: + conn.execute("CREATE TABLE Name2Id (rowid INTEGER PRIMARY KEY, user_name TEXT)") + conn.execute("INSERT INTO Name2Id(rowid, user_name) VALUES (?, ?)", (1, account)) + conn.execute("INSERT INTO Name2Id(rowid, user_name) VALUES (?, ?)", (2, username)) + + table_name = f"msg_{hashlib.md5(username.encode('utf-8')).hexdigest()}" + conn.execute( + f""" + CREATE TABLE {table_name} ( + local_id INTEGER, + server_id INTEGER, + local_type INTEGER, + sort_seq INTEGER, + real_sender_id INTEGER, + create_time INTEGER, + message_content TEXT, + compress_content BLOB + ) + """ + ) + + # Generate lots of plain text messages with unique markers. + rows = [] + base_ts = 1735689600 + for i in range(1, total + 1): + marker = f"MSG{i:04d}" + real_sender_id = 1 if (i % 2 == 0) else 2 + rows.append((i, 100000 + i, 1, i, real_sender_id, base_ts + i, marker, None)) + + conn.executemany( + f"INSERT INTO {table_name} (local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + rows, + ) + conn.commit() + finally: + conn.close() + + def _prepare_account(self, root: Path, *, account: str, username: str, total: int) -> Path: + account_dir = root / "output" / "databases" / account + account_dir.mkdir(parents=True, exist_ok=True) + self._seed_contact_db(account_dir / "contact.db", account=account, username=username) + self._seed_session_db(account_dir / "session.db", username=username) + self._seed_message_db(account_dir / "message_0.db", account=account, username=username, total=total) + return account_dir + + def _create_job(self, manager, *, account: str, username: str, html_page_size: int): + job = manager.create_job( + account=account, + scope="selected", + usernames=[username], + export_format="html", + start_time=None, + end_time=None, + include_hidden=False, + include_official=False, + include_media=False, + media_kinds=[], + message_types=[], + output_dir=None, + allow_process_key_extract=False, + download_remote_media=False, + html_page_size=html_page_size, + privacy_mode=False, + file_name=None, + ) + + # Export is async (thread). Allow enough time for a few thousand messages + zip writes. + for _ in range(600): + latest = manager.get_job(job.export_id) + if latest and latest.status in {"done", "error", "cancelled"}: + return latest + import time as _time + + _time.sleep(0.05) + self.fail("export job did not finish in time") + + def test_html_export_paging_inlines_latest_page_only(self): + with TemporaryDirectory() as td: + root = Path(td) + account = "wxid_test" + username = "wxid_friend" + + total_messages = 2300 + page_size = 1000 + self._prepare_account(root, account=account, username=username, total=total_messages) + + prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR") + try: + os.environ["WECHAT_TOOL_DATA_DIR"] = str(root) + svc = self._reload_export_modules() + job = self._create_job( + svc.CHAT_EXPORT_MANAGER, + account=account, + username=username, + html_page_size=page_size, + ) + self.assertEqual(job.status, "done", msg=job.error) + + self.assertTrue(job.zip_path and job.zip_path.exists()) + with zipfile.ZipFile(job.zip_path, "r") as zf: + names = set(zf.namelist()) + + html_path = next((n for n in names if n.endswith("/messages.html")), "") + self.assertTrue(html_path, msg="missing messages.html") + html_text = zf.read(html_path).decode("utf-8", errors="ignore") + + # Paging UI + meta should exist for multi-page exports. + self.assertIn('id="wcePageMeta"', html_text) + self.assertIn('id="wcePager"', html_text) + self.assertIn('id="wceMessageList"', html_text) + self.assertIn('id="wceLoadPrevBtn"', html_text) + + # Latest page is inlined; earliest page should not be present in messages.html. + self.assertIn("MSG2300", html_text) + self.assertNotIn("MSG0001", html_text) + + conv_dir = html_path.rsplit("/", 1)[0] + page1_js = f"{conv_dir}/pages/page-0001.js" + self.assertIn(page1_js, names) + page1_text = zf.read(page1_js).decode("utf-8", errors="ignore") + self.assertIn("MSG0001", page1_text) + finally: + if prev_data is None: + os.environ.pop("WECHAT_TOOL_DATA_DIR", None) + else: + os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data diff --git a/tests/test_chat_media_favicon.py b/tests/test_chat_media_favicon.py new file mode 100644 index 0000000..516ef84 --- /dev/null +++ b/tests/test_chat_media_favicon.py @@ -0,0 +1,133 @@ +import os +import sqlite3 +import sys +import unittest +import importlib +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + + +class _FakeResponse: + def __init__(self, *, status_code: int = 200, headers: dict | None = None, url: str = "", body: bytes = b""): + self.status_code = int(status_code) + self.headers = dict(headers or {}) + self.url = str(url or "") + self._body = bytes(body or b"") + + def iter_content(self, chunk_size: int = 64 * 1024): + yield self._body + + def close(self) -> None: + return None + + +class TestChatMediaFavicon(unittest.TestCase): + def test_chat_media_favicon_caches(self): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + # 1x1 PNG (same as other avatar cache tests) + png = bytes.fromhex( + "89504E470D0A1A0A" + "0000000D49484452000000010000000108060000001F15C489" + "0000000D49444154789C6360606060000000050001A5F64540" + "0000000049454E44AE426082" + ) + + with TemporaryDirectory() as td: + root = Path(td) + + prev_data = None + prev_cache = None + try: + prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR") + prev_cache = os.environ.get("WECHAT_TOOL_AVATAR_CACHE_ENABLED") + os.environ["WECHAT_TOOL_DATA_DIR"] = str(root) + os.environ["WECHAT_TOOL_AVATAR_CACHE_ENABLED"] = "1" + + import wechat_decrypt_tool.app_paths as app_paths + import wechat_decrypt_tool.avatar_cache as avatar_cache + import wechat_decrypt_tool.routers.chat_media as chat_media + + importlib.reload(app_paths) + importlib.reload(avatar_cache) + importlib.reload(chat_media) + + def fake_head(url, **_kwargs): + # Pretend short-link resolves to bilibili. + return _FakeResponse( + status_code=200, + headers={}, + url="https://www.bilibili.com/video/BV1Au4tzNEq2", + body=b"", + ) + + def fake_get(url, **_kwargs): + u = str(url or "") + if "www.bilibili.com/favicon.ico" in u: + return _FakeResponse( + status_code=200, + headers={"Content-Type": "image/png", "content-length": str(len(png))}, + url=u, + body=png, + ) + return _FakeResponse( + status_code=404, + headers={"Content-Type": "text/html"}, + url=u, + body=b"", + ) + + app = FastAPI() + app.include_router(chat_media.router) + client = TestClient(app) + + with patch("wechat_decrypt_tool.routers.chat_media.requests.head", side_effect=fake_head) as mock_head, patch( + "wechat_decrypt_tool.routers.chat_media.requests.get", side_effect=fake_get + ) as mock_get: + resp = client.get("/api/chat/media/favicon", params={"url": "https://b23.tv/au68guF"}) + self.assertEqual(resp.status_code, 200) + self.assertTrue(resp.headers.get("content-type", "").startswith("image/")) + self.assertEqual(resp.content, png) + + # Second call should hit disk cache (no extra favicon download). + resp2 = client.get("/api/chat/media/favicon", params={"url": "https://b23.tv/au68guF"}) + self.assertEqual(resp2.status_code, 200) + self.assertEqual(resp2.content, png) + + self.assertGreaterEqual(mock_head.call_count, 1) + self.assertEqual(mock_get.call_count, 1) + + cache_db = root / "output" / "avatar_cache" / "favicon" / "avatar_cache.db" + self.assertTrue(cache_db.exists()) + + conn = sqlite3.connect(str(cache_db)) + try: + row = conn.execute( + "SELECT source_kind, source_url, media_type FROM avatar_cache_entries WHERE source_kind = 'url' LIMIT 1" + ).fetchone() + self.assertIsNotNone(row) + self.assertEqual(str(row[0] or ""), "url") + self.assertIn("favicon.ico", str(row[1] or "")) + self.assertTrue(str(row[2] or "").startswith("image/")) + finally: + conn.close() + finally: + if prev_data is None: + os.environ.pop("WECHAT_TOOL_DATA_DIR", None) + else: + os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data + if prev_cache is None: + os.environ.pop("WECHAT_TOOL_AVATAR_CACHE_ENABLED", None) + else: + os.environ["WECHAT_TOOL_AVATAR_CACHE_ENABLED"] = prev_cache + + +if __name__ == "__main__": + unittest.main() + diff --git a/tests/test_chat_message_calendar_heatmap.py b/tests/test_chat_message_calendar_heatmap.py new file mode 100644 index 0000000..1075ae5 --- /dev/null +++ b/tests/test_chat_message_calendar_heatmap.py @@ -0,0 +1,292 @@ +import hashlib +import sqlite3 +import sys +import unittest +from datetime import datetime +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + + +from wechat_decrypt_tool.routers import chat as chat_router + + +def _msg_table_name(username: str) -> str: + md5_hex = hashlib.md5(username.encode("utf-8")).hexdigest() + return f"Msg_{md5_hex}" + + +def _seed_message_db(path: Path, *, username: str, rows: list[tuple[int, int]]) -> None: + """rows: [(create_time, sort_seq), ...]""" + table = _msg_table_name(username) + conn = sqlite3.connect(str(path)) + try: + conn.execute( + f""" + CREATE TABLE "{table}"( + local_id INTEGER PRIMARY KEY AUTOINCREMENT, + create_time INTEGER, + sort_seq INTEGER + ) + """ + ) + for create_time, sort_seq in rows: + conn.execute( + f'INSERT INTO "{table}"(create_time, sort_seq) VALUES (?, ?)', + (int(create_time), int(sort_seq)), + ) + conn.commit() + finally: + conn.close() + + +def _seed_message_db_full(path: Path, *, username: str, rows: list[tuple[int, int, str]]) -> None: + """rows: [(create_time, sort_seq, text), ...] - minimal schema for /api/chat/messages/around.""" + + table = _msg_table_name(username) + conn = sqlite3.connect(str(path)) + try: + conn.execute( + f""" + CREATE TABLE "{table}"( + local_id INTEGER PRIMARY KEY AUTOINCREMENT, + server_id INTEGER, + local_type INTEGER, + sort_seq INTEGER, + real_sender_id INTEGER, + create_time INTEGER, + message_content TEXT, + compress_content BLOB + ) + """ + ) + for create_time, sort_seq, text in rows: + conn.execute( + f'INSERT INTO "{table}"(server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content) ' + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (0, 1, int(sort_seq), 0, int(create_time), str(text), None), + ) + conn.commit() + finally: + conn.close() + + +def _seed_contact_db_minimal(path: Path) -> None: + conn = sqlite3.connect(str(path)) + try: + conn.execute( + """ + CREATE TABLE contact ( + username TEXT, + remark TEXT, + nick_name TEXT, + alias TEXT, + big_head_url TEXT, + small_head_url TEXT + ) + """ + ) + conn.execute( + """ + CREATE TABLE stranger ( + username TEXT, + remark TEXT, + nick_name TEXT, + alias TEXT, + big_head_url TEXT, + small_head_url TEXT + ) + """ + ) + conn.commit() + finally: + conn.close() + + +class TestChatMessageCalendarHeatmap(unittest.TestCase): + def test_daily_counts_aggregates_per_day_and_respects_month_range(self): + with TemporaryDirectory() as td: + account_dir = Path(td) / "acc" + account_dir.mkdir(parents=True, exist_ok=True) + + username = "wxid_test_user" + + ts_jan31_23 = int(datetime(2026, 1, 31, 23, 0, 0).timestamp()) + ts_feb01_10 = int(datetime(2026, 2, 1, 10, 0, 0).timestamp()) + ts_feb14_12 = int(datetime(2026, 2, 14, 12, 0, 0).timestamp()) + + _seed_message_db( + account_dir / "message.db", + username=username, + rows=[ + (ts_jan31_23, 0), + (ts_feb01_10, 5), + (ts_feb01_10, 2), + (ts_feb14_12, 0), + ], + ) + + with patch.object(chat_router, "_resolve_account_dir", return_value=account_dir): + resp = chat_router.get_chat_message_daily_counts( + username=username, + year=2026, + month=2, + account="acc", + ) + + self.assertEqual(resp.get("status"), "success") + self.assertEqual(resp.get("username"), username) + self.assertEqual(resp.get("year"), 2026) + self.assertEqual(resp.get("month"), 2) + + counts = resp.get("counts") or {} + self.assertEqual(counts.get("2026-02-01"), 2) + self.assertEqual(counts.get("2026-02-14"), 1) + self.assertIsNone(counts.get("2026-01-31")) + + self.assertEqual(resp.get("total"), 3) + self.assertEqual(resp.get("max"), 2) + + def test_anchor_day_picks_earliest_by_create_time_then_sort_seq_then_local_id(self): + with TemporaryDirectory() as td: + account_dir = Path(td) / "acc" + account_dir.mkdir(parents=True, exist_ok=True) + + username = "wxid_test_user" + + ts_jan31_23 = int(datetime(2026, 1, 31, 23, 0, 0).timestamp()) + ts_feb01_10 = int(datetime(2026, 2, 1, 10, 0, 0).timestamp()) + + _seed_message_db( + account_dir / "message.db", + username=username, + rows=[ + (ts_jan31_23, 0), # local_id = 1 + (ts_feb01_10, 5), # local_id = 2 + (ts_feb01_10, 2), # local_id = 3 <- expected (sort_seq smaller) + ], + ) + + with patch.object(chat_router, "_resolve_account_dir", return_value=account_dir): + resp = chat_router.get_chat_message_anchor( + username=username, + kind="day", + account="acc", + date="2026-02-01", + ) + + self.assertEqual(resp.get("status"), "success") + self.assertEqual(resp.get("kind"), "day") + self.assertEqual(resp.get("date"), "2026-02-01") + anchor_id = str(resp.get("anchorId") or "") + self.assertTrue(anchor_id.startswith("message:"), anchor_id) + self.assertTrue(anchor_id.endswith(":3"), anchor_id) + + def test_anchor_first_picks_global_earliest(self): + with TemporaryDirectory() as td: + account_dir = Path(td) / "acc" + account_dir.mkdir(parents=True, exist_ok=True) + + username = "wxid_test_user" + + ts_jan31_23 = int(datetime(2026, 1, 31, 23, 0, 0).timestamp()) + ts_feb01_10 = int(datetime(2026, 2, 1, 10, 0, 0).timestamp()) + + _seed_message_db( + account_dir / "message.db", + username=username, + rows=[ + (ts_feb01_10, 2), # local_id = 1 + (ts_jan31_23, 0), # local_id = 2, but earlier create_time -> should win even if local_id bigger + ], + ) + + with patch.object(chat_router, "_resolve_account_dir", return_value=account_dir): + resp = chat_router.get_chat_message_anchor( + username=username, + kind="first", + account="acc", + ) + + self.assertEqual(resp.get("status"), "success") + self.assertEqual(resp.get("kind"), "first") + anchor_id = str(resp.get("anchorId") or "") + self.assertTrue(anchor_id.startswith("message:"), anchor_id) + self.assertTrue(anchor_id.endswith(":2"), anchor_id) + + def test_anchor_day_empty_returns_empty_status(self): + with TemporaryDirectory() as td: + account_dir = Path(td) / "acc" + account_dir.mkdir(parents=True, exist_ok=True) + + username = "wxid_test_user" + ts_feb01_10 = int(datetime(2026, 2, 1, 10, 0, 0).timestamp()) + + _seed_message_db(account_dir / "message.db", username=username, rows=[(ts_feb01_10, 0)]) + + with patch.object(chat_router, "_resolve_account_dir", return_value=account_dir): + resp = chat_router.get_chat_message_anchor( + username=username, + kind="day", + account="acc", + date="2026-02-02", + ) + + self.assertEqual(resp.get("status"), "empty") + self.assertEqual(resp.get("anchorId"), "") + + def test_around_can_span_multiple_message_dbs_for_pagination(self): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + with TemporaryDirectory() as td: + account_dir = Path(td) / "acc" + account_dir.mkdir(parents=True, exist_ok=True) + + username = "wxid_test_user" + table = _msg_table_name(username) + + # Anchor in message.db, next message in message_1.db + _seed_message_db_full( + account_dir / "message.db", + username=username, + rows=[(1000, 0, "A")], # local_id=1 + ) + _seed_message_db_full( + account_dir / "message_1.db", + username=username, + rows=[(2000, 0, "B")], # local_id=1 + ) + _seed_contact_db_minimal(account_dir / "contact.db") + + app = FastAPI() + app.include_router(chat_router.router) + client = TestClient(app) + + with patch.object(chat_router, "_resolve_account_dir", return_value=account_dir): + resp = client.get( + "/api/chat/messages/around", + params={ + "account": "acc", + "username": username, + "anchor_id": f"message:{table}:1", + "before": 0, + "after": 10, + }, + ) + + self.assertEqual(resp.status_code, 200, resp.text) + data = resp.json() + self.assertEqual(data.get("status"), "success") + self.assertEqual(data.get("username"), username) + self.assertEqual(data.get("anchorId"), f"message:{table}:1") + self.assertEqual(data.get("anchorIndex"), 0) + + msgs = data.get("messages") or [] + self.assertEqual(len(msgs), 2) + self.assertEqual(msgs[0].get("id"), f"message:{table}:1") + self.assertEqual(msgs[1].get("id"), f"message_1:{table}:1")