fix: retain WeChat share archives until memo save

This commit is contained in:
tianma-if committed 2026-09-23 18:21:35 +08:00
1 parent 5fdf7f5935
commit e21583dfb8
11 files changed
+199 -50

No files matched your search

+3 -2
View File
@@ -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();
@@ -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;
@@ -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);
+39 -29
View File
@@ -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 };
};
@@ -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();
});
});
+2 -1
View File
@@ -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);
+42 -10
View File
@@ -262,6 +262,8 @@ export const WorkspaceApp = ({
const [notebookNameDialog, setNotebookNameDialog] = useState<NotebookNameDialogState | null>(null);
const [notebookDeleteConfirmation, setNotebookDeleteConfirmation] = useState<Notebook | null>(null);
const [appNoticeDialog, setAppNoticeDialog] = useState<AppNoticeDialogState | null>(null);
const [wechatImportFailureId, setWeChatImportFailureId] = useState<string | null>(null);
const [wechatImportsInProgress, setWeChatImportsInProgress] = useState(0);
const [pendingCatalogTrustPluginIds, setPendingCatalogTrustPluginIds] = useState<string[]>([]);
const [isExportingSelectedMemos, setIsExportingSelectedMemos] = useState(false);
const [selectedMarkdownExportProgress, setSelectedMarkdownExportProgress] = useState<MarkdownExportProgress>({
@@ -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 (
<WorkspaceMotionProvider>
<div className="edgeever-workspace-shell flex h-[100dvh] overflow-hidden text-slate-950">
{wechatImportsInProgress > 0 && (
<div className="pointer-events-none fixed inset-x-0 top-3 z-50 flex justify-center" role="status" aria-live="polite">
<div className="rounded-full border border-slate-200 bg-card px-4 py-2 text-sm font-medium text-slate-700 shadow-lg">
{t("memoList.importWeChatInProgress")}
</div>
</div>
)}
{pullToRefreshVisible && (
<div
className="pointer-events-none fixed inset-x-0 top-[max(0.75rem,env(safe-area-inset-top))] z-50 flex justify-center lg:hidden"
@@ -3545,6 +3554,29 @@ export const WorkspaceApp = ({
onConfirm={() => setAppNoticeDialog(null)}
/>
)}
{wechatImportFailureId && (
<AppConfirmDialog
title={t("memoList.importWeChatFailedTitle")}
description={t("memoList.importWeChatRetryHint")}
confirmLabel={t("memoList.importWeChatRetry")}
closeOnBrowserBack={false}
tone="neutral"
onCancel={() => 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 ? (
<AppConfirmDialog
title={t(PLUGIN_TRUST_WARNING_COPY.titleKey)}
+2 -1
View File
@@ -87,7 +87,8 @@ interface EdgeEverDesktopBridge {
onImportMarkdown(callback: (payload: { name: string; content: string }) => 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<void>;
finishWeChatImport?(importId: string, success: boolean): Promise<void>;
retryWeChatImport?(importId: string): Promise<boolean>;
onImportWeChatChat?(callback: (payload: {
ok: boolean;
reason?: string;
+3
View File
@@ -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",
+3
View File
@@ -1622,6 +1622,9 @@ export const ja = {
importWeChatFailedTitle: "WeChat のチャットを保存できませんでした",
importWeChatUnrecognized: "このアーカイブは WeChat のチャット書き出しではありません。",
importWeChatFailed: "チャットをノートとして保存できませんでした。再試行してください。",
importWeChatInProgress: "WeChat のチャットを取り込んでいます…",
importWeChatRetryHint: "チャットの書き出しファイルはダウンロードフォルダに残っています。もう一度お試しください。",
importWeChatRetry: "取り込みを再試行",
loadingMore: "さらにノートを読み込み中",
openMemo: "ノートを開く",
unpin: "ピン留めを外す",
+3
View File
@@ -1622,6 +1622,9 @@ export const zhCN = {
importWeChatFailedTitle: "无法保存微信聊天记录",
importWeChatUnrecognized: "这个压缩包不是微信导出的聊天记录。",
importWeChatFailed: "无法把聊天记录保存为笔记。请重试。",
importWeChatInProgress: "正在导入微信聊天记录…",
importWeChatRetryHint: "聊天记录仍保存在下载目录。请重试导入。",
importWeChatRetry: "重试导入",
loadingMore: "正在加载更多笔记",
openMemo: "打开笔记",
unpin: "取消置顶",