diff --git a/.github/workflows/dev-daily-fixed.yml b/.github/workflows/dev-daily-fixed.yml index 03bdc42..fb63aff 100644 --- a/.github/workflows/dev-daily-fixed.yml +++ b/.github/workflows/dev-daily-fixed.yml @@ -55,6 +55,8 @@ jobs: gh release delete "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag fi gh release create "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --title "Daily Dev Build" --notes "开发版发布页" --prerelease --target "$TARGET_BRANCH" + RELEASE_REST_ID="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_DEV_TAG" --jq '.id')" + gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -f draft=false -f prerelease=true >/dev/null dev-mac-arm64: needs: prepare @@ -327,4 +329,9 @@ jobs: - 如某个平台资源暂未生成,请进入[发布页]($RELEASE_PAGE)查看最新状态 EOF - gh release edit "$TAG" --repo "$REPO" --title "Daily Dev Build" --notes-file dev_release_notes.md + RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')" + jq -n --rawfile body dev_release_notes.md \ + '{name:"Daily Dev Build", body:$body, draft:false, prerelease:true}' \ + > release_update_payload.json + gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" --input release_update_payload.json >/dev/null + gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,url diff --git a/.github/workflows/preview-nightly-main.yml b/.github/workflows/preview-nightly-main.yml index 186c7c0..751d227 100644 --- a/.github/workflows/preview-nightly-main.yml +++ b/.github/workflows/preview-nightly-main.yml @@ -81,6 +81,8 @@ jobs: gh release delete "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag fi gh release create "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --title "Preview Nightly Build" --notes "预览版发布页" --prerelease --target "$TARGET_BRANCH" + RELEASE_REST_ID="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_PREVIEW_TAG" --jq '.id')" + gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -f draft=false -f prerelease=true >/dev/null preview-mac-arm64: needs: prepare @@ -369,4 +371,9 @@ jobs: > 如某个平台链接暂未生成,请前往[发布页]($RELEASE_PAGE)查看最新资源 EOF - gh release edit "$TAG" --repo "$REPO" --notes-file preview_release_notes.md + RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')" + jq -n --rawfile body preview_release_notes.md \ + '{name:"Preview Nightly Build", body:$body, draft:false, prerelease:true}' \ + > release_update_payload.json + gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" --input release_update_payload.json >/dev/null + gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,url diff --git a/.gitignore b/.gitignore index 99f4414..ae6f9bf 100644 --- a/.gitignore +++ b/.gitignore @@ -72,4 +72,5 @@ pnpm-lock.yaml /pnpm-workspace.yaml wechat-research-site .codex -weflow-web-offical \ No newline at end of file +weflow-web-offical +Insight \ No newline at end of file diff --git a/electron/exportWorker.ts b/electron/exportWorker.ts index 1f98439..dfa4ba3 100644 --- a/electron/exportWorker.ts +++ b/electron/exportWorker.ts @@ -5,6 +5,9 @@ interface ExportWorkerConfig { sessionIds: string[] outputDir: string options: ExportOptions + dbPath?: string + decryptKey?: string + myWxid?: string resourcesPath?: string userDataPath?: string logEnabled?: boolean @@ -29,6 +32,11 @@ async function run() { wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '') wcdbService.setLogEnabled(config.logEnabled === true) + exportService.setRuntimeConfig({ + dbPath: config.dbPath, + decryptKey: config.decryptKey, + myWxid: config.myWxid + }) const result = await exportService.exportSessions( Array.isArray(config.sessionIds) ? config.sessionIds : [], diff --git a/electron/main.ts b/electron/main.ts index ce578dc..6b692b4 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -2362,6 +2362,9 @@ function registerIpcHandlers() { const cfg = configService || new ConfigService() configService = cfg const logEnabled = cfg.get('logEnabled') + const dbPath = String(cfg.get('dbPath') || '').trim() + const decryptKey = String(cfg.get('decryptKey') || '').trim() + const myWxid = String(cfg.get('myWxid') || '').trim() const resourcesPath = app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources') @@ -2375,6 +2378,9 @@ function registerIpcHandlers() { sessionIds, outputDir, options, + dbPath, + decryptKey, + myWxid, resourcesPath, userDataPath, logEnabled diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index bfeaf4a..270b4dc 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -4486,15 +4486,16 @@ class ChatService { */ private parseQuoteMessage(content: string): { content?: string; sender?: string } { try { + const normalizedContent = this.decodeHtmlEntities(content || '') // 提取 refermsg 部分 - const referMsgStart = content.indexOf('') - const referMsgEnd = content.indexOf('') + const referMsgStart = normalizedContent.indexOf('') + const referMsgEnd = normalizedContent.indexOf('') if (referMsgStart === -1 || referMsgEnd === -1) { return {} } - const referMsgXml = content.substring(referMsgStart, referMsgEnd + 11) + const referMsgXml = normalizedContent.substring(referMsgStart, referMsgEnd + 11) // 提取发送者名称 let displayName = this.extractXmlValue(referMsgXml, 'displayname') @@ -4511,8 +4512,8 @@ class ChatService { let displayContent = referContent switch (referType) { case '1': - // 文本消息,清理可能的 wxid - displayContent = this.sanitizeQuotedContent(referContent) + // 文本消息优先取“部分引用”字段,缺失时再回退到完整 content + displayContent = this.extractPreferredQuotedText(referMsgXml) break case '3': displayContent = '[图片]' @@ -4552,6 +4553,76 @@ class ChatService { } } + private extractPreferredQuotedText(referMsgXml: string): string { + if (!referMsgXml) return '' + + const sources = [this.decodeHtmlEntities(referMsgXml)] + const rawMsgSource = this.extractXmlValue(referMsgXml, 'msgsource') + if (rawMsgSource) { + const decodedMsgSource = this.decodeHtmlEntities(rawMsgSource) + if (decodedMsgSource) { + sources.push(decodedMsgSource) + } + } + + const fullContent = this.sanitizeQuotedContent(this.extractXmlValue(sources[0] || referMsgXml, 'content')) + const partialText = this.extractPartialQuotedText(sources[0] || referMsgXml, fullContent) + if (partialText) return partialText + + const candidateTags = [ + 'selectedcontent', + 'selectedtext', + 'selectcontent', + 'selecttext', + 'quotecontent', + 'quotetext', + 'partcontent', + 'parttext', + 'excerpt', + 'summary', + 'preview' + ] + + for (const source of sources) { + for (const tag of candidateTags) { + const value = this.sanitizeQuotedContent(this.extractXmlValue(source, tag)) + if (value) return value + } + } + + return fullContent + } + + private extractPartialQuotedText(xml: string, fullContent: string): string { + if (!xml || !fullContent) return '' + + const startChar = this.extractXmlValue(xml, 'start') + const endChar = this.extractXmlValue(xml, 'end') + const startIndexRaw = this.extractXmlValue(xml, 'startindex') + const endIndexRaw = this.extractXmlValue(xml, 'endindex') + const startIndex = Number.parseInt(startIndexRaw, 10) + const endIndex = Number.parseInt(endIndexRaw, 10) + + if (startChar && endChar) { + const startPos = fullContent.indexOf(startChar) + if (startPos !== -1) { + const endPos = fullContent.indexOf(endChar, startPos + startChar.length - 1) + if (endPos !== -1 && endPos >= startPos) { + const sliced = fullContent.slice(startPos, endPos + endChar.length).trim() + if (sliced) return sliced + } + } + } + + if (Number.isFinite(startIndex) && Number.isFinite(endIndex) && endIndex >= startIndex) { + const chars = Array.from(fullContent) + const sliced = chars.slice(startIndex, endIndex + 1).join('').trim() + if (sliced) return sliced + } + + return '' + } + /** * 解析名片消息 * 格式: diff --git a/electron/services/config.ts b/electron/services/config.ts index 0c81f59..85da9fc 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -5,6 +5,13 @@ import Store from 'electron-store' // 加密前缀标记 const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式) +const isSafeStorageAvailable = (): boolean => { + try { + return typeof safeStorage?.isEncryptionAvailable === 'function' && safeStorage.isEncryptionAvailable() + } catch { + return false + } +} const LOCK_PREFIX = 'lock:' // 密码派生密钥加密(锁定模式) interface ConfigSchema { @@ -257,7 +264,7 @@ export class ConfigService { private safeEncrypt(plaintext: string): string { if (!plaintext) return '' if (plaintext.startsWith(SAFE_PREFIX)) return plaintext - if (!safeStorage.isEncryptionAvailable()) return plaintext + if (!isSafeStorageAvailable()) return plaintext const encrypted = safeStorage.encryptString(plaintext) return SAFE_PREFIX + encrypted.toString('base64') } @@ -265,7 +272,7 @@ export class ConfigService { private safeDecrypt(stored: string): string { if (!stored) return '' if (!stored.startsWith(SAFE_PREFIX)) return stored - if (!safeStorage.isEncryptionAvailable()) return '' + if (!isSafeStorageAvailable()) return '' try { const buf = Buffer.from(stored.slice(SAFE_PREFIX.length), 'base64') return safeStorage.decryptString(buf) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 95800b6..6363e98 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -254,6 +254,7 @@ async function parallelLimit( class ExportService { private configService: ConfigService + private runtimeConfig: { dbPath?: string; decryptKey?: string; myWxid?: string } | null = null private contactCache: LRUCache private inlineEmojiCache: LRUCache private htmlStyleCache: string | null = null @@ -295,6 +296,10 @@ class ExportService { return error } + setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string } | null): void { + this.runtimeConfig = config + } + private normalizeSessionIds(sessionIds: string[]): string[] { return Array.from( new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)) @@ -1316,9 +1321,9 @@ class ExportService { } private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> { - const wxid = this.configService.get('myWxid') - const dbPath = this.configService.get('dbPath') - const decryptKey = this.configService.get('decryptKey') + const wxid = String(this.runtimeConfig?.myWxid || this.configService.get('myWxid') || '').trim() + const dbPath = String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim() + const decryptKey = String(this.runtimeConfig?.decryptKey || this.configService.get('decryptKey') || '').trim() if (!wxid) return { success: false, error: '请先在设置页面配置微信ID' } if (!dbPath) return { success: false, error: '请先在设置页面配置数据库路径' } if (!decryptKey) return { success: false, error: '请先在设置页面配置解密密钥' } @@ -2254,7 +2259,7 @@ class ExportService { const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11) const quoteInfo = this.parseQuoteMessage(normalized) const replyText = this.stripSenderPrefix(this.extractXmlValue(normalized, 'title') || '') - const quotedPreview = this.formatQuotedReferencePreview( + const quotedPreview = quoteInfo.content || this.formatQuotedReferencePreview( this.extractXmlValue(referMsgXml, 'content'), this.extractXmlValue(referMsgXml, 'type') ) @@ -2960,7 +2965,7 @@ class ExportService { switch (referType) { case '1': - displayContent = this.sanitizeQuotedContent(referContent) + displayContent = this.extractPreferredQuotedText(referMsgXml) break case '3': displayContent = '[图片]' @@ -3001,6 +3006,76 @@ class ExportService { } } + private extractPreferredQuotedText(referMsgXml: string): string { + if (!referMsgXml) return '' + + const sources = [this.decodeHtmlEntities(referMsgXml)] + const rawMsgSource = this.extractXmlValue(referMsgXml, 'msgsource') + if (rawMsgSource) { + const decodedMsgSource = this.decodeHtmlEntities(rawMsgSource) + if (decodedMsgSource) { + sources.push(decodedMsgSource) + } + } + + const fullContent = this.sanitizeQuotedContent(this.extractXmlValue(sources[0] || referMsgXml, 'content')) + const partialText = this.extractPartialQuotedText(sources[0] || referMsgXml, fullContent) + if (partialText) return partialText + + const candidateTags = [ + 'selectedcontent', + 'selectedtext', + 'selectcontent', + 'selecttext', + 'quotecontent', + 'quotetext', + 'partcontent', + 'parttext', + 'excerpt', + 'summary', + 'preview' + ] + + for (const source of sources) { + for (const tag of candidateTags) { + const value = this.sanitizeQuotedContent(this.extractXmlValue(source, tag)) + if (value) return value + } + } + + return fullContent + } + + private extractPartialQuotedText(xml: string, fullContent: string): string { + if (!xml || !fullContent) return '' + + const startChar = this.extractXmlValue(xml, 'start') + const endChar = this.extractXmlValue(xml, 'end') + const startIndexRaw = this.extractXmlValue(xml, 'startindex') + const endIndexRaw = this.extractXmlValue(xml, 'endindex') + const startIndex = Number.parseInt(startIndexRaw, 10) + const endIndex = Number.parseInt(endIndexRaw, 10) + + if (startChar && endChar) { + const startPos = fullContent.indexOf(startChar) + if (startPos !== -1) { + const endPos = fullContent.indexOf(endChar, startPos + startChar.length - 1) + if (endPos !== -1 && endPos >= startPos) { + const sliced = fullContent.slice(startPos, endPos + endChar.length).trim() + if (sliced) return sliced + } + } + } + + if (Number.isFinite(startIndex) && Number.isFinite(endIndex) && endIndex >= startIndex) { + const chars = Array.from(fullContent) + const sliced = chars.slice(startIndex, endIndex + 1).join('').trim() + if (sliced) return sliced + } + + return '' + } + private extractChatLabReplyToMessageId(content: string): string | undefined { try { const normalized = this.normalizeAppMessageContent(content || '') diff --git a/package.json b/package.json index 28736e3..5361a0e 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,9 @@ "lodash": ">=4.17.21", "brace-expansion": ">=1.1.11", "picomatch": ">=2.3.1", - "ajv": ">=8.18.0" + "ajv": ">=8.18.0", + "ajv-keywords@3>ajv": "^6.12.6", + "@develar/schema-utils>ajv": "^6.12.6" } }, "build": { diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 36b7cc1..5e86cc5 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -8695,6 +8695,28 @@ function MessageBubble({ appMsgTextCache.set(selector, value) return value }, [appMsgDoc, appMsgTextCache]) + const queryPreferredQuotedContent = useCallback((): string => { + if (message.quotedContent) return message.quotedContent + const candidates = [ + 'refermsg > selectedcontent', + 'refermsg > selectedtext', + 'refermsg > selectcontent', + 'refermsg > selecttext', + 'refermsg > quotecontent', + 'refermsg > quotetext', + 'refermsg > partcontent', + 'refermsg > parttext', + 'refermsg > excerpt', + 'refermsg > summary', + 'refermsg > preview', + 'refermsg > content' + ] + for (const selector of candidates) { + const value = queryAppMsgText(selector) + if (value) return value + } + return '' + }, [message.quotedContent, queryAppMsgText]) const appMsgThumbRawCandidate = useMemo(() => ( message.linkThumb || message.appMsgThumbUrl || @@ -8712,7 +8734,7 @@ function MessageBubble({ queryAppMsgText('refermsg > fromusr'), queryAppMsgText('refermsg > chatusr') ) - const quotedContent = message.quotedContent || queryAppMsgText('refermsg > content') || '' + const quotedContent = queryPreferredQuotedContent() const quotedSenderFallbackName = useMemo( () => resolveQuotedSenderFallbackDisplayName( session.username, @@ -9262,7 +9284,7 @@ function MessageBubble({ // type 57: 引用回复消息,解析 refermsg 渲染为引用样式 if (xmlType === '57') { const replyText = q('title') || cleanedParsedContent || '' - const referContent = q('refermsg > content') || '' + const referContent = queryPreferredQuotedContent() const referType = q('refermsg > type') || '' // 根据被引用消息类型渲染对应内容 @@ -9385,7 +9407,7 @@ function MessageBubble({ if (kind === 'quote') { // 引用回复消息(appMsgKind='quote',xmlType=57) const replyText = message.linkTitle || q('title') || cleanedParsedContent || '' - const referContent = message.quotedContent || q('refermsg > content') || '' + const referContent = queryPreferredQuotedContent() return ( renderBubbleWithQuote( renderQuotedMessageBlock(renderTextWithEmoji(cleanMessageContent(referContent))), @@ -9576,7 +9598,7 @@ function MessageBubble({ // 引用回复消息 (type=57),防止被误判为链接 if (appMsgType === '57') { const replyText = parsedDoc?.querySelector('title')?.textContent?.trim() || cleanedParsedContent || '' - const referContent = parsedDoc?.querySelector('refermsg > content')?.textContent?.trim() || '' + const referContent = queryPreferredQuotedContent() const referType = parsedDoc?.querySelector('refermsg > type')?.textContent?.trim() || '' const renderReferContent2 = () => {