diff --git a/apps/desktop/share-extension/ShareViewController.swift b/apps/desktop/share-extension/ShareViewController.swift index 42c075cf..3f03a062 100644 --- a/apps/desktop/share-extension/ShareViewController.swift +++ b/apps/desktop/share-extension/ShareViewController.swift @@ -1,7 +1,9 @@ import AppKit import UniformTypeIdentifiers -/// Receives WeChat's merged-forward ZIP and hands the file to the EdgeEver app. +/// Copies a file from the macOS share sheet into Downloads and opens EdgeEver. +/// WeChat merged-forward ZIPs stay on this path; the app turns those into chat notes +/// and turns every other file into a note attachment. @objc(ShareViewController) final class ShareViewController: NSViewController { private let incomingDirectoryName = "EdgeEver Incoming" @@ -54,24 +56,109 @@ final class ShareViewController: NSViewController { } for item in items { for provider in item.attachments ?? [] { - guard let type = preferredType(for: provider) else { continue } - return try await writeProvider(provider, type: type) + if let copied = try await copyProvider(provider) { + return copied + } } } throw CocoaError(.fileReadNoSuchFile) } - private func preferredType(for provider: NSItemProvider) -> String? { - let preferred = [ - UTType.zip.identifier, - UTType.fileURL.identifier, - UTType.data.identifier, - UTType.item.identifier, - ] - return preferred.first { provider.hasItemConformingToTypeIdentifier($0) } + /// Finder often hands over `public.file-url` as `file:///.file/id=…`. Asking for that + /// type writes the URL text, not the file. Copy the security-scoped file when it is + /// readable, and otherwise ask for the file's own content type. + private func copyProvider(_ provider: NSItemProvider) async throws -> URL? { + let fileURL = await loadFileURL(provider) + if let fileURL, let copied = try await copySecurityScopedFile(fileURL, suggested: provider.suggestedName) { + return copied + } + guard let type = contentType(for: provider) else { return nil } + // The provider's temporary file exists only inside this callback. + let filename = preferredFilename(suggested: provider.suggestedName, fileURL: fileURL, copiedSource: fileURL ?? URL(fileURLWithPath: "共享的文件")) + return try await copyFileRepresentation(provider, type: type, filename: filename) } - private func writeProvider(_ provider: NSItemProvider, type: String) async throws -> URL { + private func contentType(for provider: NSItemProvider) -> String? { + let skipped: Set = [ + UTType.fileURL.identifier, + UTType.url.identifier, + UTType.plainText.identifier, + UTType.text.identifier, + UTType.utf8PlainText.identifier, + UTType.item.identifier, + ] + let registered = provider.registeredTypeIdentifiers.filter { !skipped.contains($0) } + if let concrete = registered.first(where: { $0 != UTType.data.identifier && !$0.hasPrefix("dyn.") }) { + return concrete + } + if registered.contains(UTType.data.identifier) || provider.hasItemConformingToTypeIdentifier(UTType.data.identifier) { + return UTType.data.identifier + } + return registered.first + } + + private func loadFileURL(_ provider: NSItemProvider) async -> URL? { + guard provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) else { return nil } + return await withCheckedContinuation { continuation in + var resumed = false + provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in + guard !resumed else { return } + resumed = true + if let url = item as? URL { + continuation.resume(returning: url) + return + } + if let data = item as? Data, let url = URL(dataRepresentation: data, relativeTo: nil), url.isFileURL { + continuation.resume(returning: url) + return + } + if let text = item as? String, let url = URL(string: text), url.isFileURL { + continuation.resume(returning: url) + return + } + continuation.resume(returning: nil) + } + } + } + + private func copyFileRepresentation(_ provider: NSItemProvider, type: String, filename: String) async throws -> URL? { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + var resumed = false + provider.loadFileRepresentation(forTypeIdentifier: type) { source, error in + guard !resumed else { return } + resumed = true + guard let source else { + continuation.resume(throwing: error ?? CocoaError(.fileReadUnknown)) + return + } + if self.isFileReferencePlaceholder(source) { + continuation.resume(returning: nil) + return + } + do { + let resolvedName = self.filenameWithExtension(from: source, preferred: filename) + continuation.resume(returning: try self.moveIntoIncoming(source, filename: resolvedName)) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + private func copySecurityScopedFile(_ url: URL, suggested: String?) async throws -> URL? { + let accessing = url.startAccessingSecurityScopedResource() + defer { if accessing { url.stopAccessingSecurityScopedResource() } } + let resolved = url.resolvingSymlinksInPath() + var isDirectory = ObjCBool(false) + guard FileManager.default.fileExists(atPath: resolved.path, isDirectory: &isDirectory), !isDirectory.boolValue else { + return nil + } + guard FileManager.default.isReadableFile(atPath: resolved.path) else { return nil } + let filename = preferredFilename(suggested: suggested, fileURL: url, copiedSource: resolved) + return try moveIntoIncoming(resolved, filename: filename) + } + + private func moveIntoIncoming(_ source: URL, filename: String) throws -> URL { let downloads = try FileManager.default.url( for: .downloadsDirectory, in: .userDomainMask, @@ -82,38 +169,69 @@ final class ShareViewController: NSViewController { .appendingPathComponent(incomingDirectoryName, isDirectory: true) .appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory(at: batch, withIntermediateDirectories: true) - let filename = sanitizedFilename(provider.suggestedName) let destination = batch.appendingPathComponent(filename, isDirectory: false) let partial = batch.appendingPathComponent("\(filename).partial", isDirectory: false) - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - provider.loadFileRepresentation(forTypeIdentifier: type) { source, error in - guard let source else { - continuation.resume(throwing: error ?? CocoaError(.fileReadUnknown)) - return - } - do { - try FileManager.default.copyItem(at: source, to: partial) - try FileManager.default.moveItem(at: partial, to: destination) - continuation.resume() - } catch { - continuation.resume(throwing: error) - } - } - } + try FileManager.default.copyItem(at: source, to: partial) + try FileManager.default.moveItem(at: partial, to: destination) return destination } + private func isFileReferencePlaceholder(_ url: URL) -> Bool { + guard let data = try? Data(contentsOf: url), data.count <= 256, let text = String(data: data, encoding: .utf8) else { + return false + } + return text.hasPrefix("file:///.file/id=") && !text.contains("\n") + } + + private func preferredFilename(suggested: String?, fileURL: URL?, copiedSource: URL) -> String { + if let suggested { + let trimmed = suggested.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return sanitizedFilename(trimmed) } + } + if let fileURL { + let values = try? fileURL.resourceValues(forKeys: [.localizedNameKey, .nameKey]) + if let name = values?.localizedName ?? values?.name { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty && !trimmed.hasPrefix("id=") { + return sanitizedFilename(trimmed) + } + } + } + let copiedName = copiedSource.lastPathComponent + if !copiedName.isEmpty && !copiedName.hasPrefix("id=") { + return sanitizedFilename(copiedName) + } + return sanitizedFilename(nil) + } + + private func filenameWithExtension(from source: URL, preferred: String) -> String { + let sourceName = source.lastPathComponent + let preferredExt = (preferred as NSString).pathExtension + let sourceExt = (sourceName as NSString).pathExtension + if preferredExt.isEmpty && !sourceExt.isEmpty && !sourceName.hasPrefix("id=") { + return sanitizedFilename(sourceName) + } + return preferred + } + private func sanitizedFilename(_ suggested: String?) -> String { - let base = ((suggested ?? "聊天记录.zip") as NSString).lastPathComponent - let trimmed = base.replacingOccurrences(of: "/", with: "").trimmingCharacters(in: .whitespacesAndNewlines) - let name = trimmed.isEmpty ? "聊天记录.zip" : trimmed - return (name as NSString).pathExtension.lowercased() == "zip" ? name : "\(name).zip" + let fallback = Locale.preferredLanguages.first?.hasPrefix("zh") == true ? "共享的文件" : "Shared file" + let base = ((suggested ?? fallback) as NSString).lastPathComponent + let trimmed = base + .replacingOccurrences(of: "/", with: "") + .replacingOccurrences(of: ":", with: "") + .replacingOccurrences(of: "\0", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty || trimmed == "." || trimmed == ".." || trimmed.hasPrefix("id=") { + return fallback + } + return trimmed } private func shareURL(for file: URL) -> URL? { var components = URLComponents() components.scheme = "edgeever" - components.host = "wechat-import" + components.host = "share-import" components.queryItems = [URLQueryItem(name: "path", value: file.path)] return components.url } diff --git a/apps/desktop/src/main/index.mjs b/apps/desktop/src/main/index.mjs index a56d544d..74f15253 100644 --- a/apps/desktop/src/main/index.mjs +++ b/apps/desktop/src/main/index.mjs @@ -712,7 +712,7 @@ const handleProtocolUrl = (target) => { if (typeof target !== "string" || !target.startsWith("edgeever://")) return; try { const url = new URL(target); - if (url.hostname === "wechat-import") { + if (url.hostname === "wechat-import" || url.hostname === "share-import") { void wechatShare().importFromProtocolUrl(target); return; } diff --git a/apps/desktop/src/main/wechat-share-import.mjs b/apps/desktop/src/main/wechat-share-import.mjs index c3d86920..73a9a2cd 100644 --- a/apps/desktop/src/main/wechat-share-import.mjs +++ b/apps/desktop/src/main/wechat-share-import.mjs @@ -1,10 +1,57 @@ import { randomUUID } from "node:crypto"; -import { mkdir, readFile, realpath, readdir, rmdir, rm, writeFile } from "node:fs/promises"; +import { mkdir, readFile, realpath, readdir, rmdir, rm, stat, 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"; +export const SHARE_IMPORT_FILE_ID = "file"; +export const MAX_SHARE_IMPORT_BYTES = 512 * 1024 * 1024; const SAFE_ID = /^[A-Za-z0-9_-]{1,80}$/; +const MIME_TYPES = { + jpg: "image/jpeg", + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + webp: "image/webp", + heic: "image/heic", + mp4: "video/mp4", + mov: "video/quicktime", + m4v: "video/mp4", + webm: "video/webm", + mp3: "audio/mpeg", + m4a: "audio/mp4", + wav: "audio/wav", + pdf: "application/pdf", + md: "text/markdown", + markdown: "text/markdown", + txt: "text/plain", + csv: "text/csv", + json: "application/json", + zip: "application/zip", + xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + xls: "application/vnd.ms-excel", + docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", +}; + +class ShareImportError extends Error { + constructor(code) { + super(code); + this.name = "ShareImportError"; + this.code = code; + } +} + +const extensionOf = (filename) => filename.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] ?? ""; + +export const mimeTypeForFilename = (filename) => MIME_TYPES[extensionOf(filename)] ?? "application/octet-stream"; + +// Finder's share sheet sometimes delivers `file:///.file/id=…` instead of the file. +export const isFileReferencePlaceholder = (bytes) => { + if (!bytes || bytes.length < 16 || bytes.length > 256) return false; + const text = Buffer.from(bytes).toString("utf8"); + return text.startsWith("file:///.file/id=") && !text.includes("\n") && !text.includes("\0"); +}; export const isPathInsideDirectory = (filePath, directory) => { if (!filePath || !directory) return false; @@ -21,7 +68,7 @@ export const wechatImportFilePath = (value) => { } catch { return null; } - if (url.protocol !== "edgeever:" || url.hostname !== "wechat-import") return null; + if (url.protocol !== "edgeever:" || (url.hostname !== "wechat-import" && url.hostname !== "share-import")) return null; const filePath = url.searchParams.get("path"); if (!filePath || !isAbsolute(filePath) || filePath.includes("\0")) return null; return filePath; @@ -29,11 +76,8 @@ export const wechatImportFilePath = (value) => { const incomingDirectory = (downloadsPath) => join(downloadsPath, WECHAT_INCOMING_DIRECTORY_NAME); -const assertIncomingZip = async (filePath, downloadsPath) => { +const assertIncomingFile = async (filePath, downloadsPath) => { const root = incomingDirectory(downloadsPath); - if (!filePath.toLowerCase().endsWith(".zip")) { - throw new WeChatArchiveError("rejected-path"); - } let realRoot; let realFile; try { @@ -43,12 +87,37 @@ const assertIncomingZip = async (filePath, downloadsPath) => { } // The sandboxed share extension may name ~/Downloads through its container // symlink. Validate the resolved file against the resolved incoming directory. - if (!isPathInsideDirectory(realFile, realRoot) || !realFile.toLowerCase().endsWith(".zip")) { + if (!isPathInsideDirectory(realFile, realRoot)) { + throw new WeChatArchiveError("rejected-path"); + } + const info = await stat(realFile).catch(() => null); + if (!info?.isFile()) throw new WeChatArchiveError("rejected-path"); + return realFile; +}; + +const assertIncomingZip = async (filePath, downloadsPath) => { + if (!filePath.toLowerCase().endsWith(".zip")) { + throw new WeChatArchiveError("rejected-path"); + } + const realFile = await assertIncomingFile(filePath, downloadsPath); + if (!realFile.toLowerCase().endsWith(".zip")) { throw new WeChatArchiveError("rejected-path"); } return realFile; }; +const wechatNoteFromBytes = (bytes, filename) => { + if (bytes.length < 4 || bytes[0] !== 0x50 || bytes[1] !== 0x4b) return null; + try { + return wechatChatNoteFromArchive(bytes, filename); + } catch (error) { + if (error instanceof WeChatArchiveError && (error.code === "invalid" || error.code === "unrecognized")) { + return null; + } + throw error; + } +}; + const removeImportedZip = async (zipPath, downloadsPath) => { if (!zipPath) return; const root = await realpath(incomingDirectory(downloadsPath)).catch(() => null); @@ -67,6 +136,7 @@ export const createWeChatShareController = ({ sendToRenderer, onActivity = () => {}, writeDiagnostic = () => {}, + maxBytes = MAX_SHARE_IMPORT_BYTES, }) => { const sessions = new Map(); const preparedPaths = new Set(); @@ -81,7 +151,8 @@ export const createWeChatShareController = ({ if (!success) { session.failed = true; failedPaths.add(session.zipPath); - void writeDiagnostic("wechat-import.save-failed"); + const failedEvent = session.payload?.kind === "file" ? "share-import.save-failed" : "wechat-import.save-failed"; + void writeDiagnostic(failedEvent); return; } sessions.delete(importId); @@ -89,8 +160,9 @@ export const createWeChatShareController = ({ 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 }); + if (session.directory) await rm(session.directory, { recursive: true, force: true }).catch(() => {}); + const savedEvent = session.payload?.kind === "file" ? "share-import.saved" : "wechat-import.saved"; + void writeDiagnostic(savedEvent, { media: session.media.size }); }; const retry = (importId) => { @@ -115,20 +187,60 @@ export const createWeChatShareController = ({ return { filename: media.filename, mimeType: media.mimeType, bytes }; }; + const prepareFileShare = (sourcePath, byteSize) => { + const filename = basename(sourcePath); + const importId = randomUUID(); + const mimeType = mimeTypeForFilename(filename); + const payload = { + ok: true, + kind: "file", + importId, + filename, + mimeType, + byteSize, + }; + sessions.set(importId, { + directory: null, + media: new Map([[SHARE_IMPORT_FILE_ID, { filename, mimeType, byteSize, path: sourcePath }]]), + zipPath: sourcePath, + payload, + failed: false, + }); + preparedPaths.add(sourcePath); + sendToRenderer(payload); + void writeDiagnostic("share-import.prepared", { bytes: byteSize }); + }; + const importFromProtocolUrl = async (value) => { const requestedPath = wechatImportFilePath(value); if (!requestedPath) return; + let shareFile = false; + try { + shareFile = new URL(value).hostname === "share-import"; + } catch { + return; + } const downloads = downloadsPath(); - let zipPath = null; + let sourcePath = null; let preparedDirectory = null; try { - zipPath = await assertIncomingZip(requestedPath, downloads); - if (inFlightPaths.has(zipPath) || preparedPaths.has(zipPath) - || completedPaths.has(zipPath) || failedPaths.has(zipPath)) return; - inFlightPaths.add(zipPath); + sourcePath = shareFile + ? await assertIncomingFile(requestedPath, downloads) + : await assertIncomingZip(requestedPath, downloads); + if (inFlightPaths.has(sourcePath) || preparedPaths.has(sourcePath) + || completedPaths.has(sourcePath) || failedPaths.has(sourcePath)) return; + inFlightPaths.add(sourcePath); onActivity(); - const archive = await readFile(zipPath); - const note = wechatChatNoteFromArchive(archive, basename(zipPath)); + const info = await stat(sourcePath); + if (!info.isFile() || info.size <= 0) throw new ShareImportError("empty"); + if (info.size > maxBytes) throw new ShareImportError("too-large"); + const bytes = await readFile(sourcePath); + if (isFileReferencePlaceholder(bytes)) throw new ShareImportError("unreadable"); + const note = wechatNoteFromBytes(bytes, basename(sourcePath)); + if (!note) { + prepareFileShare(sourcePath, bytes.length); + return; + } const importId = randomUUID(); const directory = join(tempPath(), "edgeever-wechat-import", importId); preparedDirectory = directory; @@ -158,22 +270,39 @@ export const createWeChatShareController = ({ markdown: note.markdown, media: listed, }; - sessions.set(importId, { directory, media, zipPath, payload, failed: false }); - preparedPaths.add(zipPath); + sessions.set(importId, { directory, media, zipPath: sourcePath, payload, failed: false }); + preparedPaths.add(sourcePath); preparedDirectory = null; sendToRenderer(payload); - void writeDiagnostic("wechat-import.prepared", { media: listed.length, bytes: archive.length }); + void writeDiagnostic("wechat-import.prepared", { media: listed.length, bytes: bytes.length }); } catch (error) { if (preparedDirectory) await rm(preparedDirectory, { recursive: true, force: true }).catch(() => {}); - const reason = error instanceof WeChatArchiveError ? error.code : "failed"; - if (zipPath) failedPaths.add(zipPath); - // A stale or malformed protocol URL is not a failed save attempt. - if (reason !== "rejected-path") { - sendToRenderer({ ok: false, reason: reason === "unrecognized" ? "unrecognized" : "failed" }); + const reason = error instanceof WeChatArchiveError || error instanceof ShareImportError + ? error.code + : "failed"; + if (reason === "unreadable" && sourcePath) { + await removeImportedZip(sourcePath, downloads); + } else if (sourcePath && reason !== "rejected-path") { + failedPaths.add(sourcePath); } + // A stale or malformed protocol URL is not a failed save attempt. + if (reason === "rejected-path") { + void writeDiagnostic("wechat-import.rejected", { reason }); + return; + } + if (reason === "unreadable" || reason === "too-large" || reason === "empty") { + sendToRenderer({ + ok: false, + kind: "file", + reason: reason === "too-large" ? "too-large" : "unreadable", + }); + void writeDiagnostic("share-import.rejected", { reason }); + return; + } + sendToRenderer({ ok: false, reason: reason === "unrecognized" ? "unrecognized" : "failed" }); void writeDiagnostic("wechat-import.rejected", { reason }); } finally { - if (zipPath) inFlightPaths.delete(zipPath); + if (sourcePath) inFlightPaths.delete(sourcePath); } }; @@ -185,8 +314,8 @@ export const createWeChatShareController = ({ const directory = join(root, batch.name); const files = await readdir(directory, { withFileTypes: true }).catch(() => []); for (const file of files) { - if (!file.isFile() || !file.name.toLowerCase().endsWith(".zip")) continue; - const url = new URL("edgeever://wechat-import"); + if (!file.isFile() || file.name.startsWith(".") || file.name.toLowerCase().endsWith(".partial")) continue; + const url = new URL(file.name.toLowerCase().endsWith(".zip") ? "edgeever://wechat-import" : "edgeever://share-import"); url.searchParams.set("path", join(directory, file.name)); await importFromProtocolUrl(url.href); } diff --git a/apps/desktop/src/main/wechat-share-import.test.mjs b/apps/desktop/src/main/wechat-share-import.test.mjs index 42e9164d..24a1acee 100644 --- a/apps/desktop/src/main/wechat-share-import.test.mjs +++ b/apps/desktop/src/main/wechat-share-import.test.mjs @@ -4,8 +4,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, test } from "bun:test"; import { + SHARE_IMPORT_FILE_ID, WECHAT_INCOMING_DIRECTORY_NAME, createWeChatShareController, + isFileReferencePlaceholder, isPathInsideDirectory, wechatImportFilePath, } from "./wechat-share-import.mjs"; @@ -74,6 +76,9 @@ describe("WeChat share handoff", () => { expect(wechatImportFilePath("edgeever://wechat-import?path=/Users/me/Downloads/EdgeEver%20Incoming/a/chat.zip")).toBe( "/Users/me/Downloads/EdgeEver Incoming/a/chat.zip", ); + expect(wechatImportFilePath("edgeever://share-import?path=/Users/me/Downloads/EdgeEver%20Incoming/a/sheet.xlsx")).toBe( + "/Users/me/Downloads/EdgeEver Incoming/a/sheet.xlsx", + ); expect(wechatImportFilePath("edgeever://memo/memo_1")).toBeNull(); expect(wechatImportFilePath("https://example.com/chat.zip")).toBeNull(); expect(wechatImportFilePath("edgeever://wechat-import?path=chat.zip")).toBeNull(); @@ -221,7 +226,12 @@ describe("WeChat share handoff", () => { sendToRenderer: (payload) => sent.push(payload), }); await controller.importPending(); - expect(sent).toEqual([{ ok: false, reason: "failed" }]); + expect(sent).toHaveLength(1); + expect(sent[0].ok).toBe(true); + expect(sent[0].kind).toBe("file"); + expect(sent[0].filename).toBe("broken.zip"); + const media = await controller.readMedia(sent[0].importId, "file"); + expect(Buffer.from(media.bytes).toString()).toBe("not a zip"); }); test("picks up a completed share without a protocol URL and ignores a duplicate URL", async () => { @@ -319,4 +329,114 @@ describe("WeChat share handoff", () => { await controller.finish(second.importId, true); await expect(stat(batch)).rejects.toThrow(); }); + + test("saves a shared spreadsheet as a file instead of a WeChat chat", 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 }); + const filePath = join(batch, "云晰科技爆单键盘项目功能梳理表.xlsx"); + await writeFile(filePath, Buffer.from("spreadsheet")); + const sent = []; + const controller = createWeChatShareController({ + downloadsPath: () => downloads, + tempPath: () => join(root, "temp"), + sendToRenderer: (payload) => sent.push(payload), + }); + await controller.importFromProtocolUrl(`edgeever://share-import?path=${encodeURIComponent(filePath)}`); + expect(sent).toHaveLength(1); + expect(sent[0]).toMatchObject({ + ok: true, + kind: "file", + filename: "云晰科技爆单键盘项目功能梳理表.xlsx", + mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + byteSize: Buffer.byteLength("spreadsheet"), + }); + const media = await controller.readMedia(sent[0].importId, SHARE_IMPORT_FILE_ID); + expect(Buffer.from(media.bytes).toString()).toBe("spreadsheet"); + await controller.finish(sent[0].importId, true); + await expect(stat(filePath)).rejects.toThrow(); + }); + + test("still unpacks a WeChat chat when it arrives through the file share URL", 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 }); + const zipPath = join(batch, "聊天记录.zip"); + await writeFile(zipPath, zipOf("聊天记录.txt", "·鱼\n2026年9月22日 22:15\n你好\n", "附件/readme.txt", "hello")); + const sent = []; + const controller = createWeChatShareController({ + downloadsPath: () => downloads, + tempPath: () => join(root, "temp"), + sendToRenderer: (payload) => sent.push(payload), + }); + await controller.importFromProtocolUrl(`edgeever://share-import?path=${encodeURIComponent(zipPath)}`); + expect(sent).toHaveLength(1); + expect(sent[0].ok).toBe(true); + expect(sent[0].kind).toBeUndefined(); + expect(sent[0].markdown).toContain("你好"); + }); + + test("reports a Finder file-id placeholder instead of treating it as a chat", 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 }); + const zipPath = join(batch, "聊天记录.zip"); + const placeholder = Buffer.from("file:///.file/id=6571367.1215912"); + expect(isFileReferencePlaceholder(placeholder)).toBe(true); + await writeFile(zipPath, placeholder); + const sent = []; + const diagnostics = []; + const controller = createWeChatShareController({ + downloadsPath: () => downloads, + tempPath: () => join(root, "temp"), + sendToRenderer: (payload) => sent.push(payload), + writeDiagnostic: (event, details) => diagnostics.push({ event, details }), + }); + await controller.importFromProtocolUrl(`edgeever://wechat-import?path=${encodeURIComponent(zipPath)}`); + expect(sent).toEqual([{ ok: false, kind: "file", reason: "unreadable" }]); + await expect(stat(zipPath)).rejects.toThrow(); + expect(diagnostics).toContainEqual({ event: "share-import.rejected", details: { reason: "unreadable" } }); + }); + + test("rejects a shared file larger than the configured limit", 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 }); + const filePath = join(batch, "large.pdf"); + await writeFile(filePath, Buffer.from("12345")); + const sent = []; + const controller = createWeChatShareController({ + downloadsPath: () => downloads, + tempPath: () => join(root, "temp"), + sendToRenderer: (payload) => sent.push(payload), + maxBytes: 4, + }); + await controller.importFromProtocolUrl(`edgeever://share-import?path=${encodeURIComponent(filePath)}`); + expect(sent).toEqual([{ ok: false, kind: "file", reason: "too-large" }]); + expect((await stat(filePath)).isFile()).toBe(true); + }); + + test("does not read a shared file outside the incoming folder", async () => { + const root = await mkdtemp(join(tmpdir(), "edgeever-wechat-share-")); + roots.push(root); + const outside = join(root, "secret.xlsx"); + await writeFile(outside, Buffer.from("secret")); + const sent = []; + const controller = createWeChatShareController({ + downloadsPath: () => join(root, "Downloads"), + tempPath: () => join(root, "temp"), + sendToRenderer: (payload) => sent.push(payload), + }); + await controller.importFromProtocolUrl(`edgeever://share-import?path=${encodeURIComponent(outside)}`); + expect(sent).toHaveLength(0); + expect((await readFile(outside)).toString()).toBe("secret"); + }); }); diff --git a/apps/web/src/components/WorkspaceApp.tsx b/apps/web/src/components/WorkspaceApp.tsx index 48d58211..370501aa 100644 --- a/apps/web/src/components/WorkspaceApp.tsx +++ b/apps/web/src/components/WorkspaceApp.tsx @@ -133,6 +133,7 @@ import { EditorPaneErrorBoundary, EditorRecoveryPane } from "./EditorPaneErrorBo import { isMarkdownFile, readMarkdownFile } from "@/lib/markdown-file-import"; import { compressImageForUpload } from "@/lib/image-compression"; import { createScreenshotMemo, screenshotFileFromImportPayload, screenshotImportDedupeKey, screenshotImportGate } from "@/lib/screenshot-import"; +import { createSharedFileMemo, sharedFileTitle } from "@/lib/shared-file-import"; import { createWeChatChatMemo } from "@/lib/wechat-chat-import"; import { isDesktopResourceRuntime, stageDesktopResource, toDesktopResourceUrl } from "@/lib/desktop-resources"; import { findMatchingMemoResource } from "@/lib/staged-resource-repair"; @@ -265,6 +266,7 @@ export const WorkspaceApp = ({ const [appNoticeDialog, setAppNoticeDialog] = useState(null); const [wechatImportFailureId, setWeChatImportFailureId] = useState(null); const [wechatImportsInProgress, setWeChatImportsInProgress] = useState(0); + const [sharedFileImportsInProgress, setSharedFileImportsInProgress] = useState(0); const [pendingCatalogTrustPluginIds, setPendingCatalogTrustPluginIds] = useState([]); const [isExportingSelectedMemos, setIsExportingSelectedMemos] = useState(false); const [selectedMarkdownExportProgress, setSelectedMarkdownExportProgress] = useState({ @@ -1780,21 +1782,135 @@ export const WorkspaceApp = ({ const pendingWeChatImportsRef = useRef; }>>([]); - const handleImportWeChatChat = useCallback(async (payload: { + const handleImportSharedFile = useCallback(async (payload: { ok: boolean; reason?: string; importId?: string; + filename?: string; + mimeType?: string; + }) => { + const bridge = window.edgeeverDesktop; + if (!payload.ok || !payload.importId || !payload.filename) { + setAppNoticeDialog({ + title: t("memoList.importSharedFileFailedTitle"), + description: payload.reason === "too-large" + ? t("memoList.importSharedFileTooLarge") + : payload.reason === "unreadable" + ? t("memoList.importSharedFileUnreadable") + : t("memoList.importSharedFileFailed"), + }); + return; + } + const notebookId = selectedNotebookId && notebooks.some((notebook) => notebook.id === selectedNotebookId) && memoView !== "trash" + ? selectedNotebookId + : defaultMemoNotebookId; + if (!notebookId) { + pendingWeChatImportsRef.current.push({ ...payload, kind: "file" }); + return; + } + const importId = payload.importId; + const filename = payload.filename; + setTemplatesOpen(false); + setMobileBottomNavActive("home"); + creatingMemoSelectionRef.current = true; + setSharedFileImportsInProgress((count) => count + 1); + let savedMemo: MemoDetail | null = null; + let resumeDesktopSync: (() => void) | null = null; + try { + if (isDesktopResourceRuntime()) { + resumeDesktopSync = await (await import("@/lib/desktop-sync")).pauseDesktopSyncForImport(); + } + const memo = await createSharedFileMemo({ + notebookId, + filename, + file: await (async () => { + const shared = await bridge?.readWeChatImportMedia?.(importId, "file"); + if (!shared?.bytes?.byteLength) throw new Error("Missing shared file"); + const bytes = shared.bytes; + const copy = new ArrayBuffer(bytes.byteLength); + new Uint8Array(copy).set(bytes); + return new File([copy], shared.filename || filename, { type: shared.mimeType || payload.mimeType || "application/octet-stream" }); + })(), + createMemo: (input) => repository.createMemo(input), + prepareFile: async (file) => (imageCompressionEnabled && file.type.startsWith("image/") + ? (await compressImageForUpload(file)).file + : file), + uploadResource: async (memoId, uploadFile) => { + try { + const { resource } = await repository.uploadMemoResource(memoId, uploadFile); + return { url: toDesktopResourceUrl(resource.url), filename: resource.filename }; + } catch (error) { + if (!isDesktopResourceRuntime()) throw error; + const listed = await repository.listResources().catch(() => ({ resources: [] as Array<{ memoId?: string; url: string; filename?: string | null; kind?: string | null }> })); + const existing = findMatchingMemoResource( + listed.resources.filter((resource) => resource.memoId === memoId), + uploadFile.name, + uploadFile.type.startsWith("image/") ? "image" : "attachment", + ); + if (existing) return { url: toDesktopResourceUrl(existing.url), filename: existing.filename }; + const staged = await stageDesktopResource(memoId, uploadFile); + if (!staged) throw error; + return { url: `edgeever-staged://${staged.id}`, filename: uploadFile.name }; + } + }, + updateMemo: (created, content) => repository.updateMemo(created, { + expectedRevision: created.revision, + expectedContentHash: created.contentHash, + editSessionId: `share:${created.id}`, + title: created.title ?? sharedFileTitle(filename), + contentJson: content.contentJson, + contentMarkdown: content.contentMarkdown, + tags: created.tags, + }), + deleteMemo: (memoId) => isDesktopResourceRuntime() + ? import("@/lib/desktop-repository").then(({ cancelPendingDesktopImportMemo }) => cancelPendingDesktopImportMemo(memoId)) + : repository.deleteMemo(memoId, true), + }); + 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); + setAppNoticeDialog({ + title: t("memoList.importSharedFileFailedTitle"), + description: t("memoList.importSharedFileFailed"), + }); + } + creatingMemoSelectionRef.current = false; + } finally { + resumeDesktopSync?.(); + setSharedFileImportsInProgress((count) => Math.max(0, count - 1)); + } + }, [defaultMemoNotebookId, imageCompressionEnabled, localDataScope, memoView, notebooks, repository, selectedNotebookId, t]); + + const handleImportWeChatChat = useCallback(async (payload: { + ok: boolean; + kind?: "file"; + reason?: string; + importId?: string; title?: string; + filename?: string; + mimeType?: string; markdown?: string; media?: Array<{ id: string; filename: string; mimeType: string; byteSize: number }>; }) => { + if (payload.kind === "file") { + await handleImportSharedFile(payload); + return; + } const bridge = window.edgeeverDesktop; if (!payload.ok || !payload.importId || !payload.markdown) { setAppNoticeDialog({ @@ -1886,7 +2002,7 @@ export const WorkspaceApp = ({ resumeDesktopSync?.(); setWeChatImportsInProgress((count) => Math.max(0, count - 1)); } - }, [defaultMemoNotebookId, imageCompressionEnabled, localDataScope, memoView, notebooks, repository, selectedNotebookId, t]); + }, [defaultMemoNotebookId, handleImportSharedFile, imageCompressionEnabled, localDataScope, memoView, notebooks, repository, selectedNotebookId, t]); const handleImportWeChatChatRef = useRef(handleImportWeChatChat); handleImportWeChatChatRef.current = handleImportWeChatChat; @@ -3035,10 +3151,10 @@ export const WorkspaceApp = ({ return (
- {wechatImportsInProgress > 0 && ( + {(wechatImportsInProgress > 0 || sharedFileImportsInProgress > 0) && (
- {t("memoList.importWeChatInProgress")} + {wechatImportsInProgress > 0 ? t("memoList.importWeChatInProgress") : t("memoList.importSharedFileInProgress")}
)} diff --git a/apps/web/src/lib/shared-file-import.test.mjs b/apps/web/src/lib/shared-file-import.test.mjs new file mode 100644 index 00000000..d894e86e --- /dev/null +++ b/apps/web/src/lib/shared-file-import.test.mjs @@ -0,0 +1,114 @@ +import { describe, expect, test } from "bun:test"; +import { FILE_ATTACHMENT_NODE_TYPE, PDF_ATTACHMENT_NODE_TYPE } from "@edgeever/shared"; +import { createSharedFileMemo, sharedFileTitle } from "./shared-file-import.ts"; + +const memo = { + id: "memo_1", + revision: 1, + contentHash: "hash", + title: "", + tags: [], + contentMarkdown: "", +}; + +describe("shared file note", () => { + test("uses the filename without its extension as the title", () => { + expect(sharedFileTitle("云晰科技爆单键盘项目功能梳理表.xlsx")).toBe("云晰科技爆单键盘项目功能梳理表"); + expect(sharedFileTitle("notes.md")).toBe("notes"); + expect(sharedFileTitle("README")).toBe("README"); + }); + + test("stores a spreadsheet as an attachment", async () => { + let createdTitle = ""; + let updated = null; + const result = await createSharedFileMemo({ + notebookId: "nb_inbox", + filename: "云晰科技爆单键盘项目功能梳理表.xlsx", + file: new File(["sheet"], "云晰科技爆单键盘项目功能梳理表.xlsx", { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }), + createMemo: async (input) => { + createdTitle = input.title; + return { memo: { ...memo, title: input.title } }; + }, + uploadResource: async () => ({ url: "edgeever-staged://sheet" }), + updateMemo: async (created, content) => { + updated = content; + return { memo: { ...created, contentMarkdown: content.contentMarkdown } }; + }, + }); + expect(createdTitle).toBe("云晰科技爆单键盘项目功能梳理表"); + expect(result.contentMarkdown).toContain("edgeever-staged://sheet"); + expect(JSON.stringify(updated.contentJson)).toContain(FILE_ATTACHMENT_NODE_TYPE); + }); + + test("stores a picture inline", async () => { + let updated = null; + await createSharedFileMemo({ + notebookId: "nb_inbox", + filename: "photo.png", + file: new File(["png"], "photo.png", { type: "image/png" }), + createMemo: async (input) => ({ memo: { ...memo, title: input.title } }), + uploadResource: async () => ({ url: "edgeever-staged://photo.png", filename: "photo.png" }), + updateMemo: async (created, content) => { + updated = content; + return { memo: { ...created, contentMarkdown: content.contentMarkdown } }; + }, + }); + expect(updated.contentMarkdown).toContain("![photo.png](edgeever-staged://photo.png)"); + expect(updated.contentJson.content.some((node) => node.type === "image")).toBe(true); + }); + + test("stores a pdf as a pdf attachment", async () => { + let updated = null; + await createSharedFileMemo({ + notebookId: "nb_inbox", + filename: "brief.pdf", + file: new File(["pdf"], "brief.pdf", { type: "application/pdf" }), + createMemo: async () => ({ memo }), + uploadResource: async () => ({ url: "edgeever-staged://brief.pdf", filename: "brief.pdf" }), + updateMemo: async (_created, content) => { + updated = content; + return { memo: { ...memo, contentMarkdown: content.contentMarkdown } }; + }, + }); + expect(JSON.stringify(updated.contentJson)).toContain(PDF_ATTACHMENT_NODE_TYPE); + }); + + test("puts markdown text in the note body", async () => { + let created = null; + const uploads = []; + const result = await createSharedFileMemo({ + notebookId: "nb_inbox", + filename: "今天.md", + file: new File(["# 今天\n\n正文"], "今天.md", { type: "text/markdown" }), + createMemo: async (input) => { + created = input; + return { memo: { ...memo, title: input.title, contentMarkdown: input.contentMarkdown } }; + }, + uploadResource: async () => { + uploads.push(true); + return { url: "edgeever-staged://unused" }; + }, + updateMemo: async () => { throw new Error("should not update"); }, + }); + expect(created.title).toBe("今天"); + expect(created.contentMarkdown).toBe("# 今天\n\n正文"); + expect(uploads).toHaveLength(0); + expect(result.contentMarkdown).toBe("# 今天\n\n正文"); + }); + + test("removes the empty note when the attachment cannot be saved", async () => { + const deleted = []; + await expect(createSharedFileMemo({ + notebookId: "nb_inbox", + filename: "sheet.xlsx", + file: new File(["x"], "sheet.xlsx"), + createMemo: async () => ({ memo }), + uploadResource: async () => { throw new Error("offline"); }, + updateMemo: async () => { throw new Error("should not update"); }, + deleteMemo: async (id) => { deleted.push(id); }, + })).rejects.toThrow("offline"); + expect(deleted).toEqual(["memo_1"]); + }); +}); diff --git a/apps/web/src/lib/shared-file-import.ts b/apps/web/src/lib/shared-file-import.ts new file mode 100644 index 00000000..eafb7651 --- /dev/null +++ b/apps/web/src/lib/shared-file-import.ts @@ -0,0 +1,93 @@ +import { docToMarkdown, markdownToDoc, type TiptapDoc } from "@edgeever/shared"; +import { screenshotNoteContent } from "./screenshot-import"; + +const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "webp"]); +const MARKDOWN_EXTENSIONS = new Set(["md", "markdown"]); +export const MAX_SHARED_MARKDOWN_BYTES = 8 * 1024 * 1024; + +const extensionOf = (filename: string) => filename.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] ?? ""; + +export const sharedFileTitle = (filename: string) => { + const trimmed = filename.trim(); + const dot = trimmed.lastIndexOf("."); + if (dot <= 0) return trimmed || filename; + const stem = trimmed.slice(0, dot).trim(); + return stem || trimmed; +}; + +const escapeLabel = (value: string) => value.replace(/[\\[\]]/g, "\\$&"); + +export const sharedFileAttachmentMarkdown = (filename: string, url: string) => + `[附件:${escapeLabel(filename || "file")}](${url})`; + +const isImageFile = (filename: string, mimeType: string) => + mimeType.toLowerCase().startsWith("image/") || IMAGE_EXTENSIONS.has(extensionOf(filename)); + +const attachmentNoteContent = (markdown: string) => { + const parsed = markdownToDoc(markdown); + const nodes = parsed.content ?? []; + const contentJson: TiptapDoc = { + type: "doc", + content: nodes.at(-1)?.type === "paragraph" ? nodes : [...nodes, { type: "paragraph" }], + }; + return { contentJson, contentMarkdown: docToMarkdown(contentJson) }; +}; + +type SharedMemo = { + id: string; + revision: number; + contentHash: string; + title: string | null; + tags: string[]; + contentMarkdown: string; +}; + +export const createSharedFileMemo = async (input: { + notebookId: string; + filename: string; + file: File; + createMemo: (payload: { + notebookId: string; + title: string; + contentMarkdown: string; + tags: string[]; + }) => Promise<{ memo: TMemo }>; + prepareFile?: (file: File) => Promise; + uploadResource: (memoId: string, file: File) => Promise<{ url: string; filename?: string | null }>; + updateMemo: ( + memo: TMemo, + content: { contentJson: TiptapDoc; contentMarkdown: string }, + ) => Promise<{ memo: TMemo }>; + deleteMemo?: (memoId: string) => Promise; +}) => { + const title = sharedFileTitle(input.filename); + const extension = extensionOf(input.filename); + if (MARKDOWN_EXTENSIONS.has(extension) && input.file.size <= MAX_SHARED_MARKDOWN_BYTES) { + const text = await input.file.text(); + return (await input.createMemo({ + notebookId: input.notebookId, + title, + contentMarkdown: text, + tags: [], + })).memo; + } + + const created = await input.createMemo({ + notebookId: input.notebookId, + title, + contentMarkdown: "", + tags: [], + }); + try { + const prepared = input.prepareFile ? await input.prepareFile(input.file) : input.file; + const resource = await input.uploadResource(created.memo.id, prepared); + const filename = resource.filename || prepared.name || input.filename; + const content = isImageFile(filename, prepared.type || input.file.type) + ? screenshotNoteContent(filename, resource.url) + : attachmentNoteContent(sharedFileAttachmentMarkdown(filename, resource.url)); + return (await input.updateMemo(created.memo, content)).memo; + } catch (error) { + await input.deleteMemo?.(created.memo.id).catch(() => undefined); + throw error; + } +}; diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 2da96b11..9a26ae58 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -93,9 +93,13 @@ interface EdgeEverDesktopBridge { retryWeChatImport?(importId: string): Promise; onImportWeChatChat?(callback: (payload: { ok: boolean; + kind?: "file"; reason?: string; importId?: string; title?: string; + filename?: string; + mimeType?: string; + byteSize?: number; markdown?: string; media?: Array<{ id: string; filename: string; mimeType: string; byteSize: number }>; }) => void): () => void; diff --git a/package.json b/package.json index 646f08dd..6e6dc431 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "release": "bun scripts/release.mjs", "publish:stores": "bun scripts/store-delivery.mjs", "test:desktop:sidecar": "bun scripts/test-desktop-sidecar.mjs", - "test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/app-protocol.test.mjs apps/desktop/src/main/child-process-start.test.mjs apps/desktop/src/main/clipboard-write.test.mjs apps/desktop/src/main/desktop-diagnostics.test.mjs apps/desktop/src/main/desktop-menu.test.mjs apps/desktop/src/main/desktop-update-flow.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/wechat-chat-archive.test.mjs apps/desktop/src/main/wechat-share-import.test.mjs apps/desktop/src/main/share-extension-registration.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/local-data-reset.test.mjs apps/desktop/src/main/ai-direct.test.mjs apps/desktop/src/main/plugin-public-network.test.mjs apps/desktop/src/main/renderer-recovery.test.mjs apps/desktop/src/main/renderer-hibernate.test.mjs apps/desktop/src/main/renderer-startup-guard.test.mjs apps/desktop/src/main/renderer-storage-migration.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/scheduled-task-scheduler.test.mjs apps/desktop/src/main/screenshot-capture.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/staged-resource-alias.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/desktop/src/main/windows-update-trust.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-renderer-ready.test.mjs apps/web/src/lib/desktop-workspace-restore.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/screenshot-import.test.mjs apps/web/src/lib/wechat-chat-import.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun run test:desktop:sidecar", + "test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/app-protocol.test.mjs apps/desktop/src/main/child-process-start.test.mjs apps/desktop/src/main/clipboard-write.test.mjs apps/desktop/src/main/desktop-diagnostics.test.mjs apps/desktop/src/main/desktop-menu.test.mjs apps/desktop/src/main/desktop-update-flow.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/wechat-chat-archive.test.mjs apps/desktop/src/main/wechat-share-import.test.mjs apps/desktop/src/main/share-extension-registration.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/local-data-reset.test.mjs apps/desktop/src/main/ai-direct.test.mjs apps/desktop/src/main/plugin-public-network.test.mjs apps/desktop/src/main/renderer-recovery.test.mjs apps/desktop/src/main/renderer-hibernate.test.mjs apps/desktop/src/main/renderer-startup-guard.test.mjs apps/desktop/src/main/renderer-storage-migration.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/scheduled-task-scheduler.test.mjs apps/desktop/src/main/screenshot-capture.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/staged-resource-alias.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/desktop/src/main/windows-update-trust.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-renderer-ready.test.mjs apps/web/src/lib/desktop-workspace-restore.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/screenshot-import.test.mjs apps/web/src/lib/wechat-chat-import.test.mjs apps/web/src/lib/shared-file-import.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun run test:desktop:sidecar", "verify:desktop-renderer": "bun scripts/verify-desktop-renderer.mjs", "verify:renderer-origin-migration": "bun run --cwd apps/desktop verify-origin-migration", "verify:desktop-cross-version-startup": "bun scripts/verify-desktop-cross-version-startup.mjs", diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index ba25469a..35404e7e 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -1641,6 +1641,11 @@ export const enUS = { importWeChatInProgress: "Importing the WeChat chat…", importWeChatRetryHint: "The chat export is still in Downloads. Try importing it again.", importWeChatRetry: "Retry import", + importSharedFileFailedTitle: "Could not save the file", + importSharedFileFailed: "Couldn't save this file as a note. Try again.", + importSharedFileUnreadable: "The selected file could not be read. Share it again.", + importSharedFileTooLarge: "This file is too large to save as a note.", + importSharedFileInProgress: "Saving the file as a note…", 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 c9ec3629..ec4ec4a8 100644 --- a/packages/shared/src/i18n/ja.ts +++ b/packages/shared/src/i18n/ja.ts @@ -1641,6 +1641,11 @@ export const ja = { importWeChatInProgress: "WeChat のチャットを取り込んでいます…", importWeChatRetryHint: "チャットの書き出しファイルはダウンロードフォルダに残っています。もう一度お試しください。", importWeChatRetry: "取り込みを再試行", + importSharedFileFailedTitle: "ファイルを保存できませんでした", + importSharedFileFailed: "このファイルをノートとして保存できませんでした。再試行してください。", + importSharedFileUnreadable: "選択したファイルを読み取れませんでした。もう一度共有してください。", + importSharedFileTooLarge: "このファイルは大きすぎるため、ノートとして保存できません。", + importSharedFileInProgress: "ファイルをノートとして保存しています…", loadingMore: "さらにノートを読み込み中", openMemo: "ノートを開く", unpin: "ピン留めを外す", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 92ce4d3f..a65cb08f 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -1641,6 +1641,11 @@ export const zhCN = { importWeChatInProgress: "正在导入微信聊天记录…", importWeChatRetryHint: "聊天记录仍保存在下载目录。请重试导入。", importWeChatRetry: "重试导入", + importSharedFileFailedTitle: "无法保存文件", + importSharedFileFailed: "无法把这个文件保存为笔记。请重试。", + importSharedFileUnreadable: "没能读取所选文件。请再共享一次。", + importSharedFileTooLarge: "这个文件太大,无法保存为笔记。", + importSharedFileInProgress: "正在把文件保存为笔记…", loadingMore: "正在加载更多笔记", openMemo: "打开笔记", unpin: "取消置顶",