From e21583dfb84c145397620b962fff65217f1ca5cc Mon Sep 17 00:00:00 2001 From: tianma-if Date: Wed, 23 Sep 2026 18:21:35 +0800 Subject: [PATCH] fix: retain WeChat share archives until memo save --- apps/desktop/src/main/index.mjs | 5 +- apps/desktop/src/main/wechat-chat-archive.mjs | 14 ++-- .../src/main/wechat-chat-archive.test.mjs | 20 +++++ apps/desktop/src/main/wechat-share-import.mjs | 68 ++++++++++------- .../src/main/wechat-share-import.test.mjs | 75 ++++++++++++++++++- apps/desktop/src/preload/index.cjs | 3 +- apps/web/src/components/WorkspaceApp.tsx | 52 ++++++++++--- apps/web/src/vite-env.d.ts | 3 +- packages/shared/src/i18n/en-US.ts | 3 + packages/shared/src/i18n/ja.ts | 3 + packages/shared/src/i18n/zh-CN.ts | 3 + 11 files changed, 199 insertions(+), 50 deletions(-) diff --git a/apps/desktop/src/main/index.mjs b/apps/desktop/src/main/index.mjs index 2ffa8e42..c9c1fdbd 100644 --- a/apps/desktop/src/main/index.mjs +++ b/apps/desktop/src/main/index.mjs @@ -1801,9 +1801,10 @@ const startApplication = async () => { ipcMain.handle("desktop:read-wechat-import-media", async (_event, importId, mediaId) => ( wechatShare().readMedia(importId, mediaId) )); - ipcMain.handle("desktop:finish-wechat-import", async (_event, importId) => { - await wechatShare().finish(importId); + ipcMain.handle("desktop:finish-wechat-import", async (_event, importId, success) => { + await wechatShare().finish(importId, success === true); }); + ipcMain.handle("desktop:retry-wechat-import", (_event, importId) => wechatShare().retry(importId)); ipcMain.handle("desktop:remove-staged-resource", async (_event, id) => { if (!isSafeResourceId(id)) throw new Error("Invalid staged resource id"); const directory = stagedResourceDirectory(); diff --git a/apps/desktop/src/main/wechat-chat-archive.mjs b/apps/desktop/src/main/wechat-chat-archive.mjs index 9af7c5d4..4192a2c4 100644 --- a/apps/desktop/src/main/wechat-chat-archive.mjs +++ b/apps/desktop/src/main/wechat-chat-archive.mjs @@ -159,13 +159,16 @@ const isMessageHeader = (lines, index) => const parseMessages = (text) => { const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/); - const messages = []; + const sections = []; let index = 0; while (index < lines.length) { while (index < lines.length && lines[index].trim() === "") index += 1; if (index >= lines.length) break; if (!isMessageHeader(lines, index)) { - index += 1; + const start = index; + while (index < lines.length && !isMessageHeader(lines, index)) index += 1; + const raw = lines.slice(start, index).join("\n").trim(); + if (raw) sections.push({ raw: raw.split("\n") }); continue; } const sender = lines[index].slice(1).trim(); @@ -185,9 +188,9 @@ const parseMessages = (text) => { body.push(lines[index]); index += 1; } - messages.push({ sender, time, body }); + sections.push({ sender, time, body }); } - return messages; + return sections; }; const parseLink = (line) => { @@ -234,7 +237,7 @@ const titleTimestamp = (time) => { const archiveTitle = (archiveName, messages) => { const base = posix.basename(archiveName || "").replace(/\.(?:zip|txt)$/i, "").trim(); if (!base || base === "聊天记录") { - const timestamp = titleTimestamp(messages[0]?.time); + const timestamp = titleTimestamp(messages.find((message) => message.time)?.time); if (timestamp) return `聊天记录 · ${timestamp}`; } return base || "聊天记录"; @@ -283,6 +286,7 @@ export const wechatChatNoteFromArchive = (bytes, archiveName = TRANSCRIPT_NAME) const messages = parseMessages(text); const sections = messages.length > 0 ? messages.map((message) => { + if (message.raw) return renderBody(message.raw, mediaByName); const body = renderBody(message.body, mediaByName); const header = `**${escapeMarkdownText(message.sender)}** · ${escapeMarkdownText(message.time)}`; return body ? `${header}\n\n${body}` : header; diff --git a/apps/desktop/src/main/wechat-chat-archive.test.mjs b/apps/desktop/src/main/wechat-chat-archive.test.mjs index f0fda3f5..5990b1e2 100644 --- a/apps/desktop/src/main/wechat-chat-archive.test.mjs +++ b/apps/desktop/src/main/wechat-chat-archive.test.mjs @@ -116,6 +116,26 @@ describe("WeChat chat archive", () => { expect(wechatChatNoteFromArchive(withoutMessages, "聊天记录.zip").title).toBe("聊天记录"); }); + test("preserves transcript text that does not match a message header", () => { + const archive = zipOf([{ name: "聊天记录.txt", data: [ + "聊天开始前的说明", + "·鱼", + "2026年9月22日 22:15", + "你好", + "", + "[系统消息] 群名已更改", + "", + "·鱼", + "2026年9月22日 22:16", + "收到", + ].join("\n") }]); + const note = wechatChatNoteFromArchive(archive, "聊天记录.zip"); + expect(note.title).toBe("聊天记录 · 2026-09-22 22:15"); + expect(note.markdown).toContain("聊天开始前的说明"); + expect(note.markdown).toContain("\\[系统消息\\] 群名已更改"); + expect(note.markdown).toContain("收到"); + }); + test("rejects archives that are not a WeChat transcript", () => { expect(() => wechatChatNoteFromArchive(zipOf([{ name: "readme.txt", data: "hello" }]))).toThrow(WeChatArchiveError); expect(() => wechatChatNoteFromArchive(Buffer.from("not a zip"))).toThrow(WeChatArchiveError); diff --git a/apps/desktop/src/main/wechat-share-import.mjs b/apps/desktop/src/main/wechat-share-import.mjs index 40ceeafb..1b62d010 100644 --- a/apps/desktop/src/main/wechat-share-import.mjs +++ b/apps/desktop/src/main/wechat-share-import.mjs @@ -1,10 +1,9 @@ import { randomUUID } from "node:crypto"; -import { mkdir, readFile, realpath, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { mkdir, readFile, realpath, readdir, rmdir, rm, writeFile } from "node:fs/promises"; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { WeChatArchiveError, wechatChatNoteFromArchive } from "./wechat-chat-archive.mjs"; export const WECHAT_INCOMING_DIRECTORY_NAME = "EdgeEver Incoming"; -const STALE_INCOMING_MS = 24 * 60 * 60 * 1000; const SAFE_ID = /^[A-Za-z0-9_-]{1,80}$/; export const isPathInsideDirectory = (filePath, directory) => { @@ -46,23 +45,10 @@ const removeImportedZip = async (zipPath, downloadsPath) => { const parent = dirname(zipPath); const realParent = await realpath(parent).catch(() => null); if (root && realParent && isPathInsideDirectory(realParent, root)) { - await rm(realParent, { recursive: true, force: true }).catch(() => {}); + await rm(zipPath, { force: true }).catch(() => {}); + await rmdir(realParent).catch(() => {}); return; } - await rm(zipPath, { force: true }).catch(() => {}); -}; - -const removeStaleIncoming = async (downloadsPath, keepPath) => { - const root = incomingDirectory(downloadsPath); - const entries = await readdir(root, { withFileTypes: true }).catch(() => []); - const cutoff = Date.now() - STALE_INCOMING_MS; - await Promise.all(entries.map(async (entry) => { - const path = join(root, entry.name); - if (keepPath && (path === keepPath || keepPath.startsWith(`${path}${sep}`))) return; - const info = await stat(path).catch(() => null); - if (!info || info.mtimeMs > cutoff) return; - await rm(path, { recursive: true, force: true }).catch(() => {}); - })); }; export const createWeChatShareController = ({ @@ -73,15 +59,38 @@ export const createWeChatShareController = ({ writeDiagnostic = () => {}, }) => { const sessions = new Map(); + const preparedPaths = new Set(); const inFlightPaths = new Set(); const completedPaths = new Set(); + const failedPaths = new Set(); - const finish = async (importId) => { + const finish = async (importId, success) => { if (!SAFE_ID.test(importId || "")) return; const session = sessions.get(importId); - sessions.delete(importId); if (!session) return; + if (!success) { + session.failed = true; + failedPaths.add(session.zipPath); + void writeDiagnostic("wechat-import.save-failed"); + return; + } + sessions.delete(importId); + preparedPaths.delete(session.zipPath); + completedPaths.add(session.zipPath); + failedPaths.delete(session.zipPath); + await removeImportedZip(session.zipPath, downloadsPath()); await rm(session.directory, { recursive: true, force: true }).catch(() => {}); + void writeDiagnostic("wechat-import.saved", { media: session.media.size }); + }; + + const retry = (importId) => { + if (!SAFE_ID.test(importId || "")) return false; + const session = sessions.get(importId); + if (!session?.failed) return false; + session.failed = false; + failedPaths.delete(session.zipPath); + sendToRenderer(session.payload); + return true; }; const readMedia = async (importId, mediaId) => { @@ -98,14 +107,14 @@ export const createWeChatShareController = ({ const importFromProtocolUrl = async (value) => { const requestedPath = wechatImportFilePath(value); - if (!requestedPath || inFlightPaths.has(requestedPath) || completedPaths.has(requestedPath)) return; + if (!requestedPath || inFlightPaths.has(requestedPath) || preparedPaths.has(requestedPath) + || completedPaths.has(requestedPath) || failedPaths.has(requestedPath)) return; inFlightPaths.add(requestedPath); onActivity(); const downloads = downloadsPath(); let zipPath = null; let preparedDirectory = null; try { - await removeStaleIncoming(downloads, requestedPath); zipPath = await assertIncomingZip(requestedPath, downloads); const archive = await readFile(zipPath); const note = wechatChatNoteFromArchive(archive, basename(zipPath)); @@ -131,24 +140,25 @@ export const createWeChatShareController = ({ byteSize: item.bytes.length, }); } - sessions.set(importId, { directory, media }); - preparedDirectory = null; - completedPaths.add(requestedPath); - sendToRenderer({ + const payload = { ok: true, importId, title: note.title, markdown: note.markdown, media: listed, - }); - void writeDiagnostic("wechat-import.accepted", { media: listed.length, bytes: archive.length }); + }; + sessions.set(importId, { directory, media, zipPath, payload, failed: false }); + preparedPaths.add(requestedPath); + preparedDirectory = null; + sendToRenderer(payload); + void writeDiagnostic("wechat-import.prepared", { media: listed.length, bytes: archive.length }); } catch (error) { if (preparedDirectory) await rm(preparedDirectory, { recursive: true, force: true }).catch(() => {}); const reason = error instanceof WeChatArchiveError ? error.code : "failed"; + failedPaths.add(requestedPath); sendToRenderer({ ok: false, reason: reason === "unrecognized" ? "unrecognized" : "failed" }); void writeDiagnostic("wechat-import.rejected", { reason }); } finally { - await removeImportedZip(zipPath, downloads); inFlightPaths.delete(requestedPath); } }; @@ -169,5 +179,5 @@ export const createWeChatShareController = ({ } }; - return { importFromProtocolUrl, importPending, readMedia, finish }; + return { importFromProtocolUrl, importPending, readMedia, finish, retry }; }; diff --git a/apps/desktop/src/main/wechat-share-import.test.mjs b/apps/desktop/src/main/wechat-share-import.test.mjs index 77a5fa35..f6e8dedd 100644 --- a/apps/desktop/src/main/wechat-share-import.test.mjs +++ b/apps/desktop/src/main/wechat-share-import.test.mjs @@ -1,5 +1,5 @@ import { deflateRawSync } from "node:zlib"; -import { mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rename, rm, stat, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, test } from "bun:test"; @@ -110,9 +110,10 @@ describe("WeChat share handoff", () => { expect(sent[0].markdown).toContain("![微信图片_1.jpg](edgeever-wechat-media://m1)"); const media = await controller.readMedia(sent[0].importId, "m1"); expect(Buffer.from(media.bytes).toString()).toBe("jpeg"); + expect((await stat(zipPath)).isFile()).toBe(true); + await controller.finish(sent[0].importId, true); await expect(stat(zipPath)).rejects.toThrow(); await expect(stat(batch)).rejects.toThrow(); - await controller.finish(sent[0].importId); await expect(readFile(join(temp, "edgeever-wechat-import", sent[0].importId, "m1.bin"))).rejects.toThrow(); }); @@ -154,7 +155,77 @@ describe("WeChat share handoff", () => { await Promise.all([controller.importPending(), controller.importFromProtocolUrl(url)]); expect(sent).toHaveLength(1); expect(sent[0].ok).toBe(true); + expect((await stat(zipPath)).isFile()).toBe(true); await controller.importFromProtocolUrl(url); expect(sent).toHaveLength(1); + await controller.finish(sent[0].importId, false); + await controller.importPending(); + expect(sent).toHaveLength(1); + expect(controller.retry(sent[0].importId)).toBe(true); + expect(sent).toHaveLength(2); + await controller.finish(sent[0].importId, true); + await expect(stat(zipPath)).rejects.toThrow(); + }); + + test("keeps multiple old pending shares available across app restarts", async () => { + const root = await mkdtemp(join(tmpdir(), "edgeever-wechat-share-")); + roots.push(root); + const downloads = join(root, "Downloads"); + const incoming = join(downloads, WECHAT_INCOMING_DIRECTORY_NAME); + const old = new Date(Date.now() - 48 * 60 * 60 * 1000); + for (const name of ["first", "second"]) { + const batch = join(incoming, name); + await mkdir(batch, { recursive: true }); + const zipPath = join(batch, "聊天记录.zip"); + await writeFile(zipPath, zipOf("聊天记录.txt", `·鱼\n2026年9月22日 22:15\n${name}\n`, "附件/readme.txt", "hello")); + await utimes(batch, old, old); + } + const prepared = []; + const controller = createWeChatShareController({ + downloadsPath: () => downloads, + tempPath: () => join(root, "temp"), + sendToRenderer: (payload) => prepared.push(payload), + }); + await controller.importPending(); + expect(prepared).toHaveLength(2); + await controller.finish(prepared[0].importId, false); + const resumed = []; + const restarted = createWeChatShareController({ + downloadsPath: () => downloads, + tempPath: () => join(root, "new-temp"), + sendToRenderer: (payload) => resumed.push(payload), + }); + await restarted.importPending(); + expect(resumed).toHaveLength(2); + for (const name of ["first", "second"]) { + expect((await stat(join(incoming, name, "聊天记录.zip"))).isFile()).toBe(true); + } + }); + + test("finishing one zip keeps another zip in the same incoming batch", async () => { + const root = await mkdtemp(join(tmpdir(), "edgeever-wechat-share-")); + roots.push(root); + const downloads = join(root, "Downloads"); + const batch = join(downloads, WECHAT_INCOMING_DIRECTORY_NAME, "batch"); + await mkdir(batch, { recursive: true }); + for (const name of ["first", "second"]) { + await writeFile(join(batch, `${name}.zip`), zipOf("聊天记录.txt", `·鱼\n2026年9月22日 22:15\n${name}\n`, "附件/readme.txt", "hello")); + } + const sent = []; + const controller = createWeChatShareController({ + downloadsPath: () => downloads, + tempPath: () => join(root, "temp"), + sendToRenderer: (payload) => sent.push(payload), + }); + await controller.importPending(); + expect(sent).toHaveLength(2); + const first = sent.find((payload) => payload.title === "first"); + const second = sent.find((payload) => payload.title === "second"); + expect(first).toBeDefined(); + expect(second).toBeDefined(); + await controller.finish(first.importId, true); + expect((await stat(join(batch, "second.zip"))).isFile()).toBe(true); + await controller.finish(second.importId, true); + await expect(stat(batch)).rejects.toThrow(); }); }); diff --git a/apps/desktop/src/preload/index.cjs b/apps/desktop/src/preload/index.cjs index 49e2f1fe..9678911f 100644 --- a/apps/desktop/src/preload/index.cjs +++ b/apps/desktop/src/preload/index.cjs @@ -108,7 +108,8 @@ contextBridge.exposeInMainWorld("edgeeverDesktop", Object.freeze({ mimeType: file.mimeType, bytes: normalizeIpcBytes(file.bytes), })), - finishWeChatImport: (importId) => ipcRenderer.invoke("desktop:finish-wechat-import", importId), + finishWeChatImport: (importId, success) => ipcRenderer.invoke("desktop:finish-wechat-import", importId, success), + retryWeChatImport: (importId) => ipcRenderer.invoke("desktop:retry-wechat-import", importId), onImportWeChatChat: (callback) => { const listener = (_event, payload) => callback(payload); ipcRenderer.on("desktop:import-wechat-chat", listener); diff --git a/apps/web/src/components/WorkspaceApp.tsx b/apps/web/src/components/WorkspaceApp.tsx index 2de30379..859cc852 100644 --- a/apps/web/src/components/WorkspaceApp.tsx +++ b/apps/web/src/components/WorkspaceApp.tsx @@ -262,6 +262,8 @@ export const WorkspaceApp = ({ const [notebookNameDialog, setNotebookNameDialog] = useState(null); const [notebookDeleteConfirmation, setNotebookDeleteConfirmation] = useState(null); const [appNoticeDialog, setAppNoticeDialog] = useState(null); + const [wechatImportFailureId, setWeChatImportFailureId] = useState(null); + const [wechatImportsInProgress, setWeChatImportsInProgress] = useState(0); const [pendingCatalogTrustPluginIds, setPendingCatalogTrustPluginIds] = useState([]); const [isExportingSelectedMemos, setIsExportingSelectedMemos] = useState(false); const [selectedMarkdownExportProgress, setSelectedMarkdownExportProgress] = useState({ @@ -1784,11 +1786,7 @@ export const WorkspaceApp = ({ media?: Array<{ id: string; filename: string; mimeType: string; byteSize: number }>; }) => { const bridge = window.edgeeverDesktop; - const finish = () => { - if (payload.importId && bridge?.finishWeChatImport) void bridge.finishWeChatImport(payload.importId); - }; if (!payload.ok || !payload.importId || !payload.markdown) { - finish(); setAppNoticeDialog({ title: t("memoList.importWeChatFailedTitle"), description: payload.reason === "unrecognized" @@ -1809,6 +1807,8 @@ export const WorkspaceApp = ({ setTemplatesOpen(false); setMobileBottomNavActive("home"); creatingMemoSelectionRef.current = true; + setWeChatImportsInProgress((count) => count + 1); + let savedMemo: MemoDetail | null = null; try { const memo = await createWeChatChatMemo({ notebookId, @@ -1856,16 +1856,18 @@ export const WorkspaceApp = ({ }), deleteMemo: (memoId) => repository.deleteMemo(memoId, true), }); - await putLocalMemo(localDataScope, memo); + savedMemo = memo; + await bridge?.finishWeChatImport?.(importId, true).catch(() => undefined); + await putLocalMemo(localDataScope, memo).catch(() => undefined); revealCreatedMemo(memo); } catch { + if (!savedMemo) { + await bridge?.finishWeChatImport?.(importId, false).catch(() => undefined); + setWeChatImportFailureId(importId); + } creatingMemoSelectionRef.current = false; - setAppNoticeDialog({ - title: t("memoList.importWeChatFailedTitle"), - description: t("memoList.importWeChatFailed"), - }); } finally { - finish(); + setWeChatImportsInProgress((count) => Math.max(0, count - 1)); } }, [defaultMemoNotebookId, imageCompressionEnabled, localDataScope, memoView, notebooks, repository, selectedNotebookId, t]); @@ -3003,6 +3005,13 @@ export const WorkspaceApp = ({ return (
+ {wechatImportsInProgress > 0 && ( +
+
+ {t("memoList.importWeChatInProgress")} +
+
+ )} {pullToRefreshVisible && (
setAppNoticeDialog(null)} /> )} + {wechatImportFailureId && ( + setWeChatImportFailureId(null)} + onConfirm={() => { + const importId = wechatImportFailureId; + setWeChatImportFailureId(null); + void Promise.resolve(window.edgeeverDesktop?.retryWeChatImport?.(importId) ?? false).then((retried) => { + if (!retried) setAppNoticeDialog({ + title: t("memoList.importWeChatFailedTitle"), + description: t("memoList.importWeChatFailed"), + }); + }).catch(() => setAppNoticeDialog({ + title: t("memoList.importWeChatFailedTitle"), + description: t("memoList.importWeChatFailed"), + })); + }} + /> + )} {pendingCatalogTrustPluginIds.length > 0 ? ( void): () => void; onImportScreenshot?(callback: (payload: { captureId?: string; name: string; type: string; title?: string; bytes: Uint8Array }) => void): () => void; readWeChatImportMedia?(importId: string, mediaId: string): Promise<{ filename: string; mimeType: string; bytes: Uint8Array }>; - finishWeChatImport?(importId: string): Promise; + finishWeChatImport?(importId: string, success: boolean): Promise; + retryWeChatImport?(importId: string): Promise; onImportWeChatChat?(callback: (payload: { ok: boolean; reason?: string; diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index 9c201fff..277cff0a 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -1622,6 +1622,9 @@ export const enUS = { importWeChatFailedTitle: "Could not save the WeChat chat", importWeChatUnrecognized: "This archive is not a WeChat chat export.", importWeChatFailed: "Couldn't save the chat as a note. Try again.", + importWeChatInProgress: "Importing the WeChat chat…", + importWeChatRetryHint: "The chat export is still in Downloads. Try importing it again.", + importWeChatRetry: "Retry import", loadingMore: "Loading more notes", openMemo: "Open note", unpin: "Unpin", diff --git a/packages/shared/src/i18n/ja.ts b/packages/shared/src/i18n/ja.ts index 33efc863..fc4de57b 100644 --- a/packages/shared/src/i18n/ja.ts +++ b/packages/shared/src/i18n/ja.ts @@ -1622,6 +1622,9 @@ export const ja = { importWeChatFailedTitle: "WeChat のチャットを保存できませんでした", importWeChatUnrecognized: "このアーカイブは WeChat のチャット書き出しではありません。", importWeChatFailed: "チャットをノートとして保存できませんでした。再試行してください。", + importWeChatInProgress: "WeChat のチャットを取り込んでいます…", + importWeChatRetryHint: "チャットの書き出しファイルはダウンロードフォルダに残っています。もう一度お試しください。", + importWeChatRetry: "取り込みを再試行", loadingMore: "さらにノートを読み込み中", openMemo: "ノートを開く", unpin: "ピン留めを外す", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index a0cd499c..7da60b32 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -1622,6 +1622,9 @@ export const zhCN = { importWeChatFailedTitle: "无法保存微信聊天记录", importWeChatUnrecognized: "这个压缩包不是微信导出的聊天记录。", importWeChatFailed: "无法把聊天记录保存为笔记。请重试。", + importWeChatInProgress: "正在导入微信聊天记录…", + importWeChatRetryHint: "聊天记录仍保存在下载目录。请重试导入。", + importWeChatRetry: "重试导入", loadingMore: "正在加载更多笔记", openMemo: "打开笔记", unpin: "取消置顶",