From 36f147678206affb16427121adb9d03f9bce4290 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Thu, 5 Mar 2026 10:36:29 +0800 Subject: [PATCH] feat(export): add session name prefix toggle in layout dropdown --- electron/services/exportService.ts | 136 +++++++++++++++++++++++++++-- src/pages/ExportPage.scss | 62 +++++++++++++ src/pages/ExportPage.tsx | 34 +++++++- src/services/config.ts | 11 +++ src/types/electron.d.ts | 1 + 5 files changed, 233 insertions(+), 11 deletions(-) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index e69c5eb..5507bd6 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -85,6 +85,7 @@ export interface ExportOptions { excelCompactColumns?: boolean txtColumns?: string[] sessionLayout?: 'shared' | 'per-session' + sessionNameWithTypePrefix?: boolean displayNamePreference?: 'group-nickname' | 'remark' | 'nickname' exportConcurrency?: number } @@ -1652,12 +1653,110 @@ class ExportService { return match[1].replace(//g, '').trim() } + private extractAppMessageType(content: string): string { + if (!content) return '' + const appmsgMatch = /([\s\S]*?)<\/appmsg>/i.exec(content) + if (appmsgMatch) { + const appmsgInner = appmsgMatch[1] + .replace(//gi, '') + .replace(//gi, '') + const typeMatch = /([\s\S]*?)<\/type>/i.exec(appmsgInner) + if (typeMatch) return typeMatch[1].trim() + } + return this.extractXmlValue(content, 'type') + } + + private looksLikeWxid(text: string): boolean { + if (!text) return false + const trimmed = text.trim().toLowerCase() + if (trimmed.startsWith('wxid_')) return true + return /^wx[a-z0-9_-]{4,}$/.test(trimmed) + } + + private sanitizeQuotedContent(content: string): string { + if (!content) return '' + let result = content + result = result.replace(/wxid_[A-Za-z0-9_-]{3,}/g, '') + result = result.replace(/^[\s::\-]+/, '') + result = result.replace(/[::]{2,}/g, ':') + result = result.replace(/^[\s::\-]+/, '') + result = result.replace(/\s+/g, ' ').trim() + return result + } + + private parseQuoteMessage(content: string): { content?: string; sender?: string; type?: string } { + try { + const normalized = this.normalizeAppMessageContent(content || '') + const referMsgStart = normalized.indexOf('') + const referMsgEnd = normalized.indexOf('') + if (referMsgStart === -1 || referMsgEnd === -1) { + return {} + } + + const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11) + let sender = this.extractXmlValue(referMsgXml, 'displayname') + if (sender && this.looksLikeWxid(sender)) { + sender = '' + } + + const referContent = this.extractXmlValue(referMsgXml, 'content') + const referType = this.extractXmlValue(referMsgXml, 'type') + let displayContent = referContent + + switch (referType) { + case '1': + displayContent = this.sanitizeQuotedContent(referContent) + break + case '3': + displayContent = '[图片]' + break + case '34': + displayContent = '[语音]' + break + case '43': + displayContent = '[视频]' + break + case '47': + displayContent = '[动画表情]' + break + case '49': + displayContent = '[链接]' + break + case '42': + displayContent = '[名片]' + break + case '48': + displayContent = '[位置]' + break + default: + if (!referContent || referContent.includes('wxid_')) { + displayContent = '[消息]' + } else { + displayContent = this.sanitizeQuotedContent(referContent) + } + } + + return { + content: displayContent || undefined, + sender: sender || undefined, + type: referType || undefined + } + } catch { + return {} + } + } + private extractArkmeAppMessageMeta(content: string, localType: number): Record | null { if (!content) return null const normalized = this.normalizeAppMessageContent(content) - const looksLikeAppMsg = localType === 49 || normalized.includes('') - const xmlType = this.extractXmlValue(normalized, 'type') + const looksLikeAppMsg = + localType === 49 || + localType === 244813135921 || + normalized.includes('') + const hasReferMsg = normalized.includes('') + const xmlType = this.extractAppMessageType(normalized) const isFinder = xmlType === '51' || normalized.includes('') || normalized.includes('') - if (!looksLikeAppMsg && !isFinder) return null + if (!looksLikeAppMsg && !isFinder && !hasReferMsg) return null let appMsgKind: string | undefined if (isFinder) { @@ -1688,7 +1787,7 @@ class ExportService { appMsgKind = 'transfer' } else if (xmlType === '87') { appMsgKind = 'announcement' - } else if (xmlType === '57') { + } else if (xmlType === '57' || hasReferMsg || localType === 244813135921) { appMsgKind = 'quote' } else if (xmlType === '5' || xmlType === '49') { appMsgKind = 'link' @@ -1698,8 +1797,16 @@ class ExportService { const meta: Record = {} if (xmlType) meta.appMsgType = xmlType + else if (appMsgKind === 'quote') meta.appMsgType = '57' if (appMsgKind) meta.appMsgKind = appMsgKind + if (appMsgKind === 'quote') { + const quoteInfo = this.parseQuoteMessage(normalized) + if (quoteInfo.content) meta.quotedContent = quoteInfo.content + if (quoteInfo.sender) meta.quotedSender = quoteInfo.sender + if (quoteInfo.type) meta.quotedType = quoteInfo.type + } + if (isMusic) { const musicTitle = this.extractXmlValue(normalized, 'songname') || @@ -3797,11 +3904,16 @@ class ExportService { senderAvatarKey: msg.senderUsername } - if (options.format === 'arkme-json') { - const appMsgMeta = this.extractArkmeAppMessageMeta(msg.content, msg.localType) - if (appMsgMeta) { + const appMsgMeta = this.extractArkmeAppMessageMeta(msg.content, msg.localType) + if (appMsgMeta) { + if (options.format === 'arkme-json') { + Object.assign(msgObj, appMsgMeta) + } else if (options.format === 'json' && appMsgMeta.appMsgKind === 'quote') { Object.assign(msgObj, appMsgMeta) } + } + + if (options.format === 'arkme-json') { const contactCardMeta = this.extractArkmeContactCardMeta(msg.content, msg.localType) if (contactCardMeta) { Object.assign(msgObj, contactCardMeta) @@ -3988,6 +4100,9 @@ class ExportService { if (message.locationLabel) compactMessage.locationLabel = message.locationLabel if (message.appMsgType) compactMessage.appMsgType = message.appMsgType if (message.appMsgKind) compactMessage.appMsgKind = message.appMsgKind + if (message.quotedContent) compactMessage.quotedContent = message.quotedContent + if (message.quotedSender) compactMessage.quotedSender = message.quotedSender + if (message.quotedType) compactMessage.quotedType = message.quotedType if (message.finderTitle) compactMessage.finderTitle = message.finderTitle if (message.finderDesc) compactMessage.finderDesc = message.finderDesc if (message.finderUsername) compactMessage.finderUsername = message.finderUsername @@ -6381,9 +6496,12 @@ class ExportService { const baseName = sanitizeName(sessionInfo.displayName || sessionId) || sanitizeName(sessionId) || 'session' const suffix = sanitizeName(effectiveOptions.fileNameSuffix || '') const safeName = suffix ? `${baseName}_${suffix}` : baseName - const fileNameWithPrefix = `${await this.getSessionFilePrefix(sessionId)}${safeName}` + const sessionNameWithTypePrefix = effectiveOptions.sessionNameWithTypePrefix !== false + const sessionTypePrefix = sessionNameWithTypePrefix ? await this.getSessionFilePrefix(sessionId) : '' + const fileNameWithPrefix = `${sessionTypePrefix}${safeName}` const useSessionFolder = sessionLayout === 'per-session' - const sessionDir = useSessionFolder ? path.join(exportBaseDir, safeName) : exportBaseDir + const sessionDirName = sessionNameWithTypePrefix ? `${sessionTypePrefix}${safeName}` : safeName + const sessionDir = useSessionFolder ? path.join(exportBaseDir, sessionDirName) : exportBaseDir if (useSessionFolder && !fs.existsSync(sessionDir)) { fs.mkdirSync(sessionDir, { recursive: true }) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 69469cb..6c5ecd2 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -271,6 +271,68 @@ line-height: 1.45; } + .layout-prefix-toggle { + margin-top: 4px; + padding: 10px; + border-top: 1px solid color-mix(in srgb, var(--border-color) 85%, transparent); + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + } + + .layout-prefix-copy { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + } + + .layout-prefix-label { + font-size: 12px; + color: var(--text-primary); + font-weight: 600; + line-height: 1.35; + } + + .layout-prefix-desc { + font-size: 11px; + color: var(--text-secondary); + line-height: 1.4; + } + + .layout-prefix-switch { + width: 38px; + height: 22px; + border-radius: 999px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + cursor: pointer; + padding: 2px; + display: inline-flex; + align-items: center; + transition: background 0.15s ease, border-color 0.15s ease; + flex-shrink: 0; + + .layout-prefix-switch-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.22); + transition: transform 0.15s ease; + } + + &.on { + background: rgba(var(--primary-rgb), 0.9); + border-color: rgba(var(--primary-rgb), 0.95); + + .layout-prefix-switch-thumb { + transform: translateX(16px); + } + } + } + .task-center-control { display: flex; flex-direction: column; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 57e5ac7..75071d4 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -750,10 +750,14 @@ const normalizeMessageCount = (value: unknown): number | undefined => { const WriteLayoutSelector = memo(function WriteLayoutSelector({ writeLayout, - onChange + onChange, + sessionNameWithTypePrefix, + onSessionNameWithTypePrefixChange }: { writeLayout: configService.ExportWriteLayout onChange: (value: configService.ExportWriteLayout) => Promise + sessionNameWithTypePrefix: boolean + onSessionNameWithTypePrefixChange: (enabled: boolean) => Promise }) { const [isOpen, setIsOpen] = useState(false) const containerRef = useRef(null) @@ -797,6 +801,23 @@ const WriteLayoutSelector = memo(function WriteLayoutSelector({ {option.desc} ))} +
+
+ 聊天文本文件和会话文件夹带前缀 + 开启后使用群聊_、私聊_、公众号_、曾经的好友_前缀 +
+ +
) @@ -1082,6 +1103,7 @@ function ExportPage() { const [exportFolder, setExportFolder] = useState('') const [writeLayout, setWriteLayout] = useState('B') + const [sessionNameWithTypePrefix, setSessionNameWithTypePrefix] = useState(true) const [snsExportFormat, setSnsExportFormat] = useState('html') const [snsExportImages, setSnsExportImages] = useState(false) const [snsExportLivePhotos, setSnsExportLivePhotos] = useState(false) @@ -1456,7 +1478,7 @@ function ExportPage() { setIsBaseConfigLoading(true) let isReady = true try { - const [savedPath, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, exportCacheScope] = await Promise.all([ + const [savedPath, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, exportCacheScope] = await Promise.all([ configService.getExportPath(), configService.getExportDefaultMedia(), configService.getExportDefaultVoiceAsText(), @@ -1468,6 +1490,7 @@ function ExportPage() { configService.getExportSessionRecordMap(), configService.getExportLastSnsPostCount(), configService.getExportWriteLayout(), + configService.getExportSessionNamePrefixEnabled(), ensureExportCacheScope() ]) @@ -1481,6 +1504,7 @@ function ExportPage() { } setWriteLayout(savedWriteLayout) + setSessionNameWithTypePrefix(savedSessionNameWithTypePrefix) setLastExportBySession(savedSessionMap) setLastExportByContent(savedContentMap) setExportRecordsBySession(savedSessionRecordMap) @@ -2266,6 +2290,7 @@ function ExportPage() { displayNamePreference: options.displayNamePreference, exportConcurrency: options.exportConcurrency, sessionLayout, + sessionNameWithTypePrefix, dateRange: options.useAllTime ? null : options.dateRange @@ -3708,6 +3733,11 @@ function ExportPage() { setWriteLayout(value) await configService.setExportWriteLayout(value) }} + sessionNameWithTypePrefix={sessionNameWithTypePrefix} + onSessionNameWithTypePrefixChange={async (enabled) => { + setSessionNameWithTypePrefix(enabled) + await configService.setExportSessionNamePrefixEnabled(enabled) + }} />
diff --git a/src/services/config.ts b/src/services/config.ts index 40dee74..da80f97 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -33,6 +33,7 @@ export const CONFIG_KEYS = { EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns', EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency', EXPORT_WRITE_LAYOUT: 'exportWriteLayout', + EXPORT_SESSION_NAME_PREFIX_ENABLED: 'exportSessionNamePrefixEnabled', EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap', EXPORT_LAST_CONTENT_RUN_MAP: 'exportLastContentRunMap', EXPORT_SESSION_RECORD_MAP: 'exportSessionRecordMap', @@ -410,6 +411,16 @@ export async function setExportWriteLayout(layout: ExportWriteLayout): Promise { + const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_NAME_PREFIX_ENABLED) + if (typeof value === 'boolean') return value + return true +} + +export async function setExportSessionNamePrefixEnabled(enabled: boolean): Promise { + await config.set(CONFIG_KEYS.EXPORT_SESSION_NAME_PREFIX_ENABLED, enabled) +} + export async function getExportLastSessionRunMap(): Promise> { const value = await config.get(CONFIG_KEYS.EXPORT_LAST_SESSION_RUN_MAP) if (!value || typeof value !== 'object') return {} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index f5895e3..713e21c 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -801,6 +801,7 @@ export interface ExportOptions { excelCompactColumns?: boolean txtColumns?: string[] sessionLayout?: 'shared' | 'per-session' + sessionNameWithTypePrefix?: boolean displayNamePreference?: 'group-nickname' | 'remark' | 'nickname' exportConcurrency?: number }