feat: offer no-key MyMemory translation provider
This commit is contained in:
@@ -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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import Security
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class TranslationController: NSViewController {
|
final class TranslationController: NSViewController {
|
||||||
private let keyField = NSSecureTextField()
|
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 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 let translateButton = NSButton(title: "Translate ↵", target: nil, action: nil)
|
||||||
private var task: Task<Void, Never>?
|
private var task: Task<Void, Never>?
|
||||||
var input: (() -> String)?
|
var input: (() -> String)?
|
||||||
@@ -23,12 +26,17 @@ final class TranslationController: NSViewController {
|
|||||||
let title = NSTextField(labelWithString: "Conjure Translate · English → Japanese")
|
let title = NSTextField(labelWithString: "Conjure Translate · English → Japanese")
|
||||||
title.font = .systemFont(ofSize: 13, weight: .semibold)
|
title.font = .systemFont(ofSize: 13, weight: .semibold)
|
||||||
let heading = NSStackView(views: [back, title, NSView()])
|
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.placeholderString = "OpenAI API key (saved in Keychain)"
|
||||||
keyField.setAccessibilityLabel("OpenAI API key")
|
keyField.setAccessibilityLabel("OpenAI API key")
|
||||||
keyField.widthAnchor.constraint(greaterThanOrEqualToConstant: 220).isActive = true
|
keyField.widthAnchor.constraint(greaterThanOrEqualToConstant: 220).isActive = true
|
||||||
let save = NSButton(title: "Save Key", target: self, action: #selector(saveKey))
|
let save = NSButton(title: "Save Key", target: self, action: #selector(saveKey))
|
||||||
let forget = NSButton(title: "Forget Key", target: self, action: #selector(forgetKey))
|
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
|
credentials.distribution = .fill
|
||||||
translateButton.target = self
|
translateButton.target = self
|
||||||
translateButton.action = #selector(translate)
|
translateButton.action = #selector(translate)
|
||||||
@@ -48,7 +56,7 @@ final class TranslationController: NSViewController {
|
|||||||
output.textContainer?.widthTracksTextView = true
|
output.textContainer?.widthTracksTextView = true
|
||||||
status.textColor = SunsetPalette.secondaryText
|
status.textColor = SunsetPalette.secondaryText
|
||||||
status.font = .systemFont(ofSize: 12)
|
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.orientation = .vertical
|
||||||
stack.alignment = .leading
|
stack.alignment = .leading
|
||||||
stack.spacing = 12
|
stack.spacing = 12
|
||||||
@@ -64,6 +72,7 @@ final class TranslationController: NSViewController {
|
|||||||
scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 100),
|
scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 100),
|
||||||
status.widthAnchor.constraint(equalTo: stack.widthAnchor),
|
status.widthAnchor.constraint(equalTo: stack.widthAnchor),
|
||||||
])
|
])
|
||||||
|
updateProvider()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func goBack() {
|
@objc func goBack() {
|
||||||
@@ -77,7 +86,20 @@ final class TranslationController: NSViewController {
|
|||||||
task = nil
|
task = nil
|
||||||
translateButton.isEnabled = true
|
translateButton.isEnabled = true
|
||||||
output.string = ""
|
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() {
|
@objc private func saveKey() {
|
||||||
@@ -122,22 +144,29 @@ final class TranslationController: NSViewController {
|
|||||||
status.stringValue = "Enter English text in the search bar."
|
status.stringValue = "Enter English text in the search bar."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let myMemory = usesMyMemory
|
||||||
|
var key = ""
|
||||||
|
if !myMemory {
|
||||||
if !keyField.stringValue.isEmpty { saveKey(); guard keyField.stringValue.isEmpty else { return } }
|
if !keyField.stringValue.isEmpty { saveKey(); guard keyField.stringValue.isEmpty else { return } }
|
||||||
var query = Self.credential
|
var query = Self.credential
|
||||||
query[kSecReturnData as String] = true
|
query[kSecReturnData as String] = true
|
||||||
var item: CFTypeRef?
|
var item: CFTypeRef?
|
||||||
let result = SecItemCopyMatching(query as CFDictionary, &item)
|
let result = SecItemCopyMatching(query as CFDictionary, &item)
|
||||||
guard result == errSecSuccess, let data = item as? Data, let key = String(data: data, encoding: .utf8) else {
|
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." }
|
if result == errSecItemNotFound { status.stringValue = "Enter and save your OpenAI API key first." }
|
||||||
else { showKeychainError(result) }
|
else { showKeychainError(result) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
key = savedKey
|
||||||
|
}
|
||||||
output.string = ""
|
output.string = ""
|
||||||
status.stringValue = "Translating…"
|
status.stringValue = "Translating…"
|
||||||
translateButton.isEnabled = false
|
translateButton.isEnabled = false
|
||||||
task = Task { [weak self] in
|
task = Task { [weak self] in
|
||||||
do {
|
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 }
|
guard !Task.isCancelled, let self else { return }
|
||||||
self.output.string = translation
|
self.output.string = translation
|
||||||
self.status.stringValue = "Japanese · Select text to copy (⌘C)."
|
self.status.stringValue = "Japanese · Select text to copy (⌘C)."
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user