feat: offer no-key MyMemory translation provider

This commit is contained in:
Valdemar Lorenzen
2026-10-15 10:48:20 +02:00
parent fa3fc762ec
commit d0a66c29a8
4 changed files with 125 additions and 15 deletions
+43 -14
View File
@@ -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<Void, Never>?
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)."