From d0a66c29a80ab4c0fa1ef8fe77c9e2e448a57fef Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Thu, 15 Oct 2026 10:48:20 +0200 Subject: [PATCH] feat: offer no-key MyMemory translation provider --- README.md | 6 ++- Sources/Conjure/TranslationController.swift | 57 ++++++++++++++++----- Sources/ConjureKit/MyMemoryAPI.swift | 47 +++++++++++++++++ Tests/ConjureKitTests/MyMemoryTests.swift | 30 +++++++++++ 4 files changed, 125 insertions(+), 15 deletions(-) create mode 100644 Sources/ConjureKit/MyMemoryAPI.swift create mode 100644 Tests/ConjureKitTests/MyMemoryTests.swift diff --git a/README.md b/README.md index b8ff246..99a242a 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,11 @@ Applications with identical display names show a muted containing-folder hint be Search for **Conjure Translate**, labeled **Command**, and open it with Return, double-click, or **Open Command**. The launcher fades into an English → Japanese translation tool; the existing search bar becomes the English input. Escape or **Back** returns to the previous search. -Enter an OpenAI 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. Press Return from the English input or click **Translate** to send the text to OpenAI's Responses API using exactly `gpt-6-luna`. Your account must have access to that model and sufficient API billing; failures appear in the tool, with no fallback model. Requests disable API response storage (`store: false`); OpenAI's own data-retention policies still apply. +Choose **MyMemory (no key)** or **OpenAI · gpt-6-luna** from the provider dropdown. MyMemory is the initial default, and the selection is saved locally. Switching providers cancels pending work and clears the previous translation; there is no automatic fallback. Press Return from the English input or click **Translate** to send text to the selected provider. + +[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. + +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/Conjure/TranslationController.swift b/Sources/Conjure/TranslationController.swift index 65cda6c..84fa3f3 100644 --- a/Sources/Conjure/TranslationController.swift +++ b/Sources/Conjure/TranslationController.swift @@ -5,8 +5,11 @@ import Security @MainActor final class TranslationController: NSViewController { private let keyField = NSSecureTextField() + private let provider = NSPopUpButton() + private let credentials = NSStackView() + private var usesMyMemory: Bool { provider.indexOfSelectedItem == 0 } private let output = NSTextView() - private let status = NSTextField(wrappingLabelWithString: "English → Japanese · gpt-6-luna · Text is sent to OpenAI only when you translate.") + private let status = NSTextField(wrappingLabelWithString: "") private let translateButton = NSButton(title: "Translate ↵", target: nil, action: nil) private var task: Task? var input: (() -> String)? @@ -23,12 +26,17 @@ final class TranslationController: NSViewController { let title = NSTextField(labelWithString: "Conjure Translate · English → Japanese") title.font = .systemFont(ofSize: 13, weight: .semibold) let heading = NSStackView(views: [back, title, NSView()]) + provider.addItems(withTitles: ["MyMemory (no key)", "OpenAI · gpt-6-luna"]) + provider.selectItem(at: UserDefaults.standard.string(forKey: "translationProvider") == "openai" ? 1 : 0) + provider.setAccessibilityLabel("Translation provider") + provider.target = self + provider.action = #selector(providerChanged) keyField.placeholderString = "OpenAI API key (saved in Keychain)" keyField.setAccessibilityLabel("OpenAI API key") keyField.widthAnchor.constraint(greaterThanOrEqualToConstant: 220).isActive = true let save = NSButton(title: "Save Key", target: self, action: #selector(saveKey)) let forget = NSButton(title: "Forget Key", target: self, action: #selector(forgetKey)) - let credentials = NSStackView(views: [keyField, save, forget]) + for control in [keyField, save, forget] { credentials.addArrangedSubview(control) } credentials.distribution = .fill translateButton.target = self translateButton.action = #selector(translate) @@ -48,7 +56,7 @@ final class TranslationController: NSViewController { output.textContainer?.widthTracksTextView = true status.textColor = SunsetPalette.secondaryText status.font = .systemFont(ofSize: 12) - let stack = NSStackView(views: [heading, credentials, scroll, status, translateButton]) + let stack = NSStackView(views: [heading, provider, credentials, scroll, status, translateButton]) stack.orientation = .vertical stack.alignment = .leading stack.spacing = 12 @@ -64,6 +72,7 @@ final class TranslationController: NSViewController { scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 100), status.widthAnchor.constraint(equalTo: stack.widthAnchor), ]) + updateProvider() } @objc func goBack() { @@ -77,7 +86,20 @@ final class TranslationController: NSViewController { task = nil translateButton.isEnabled = true output.string = "" - status.stringValue = "Press Return to translate English → Japanese with gpt-6-luna." + status.stringValue = usesMyMemory + ? "MyMemory · No key · 500 bytes/request · 5,000 characters/day. Text is sent to MyMemory; avoid sensitive content." + : "OpenAI · gpt-6-luna · Text is sent to OpenAI only when you translate." + } + + @objc private func providerChanged() { + UserDefaults.standard.set(usesMyMemory ? "mymemory" : "openai", forKey: "translationProvider") + keyField.stringValue = "" + updateProvider() + } + + private func updateProvider() { + credentials.isHidden = usesMyMemory + inputChanged() } @objc private func saveKey() { @@ -122,22 +144,29 @@ final class TranslationController: NSViewController { status.stringValue = "Enter English text in the search bar." return } - if !keyField.stringValue.isEmpty { saveKey(); guard keyField.stringValue.isEmpty else { return } } - var query = Self.credential - query[kSecReturnData as String] = true - var item: CFTypeRef? - let result = SecItemCopyMatching(query as CFDictionary, &item) - guard result == errSecSuccess, let data = item as? Data, let key = String(data: data, encoding: .utf8) else { - if result == errSecItemNotFound { status.stringValue = "Enter and save your OpenAI API key first." } - else { showKeychainError(result) } - return + let myMemory = usesMyMemory + var key = "" + if !myMemory { + if !keyField.stringValue.isEmpty { saveKey(); guard keyField.stringValue.isEmpty else { return } } + var query = Self.credential + query[kSecReturnData as String] = true + var item: CFTypeRef? + let result = SecItemCopyMatching(query as CFDictionary, &item) + guard result == errSecSuccess, let data = item as? Data, let savedKey = String(data: data, encoding: .utf8) else { + if result == errSecItemNotFound { status.stringValue = "Enter and save your OpenAI API key first." } + else { showKeychainError(result) } + return + } + key = savedKey } output.string = "" status.stringValue = "Translating…" translateButton.isEnabled = false task = Task { [weak self] in do { - let translation = try await TranslationAPI.translate(text, apiKey: key) + let translation = try await myMemory + ? MyMemoryAPI.translate(text) + : TranslationAPI.translate(text, apiKey: key) guard !Task.isCancelled, let self else { return } self.output.string = translation self.status.stringValue = "Japanese · Select text to copy (⌘C)." diff --git a/Sources/ConjureKit/MyMemoryAPI.swift b/Sources/ConjureKit/MyMemoryAPI.swift new file mode 100644 index 0000000..5f9d8fa --- /dev/null +++ b/Sources/ConjureKit/MyMemoryAPI.swift @@ -0,0 +1,47 @@ +import Foundation + +public enum MyMemoryAPI { + public static func translate(_ text: String) async throws -> String { + try await translate(text) { try await URLSession.shared.data(for: $0) } + } + + static func translate( + _ text: String, load: @Sendable (URLRequest) async throws -> (Data, URLResponse) + ) async throws -> String { + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw Failure(message: "Enter English text to translate.") + } + guard text.utf8.count <= 500 else { + throw Failure(message: "MyMemory allows at most 500 UTF-8 bytes per translation. Shorten the text or choose OpenAI.") + } + var url = URLComponents(string: "https://api.mymemory.translated.net/get")! + url.queryItems = [URLQueryItem(name: "q", value: text), URLQueryItem(name: "langpair", value: "en|ja")] + let request = URLRequest(url: url.url!, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 30) + let (data, response) = try await load(request) + try Task.checkCancellation() + guard let response = response as? HTTPURLResponse, (200...299).contains(response.statusCode) else { + throw Failure(message: "MyMemory request failed. Try again later or choose OpenAI.") + } + let result = try JSONDecoder().decode(Response.self, from: data) + 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 { + throw Failure(message: "MyMemory returned no successful translation. Try again later or choose OpenAI.") + } + return translation + } + + private struct Response: Decodable { + let responseStatus: Int + let quotaFinished: Bool? + let responseData: Translation? + struct Translation: Decodable { let translatedText: String? } + } + private struct Failure: LocalizedError { + let message: String + var errorDescription: String? { message } + } +} diff --git a/Tests/ConjureKitTests/MyMemoryTests.swift b/Tests/ConjureKitTests/MyMemoryTests.swift new file mode 100644 index 0000000..f24c50c --- /dev/null +++ b/Tests/ConjureKitTests/MyMemoryTests.swift @@ -0,0 +1,30 @@ +import Foundation +import Testing +@testable import ConjureKit + +@Test func myMemoryTranslatesWithoutCredentialsAndEncodesInput() async throws { + let translation = try await MyMemoryAPI.translate("Hello & goodbye?") { request in + let items = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)!.queryItems! + #expect(items.contains(URLQueryItem(name: "q", value: "Hello & goodbye?"))) + #expect(items.contains(URLQueryItem(name: "langpair", value: "en|ja"))) + #expect(request.value(forHTTPHeaderField: "Authorization") == nil) + return (Data(#"{"responseStatus":200,"quotaFinished":false,"responseData":{"translatedText":"こんにちは"}}"#.utf8), + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + #expect(translation == "こんにちは") + await #expect(throws: (any Error).self) { + try await MyMemoryAPI.translate(String(repeating: "あ", count: 167)) { _ in + Issue.record("Oversized UTF-8 input must not reach the network") + throw URLError(.badURL) + } + } + for body in [#"{"responseStatus":200,"quotaFinished":true,"responseData":{"translatedText":"QUOTA EXCEEDED"}}"#, + #"{"responseStatus":403,"responseData":{"translatedText":"ERROR"}}"#, + #"{"responseStatus":200,"responseData":{"translatedText":""}}"#] { + await #expect(throws: (any Error).self) { + try await MyMemoryAPI.translate("Hello") { request in + (Data(body.utf8), HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + } + } +}