diff --git a/README.md b/README.md index 99a242a..868e01a 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,8 @@ Choose **MyMemory (no key)** or **OpenAI · gpt-6-luna** from the provider dropd [MyMemory](https://mymemory.translated.net/doc/spec.php) requires no account or key. Anonymous use allows 500 UTF-8 bytes per request and [5,000 characters per day](https://mymemory.translated.net/doc/usagelimits.php). Oversized input is rejected before sending; quota and service errors appear in the tool. Text is sent to a third-party translation service: avoid sensitive content. +If MyMemory's top translation is blank, Conjure uses the first nonblank exact English→Japanese match in that same response. Approximate matches are not substituted, and service/quota failures still surface as errors. + For **OpenAI**, enter an API key and choose **Save Key** (or translate to save it first). Keys are stored in macOS Keychain, not preferences; **Forget Key** removes the saved key. This provider uses OpenAI's Responses API with exactly `gpt-6-luna`. Your account must have access to that model and sufficient API billing. Requests disable API response storage (`store: false`); OpenAI's own data-retention policies still apply. MyMemory never reads or sends the OpenAI key. Translations appear as selectable Japanese text; use Command-C to copy a selection. Editing input or leaving the tool cancels the pending request and prevents stale results from appearing. Typing alone never sends text. Application-only actions and numbered launching are disabled inside the tool. diff --git a/Sources/ConjureKit/MyMemoryAPI.swift b/Sources/ConjureKit/MyMemoryAPI.swift index 5f9d8fa..0514154 100644 --- a/Sources/ConjureKit/MyMemoryAPI.swift +++ b/Sources/ConjureKit/MyMemoryAPI.swift @@ -26,18 +26,35 @@ public enum MyMemoryAPI { guard result.quotaFinished != true, result.responseStatus != 429 else { throw Failure(message: "MyMemory's anonymous daily quota is exhausted. Try later or choose OpenAI.") } - guard result.responseStatus == 200, - let translation = result.responseData?.translatedText, - !translation.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + guard result.responseStatus == 200 else { throw Failure(message: "MyMemory returned no successful translation. Try again later or choose OpenAI.") } - return translation + if let translation = result.responseData?.translatedText, + !translation.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return translation + } + // The service can rank an empty memory first; only use another exact en→ja match. + if let match = result.matches?.first(where: { + $0.segment == text && $0.source.split(separator: "-").first == "en" + && $0.target.split(separator: "-").first == "ja" + && !$0.translation.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + }) { + return match.translation + } + throw Failure(message: "MyMemory returned no successful translation. Try again later or choose OpenAI.") } private struct Response: Decodable { let responseStatus: Int let quotaFinished: Bool? let responseData: Translation? + let matches: [Match]? + struct Match: Decodable { + let segment: String + let translation: String + let source: String + let target: String + } struct Translation: Decodable { let translatedText: String? } } private struct Failure: LocalizedError { diff --git a/Tests/ConjureKitTests/MyMemoryTests.swift b/Tests/ConjureKitTests/MyMemoryTests.swift index f24c50c..34f1f78 100644 --- a/Tests/ConjureKitTests/MyMemoryTests.swift +++ b/Tests/ConjureKitTests/MyMemoryTests.swift @@ -28,3 +28,35 @@ import Testing } } } + +@Test func myMemoryUsesNonblankExactMatchWhenPrimaryTranslationIsEmpty() async throws { + // MyMemory's live response for "hello" has a blank top translation and a valid exact match. + let body = #"{"responseStatus":200,"quotaFinished":false,"responseData":{"translatedText":""},"matches":[{"segment":"hello","translation":"","source":"en-GB","target":"ja-JP"},{"segment":"hello there","translation":"近似","source":"en","target":"ja"},{"segment":"hello","translation":"bonjour","source":"en","target":"fr"},{"segment":"hello","translation":"こんにちは","source":"en-GB","target":"ja-JP"}]}"# + let translation = try await MyMemoryAPI.translate("hello") { request in + (Data(body.utf8), HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + #expect(translation == "こんにちは") +} + +@Test func myMemoryNeverUsesMatchesToBypassFailureOrReplacePrimaryTranslation() async throws { + let matches = #""matches":[{"segment":"hello","translation":"こんにちは","source":"en","target":"ja"}]"# + for metadata in [#""responseStatus":403"#, #""responseStatus":200,"quotaFinished":true"#] { + await #expect(throws: (any Error).self) { + try await MyMemoryAPI.translate("hello") { request in + (Data("{\(metadata),\(matches)}".utf8), + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + } + } + let primary = try await MyMemoryAPI.translate("hello") { request in + (Data("{\"responseStatus\":200,\"responseData\":{\"translatedText\":\"どうも\"},\(matches)}".utf8), + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + #expect(primary == "どうも") + await #expect(throws: (any Error).self) { + try await MyMemoryAPI.translate("unrelated") { request in + (Data("{\"responseStatus\":200,\(matches)}".utf8), + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + } +}