387 lines
18 KiB
Swift
387 lines
18 KiB
Swift
import AppKit
|
|
import ConjureKit
|
|
import Security
|
|
|
|
@MainActor
|
|
final class TranslationController: NSViewController {
|
|
private let providerButton = NSButton(title: "", target: nil, action: nil)
|
|
let providerControl = NSView()
|
|
private let languageButton = NSButton(title: "", target: nil, action: nil)
|
|
private let actionsButton = NSButton(title: "Actions", target: nil, action: nil)
|
|
private let actions = ApplicationActionsController()
|
|
private(set) var sourceLanguage = "en"
|
|
private(set) var targetLanguage = "ja"
|
|
var focusSearch: (() -> Void)?
|
|
var languagesChanged: (() -> Void)?
|
|
var inputPlaceholder: String { "Enter \(languageName(sourceLanguage)) text to translate…" }
|
|
var inputAccessibilityLabel: String { "\(languageName(sourceLanguage)) text to translate into \(languageName(targetLanguage))" }
|
|
private static let languages = Locale.LanguageCode.isoLanguageCodes.map(\.identifier).filter { $0.count == 2 }
|
|
.sorted { languageName($0).localizedCaseInsensitiveCompare(languageName($1)) == .orderedAscending }
|
|
private static func languageName(_ code: String) -> String {
|
|
Locale(identifier: "en").localizedString(forLanguageCode: code) ?? code
|
|
}
|
|
private func languageName(_ code: String) -> String { Self.languageName(code) }
|
|
private var usesMyMemory: Bool
|
|
private let output = NSTextView()
|
|
private let settingsButton = NSButton(title: "Open Translate Settings", target: nil, action: nil)
|
|
private let status = NSTextField(wrappingLabelWithString: "")
|
|
private let translateButton = NSButton(title: "Translate", target: nil, action: nil)
|
|
private var task: Task<Void, Never>?
|
|
var input: (() -> String)?
|
|
var closed: (() -> Void)?
|
|
var dismiss: (() -> Void)?
|
|
private let pasteboard: NSPasteboard
|
|
private let defaults: UserDefaults
|
|
private let credentialsStore: Credentials
|
|
private let myMemory: @MainActor (String, String, String) async throws -> String
|
|
private let openAI: @MainActor (String, String, String, String) async throws -> String
|
|
|
|
init(
|
|
defaults: UserDefaults = .standard,
|
|
credentials: Credentials = .keychain,
|
|
myMemory: @escaping @MainActor (String, String, String) async throws -> String = {
|
|
try await MyMemoryAPI.translate($0, sourceLanguage: $1, targetLanguage: $2)
|
|
},
|
|
openAI: @escaping @MainActor (String, String, String, String) async throws -> String = {
|
|
try await TranslationAPI.translate($0, apiKey: $3, sourceLanguage: $1, targetLanguage: $2)
|
|
},
|
|
pasteboard: NSPasteboard = .general
|
|
) {
|
|
self.defaults = defaults
|
|
self.credentialsStore = credentials
|
|
self.myMemory = myMemory
|
|
self.openAI = openAI
|
|
self.pasteboard = pasteboard
|
|
usesMyMemory = defaults.string(forKey: "translationProvider") != "openai"
|
|
sourceLanguage = defaults.string(forKey: "translationSourceLanguage")
|
|
.flatMap { Self.languages.contains($0) ? $0 : nil } ?? "en"
|
|
targetLanguage = defaults.string(forKey: "translationTargetLanguage")
|
|
.flatMap { Self.languages.contains($0) ? $0 : nil } ?? "ja"
|
|
super.init(nibName: nil, bundle: nil)
|
|
}
|
|
|
|
required init?(coder: NSCoder) { fatalError("Conjure creates Translate programmatically") }
|
|
|
|
override func loadView() {
|
|
view = NSView()
|
|
providerButton.title = usesMyMemory ? "MyMemory (no key)" : "OpenAI · gpt-6-luna"
|
|
providerButton.setAccessibilityLabel("Translation provider")
|
|
providerButton.target = self
|
|
providerButton.action = #selector(showProviders)
|
|
providerButton.keyEquivalent = "p"
|
|
providerButton.keyEquivalentModifierMask = .command
|
|
providerButton.toolTip = "Choose translation provider (Command-P)"
|
|
providerButton.isBordered = false
|
|
providerButton.font = .systemFont(ofSize: 12, weight: .medium)
|
|
providerButton.contentTintColor = SunsetPalette.text
|
|
let chevron = NSButton(image: NSImage(systemSymbolName: "chevron.down", accessibilityDescription: nil)!,
|
|
target: self, action: #selector(showProviders))
|
|
chevron.isBordered = false
|
|
chevron.contentTintColor = SunsetPalette.secondaryText
|
|
chevron.setAccessibilityElement(false)
|
|
stylePill(providerControl)
|
|
for control in [providerButton, chevron] {
|
|
control.translatesAutoresizingMaskIntoConstraints = false
|
|
providerControl.addSubview(control)
|
|
}
|
|
NSLayoutConstraint.activate([
|
|
providerControl.heightAnchor.constraint(equalToConstant: 36),
|
|
providerButton.leadingAnchor.constraint(equalTo: providerControl.leadingAnchor, constant: 14),
|
|
providerButton.centerYAnchor.constraint(equalTo: providerControl.centerYAnchor),
|
|
chevron.leadingAnchor.constraint(equalTo: providerButton.trailingAnchor, constant: 8),
|
|
chevron.centerYAnchor.constraint(equalTo: providerControl.centerYAnchor),
|
|
chevron.trailingAnchor.constraint(equalTo: providerControl.trailingAnchor, constant: -12),
|
|
chevron.widthAnchor.constraint(equalToConstant: 10),
|
|
])
|
|
languageButton.target = self
|
|
languageButton.action = #selector(showActions)
|
|
languageButton.isBordered = false
|
|
languageButton.font = .systemFont(ofSize: 12, weight: .medium)
|
|
languageButton.contentTintColor = SunsetPalette.text
|
|
languageButton.toolTip = "Change or swap translation languages (Command-K)"
|
|
languageButton.setAccessibilityLabel("Translation languages")
|
|
actionsButton.target = self
|
|
actionsButton.action = #selector(showActions)
|
|
actions.closed = { [weak self] in
|
|
self?.translateButton.keyEquivalent = "\r"
|
|
self?.focusSearch?()
|
|
}
|
|
actions.changeSourceLanguage = { [weak self] in self?.chooseLanguage(source: true) }
|
|
actions.changeTargetLanguage = { [weak self] in self?.chooseLanguage(source: false) }
|
|
actions.swapLanguages = { [weak self] in self?.swapLanguages() }
|
|
actions.selectProvider = { [weak self] in self?.selectProvider(openAI: $0) }
|
|
settingsButton.target = self
|
|
settingsButton.action = #selector(openSettings)
|
|
settingsButton.isHidden = true
|
|
NotificationCenter.default.addObserver(self, selector: #selector(credentialsChanged),
|
|
name: Notification.Name("ConjureTranslationCredentialsChanged"), object: nil)
|
|
translateButton.target = self
|
|
translateButton.action = #selector(performPrimaryAction)
|
|
translateButton.toolTip = "Translate the input (Return). Escape dismisses Conjure."
|
|
let actionPill = ActionPill(primaryButton: translateButton, actionsButton: actionsButton)
|
|
let footer = NSStackView(views: [languageButton, status, settingsButton, NSView(), actionPill])
|
|
footer.orientation = .horizontal
|
|
footer.alignment = .centerY
|
|
footer.spacing = 12
|
|
footer.translatesAutoresizingMaskIntoConstraints = false
|
|
view.addSubview(footer)
|
|
output.isEditable = false
|
|
output.isSelectable = true
|
|
output.drawsBackground = false
|
|
output.textColor = SunsetPalette.text
|
|
output.font = .systemFont(ofSize: 20)
|
|
output.setAccessibilityLabel("\(languageName(targetLanguage)) translation")
|
|
let scroll = NSScrollView()
|
|
scroll.documentView = output
|
|
scroll.hasVerticalScroller = true
|
|
scroll.drawsBackground = false
|
|
output.isVerticallyResizable = true
|
|
output.autoresizingMask = [.width]
|
|
output.textContainer?.widthTracksTextView = true
|
|
status.font = .systemFont(ofSize: 12)
|
|
status.setAccessibilityLabel("Translation status and errors")
|
|
status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
scroll.translatesAutoresizingMaskIntoConstraints = false
|
|
view.addSubview(scroll)
|
|
NSLayoutConstraint.activate([
|
|
scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
|
scroll.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
|
|
scroll.topAnchor.constraint(equalTo: view.topAnchor),
|
|
scroll.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: -12),
|
|
footer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
|
footer.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8),
|
|
footer.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -8),
|
|
footer.heightAnchor.constraint(equalToConstant: 36),
|
|
scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 100),
|
|
])
|
|
inputChanged()
|
|
}
|
|
|
|
@objc private func openSettings() {
|
|
NSApp.sendAction(NSSelectorFromString("showSettings:"), to: NSApp.delegate, from: self)
|
|
}
|
|
|
|
@objc private func credentialsChanged() { inputChanged() }
|
|
|
|
private func stylePill(_ pill: NSView) {
|
|
pill.wantsLayer = true
|
|
pill.layer?.cornerRadius = 18
|
|
pill.layer?.borderWidth = 0.5
|
|
pill.layer?.borderColor = SunsetPalette.accent.withAlphaComponent(0.35).cgColor
|
|
pill.layer?.backgroundColor = SunsetPalette.background.withAlphaComponent(0.25).cgColor
|
|
}
|
|
|
|
@objc func showProviders() {
|
|
actions.showProviders(selectedOpenAI: !usesMyMemory, relativeTo: providerButton)
|
|
translateButton.keyEquivalent = actions.isShown ? "" : "\r"
|
|
}
|
|
|
|
@objc func showActions() {
|
|
actions.showTranslation(relativeTo: actionsButton)
|
|
translateButton.keyEquivalent = actions.isShown ? "" : "\r"
|
|
}
|
|
|
|
func handle(_ selector: Selector) -> Bool {
|
|
if actions.isShown { return actions.handle(selector) }
|
|
guard selector == #selector(NSResponder.insertNewline(_:)) else { return false }
|
|
performPrimaryAction()
|
|
return true
|
|
}
|
|
|
|
func handleControlKey(_ key: String) -> Bool {
|
|
guard actions.isShown, key == "j" || key == "k" else { return false }
|
|
actions.moveSelection(by: key == "j" ? 1 : -1)
|
|
return true
|
|
}
|
|
|
|
private func chooseLanguage(source: Bool) {
|
|
let menu = NSMenu(title: source ? "Source language" : "Target language")
|
|
for code in Self.languages {
|
|
let item = NSMenuItem(title: languageName(code), action: #selector(languageSelected(_:)), keyEquivalent: "")
|
|
item.target = self
|
|
item.representedObject = code
|
|
item.tag = source ? 0 : 1
|
|
item.state = code == (source ? sourceLanguage : targetLanguage) ? .on : .off
|
|
menu.addItem(item)
|
|
}
|
|
menu.popUp(positioning: menu.items.first { $0.state == .on },
|
|
at: NSPoint(x: 0, y: languageButton.bounds.maxY), in: languageButton)
|
|
}
|
|
|
|
@objc private func languageSelected(_ item: NSMenuItem) {
|
|
guard let code = item.representedObject as? String else { return }
|
|
setLanguages(source: item.tag == 0 ? code : sourceLanguage,
|
|
target: item.tag == 1 ? code : targetLanguage)
|
|
}
|
|
|
|
func setLanguages(source: String, target: String) {
|
|
guard Self.languages.contains(source), Self.languages.contains(target) else { return }
|
|
sourceLanguage = source
|
|
targetLanguage = target
|
|
defaults.set(source, forKey: "translationSourceLanguage")
|
|
defaults.set(target, forKey: "translationTargetLanguage")
|
|
inputChanged()
|
|
languagesChanged?()
|
|
}
|
|
|
|
func swapLanguages() { setLanguages(source: targetLanguage, target: sourceLanguage) }
|
|
|
|
func close() {
|
|
actions.close(animated: false)
|
|
cancelRequest()
|
|
closed?()
|
|
}
|
|
|
|
@objc func goBack() {
|
|
if actions.isShown { actions.close() } else { close() }
|
|
}
|
|
|
|
func inputChanged() {
|
|
cancelRequest()
|
|
output.string = ""
|
|
let isEmpty = (input?() ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
translateButton.title = isEmpty ? "Swap languages" : "Translate"
|
|
translateButton.toolTip = isEmpty
|
|
? "Swap source and target languages (Return)."
|
|
: "Translate the input (Return). Escape dismisses Conjure."
|
|
settingsButton.isHidden = true
|
|
languageButton.title = "\(languageName(sourceLanguage)) → \(languageName(targetLanguage))"
|
|
output.setAccessibilityLabel("\(languageName(targetLanguage)) translation")
|
|
setStatus("")
|
|
}
|
|
|
|
func selectProvider(openAI: Bool) {
|
|
let newUsesMyMemory = !openAI
|
|
guard newUsesMyMemory != usesMyMemory else { return }
|
|
usesMyMemory = newUsesMyMemory
|
|
providerButton.title = usesMyMemory ? "MyMemory (no key)" : "OpenAI · gpt-6-luna"
|
|
defaults.set(openAI ? "openai" : "mymemory", forKey: "translationProvider")
|
|
inputChanged()
|
|
}
|
|
|
|
private func cancelRequest() {
|
|
task?.cancel()
|
|
task = nil
|
|
translateButton.isEnabled = true
|
|
}
|
|
|
|
private func setStatus(_ message: String, isError: Bool = false) {
|
|
status.stringValue = message
|
|
status.textColor = isError ? .systemRed : SunsetPalette.secondaryText
|
|
}
|
|
|
|
@objc func performPrimaryAction() {
|
|
guard task == nil else { return }
|
|
if (input?() ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
swapLanguages()
|
|
return
|
|
}
|
|
guard !output.string.isEmpty else { translate(); return }
|
|
pasteboard.clearContents()
|
|
if pasteboard.setString(output.string, forType: .string) {
|
|
dismiss?()
|
|
} else {
|
|
setStatus("Could not copy the translation. Try again.", isError: true)
|
|
}
|
|
}
|
|
|
|
@objc func translate() {
|
|
guard task == nil else { return }
|
|
let text = input?() ?? ""
|
|
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
setStatus("Enter \(languageName(sourceLanguage)) text in the search bar.", isError: true)
|
|
return
|
|
}
|
|
let myMemory = usesMyMemory
|
|
var key = ""
|
|
if !myMemory {
|
|
do {
|
|
guard let savedKey = try credentialsStore.load() else {
|
|
settingsButton.isHidden = false
|
|
setStatus("Save your OpenAI API key in Settings first.", isError: true)
|
|
return
|
|
}
|
|
key = savedKey
|
|
} catch {
|
|
settingsButton.isHidden = false
|
|
setStatus(error.localizedDescription, isError: true)
|
|
return
|
|
}
|
|
}
|
|
output.string = ""
|
|
setStatus("Translating…")
|
|
translateButton.isEnabled = false
|
|
let myMemoryTranslate = self.myMemory
|
|
let openAITranslate = self.openAI
|
|
let source = sourceLanguage
|
|
let target = targetLanguage
|
|
task = Task { [weak self] in
|
|
do {
|
|
let translation = try await myMemory
|
|
? myMemoryTranslate(text, source, target)
|
|
: openAITranslate(text, source, target, key)
|
|
guard !Task.isCancelled, let self else { return }
|
|
self.output.string = translation
|
|
self.setStatus("")
|
|
self.translateButton.title = "Copy"
|
|
self.translateButton.toolTip = "Copy the translation and close Conjure (Return)."
|
|
} catch {
|
|
guard !Task.isCancelled, let self else { return }
|
|
self.settingsButton.isHidden = myMemory
|
|
self.setStatus("Translation failed: \(error.localizedDescription)", isError: true)
|
|
}
|
|
guard let self else { return }
|
|
self.task = nil
|
|
self.translateButton.isEnabled = true
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
struct Credentials {
|
|
var save: (String) throws -> Void
|
|
var load: () throws -> String?
|
|
var forget: () throws -> Void
|
|
|
|
private static var query: [String: Any] {
|
|
[kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: "Conjure.OpenAI",
|
|
kSecAttrAccount as String: "translation"]
|
|
}
|
|
|
|
static let keychain = Credentials(
|
|
save: { key in
|
|
let data = Data(key.utf8)
|
|
var result = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary)
|
|
if result == errSecItemNotFound {
|
|
var item = query
|
|
item[kSecValueData as String] = data
|
|
item[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
|
|
result = SecItemAdd(item as CFDictionary, nil)
|
|
}
|
|
guard result == errSecSuccess else { throw KeychainError(code: result) }
|
|
},
|
|
load: {
|
|
var query = Self.query
|
|
query[kSecReturnData as String] = true
|
|
var item: CFTypeRef?
|
|
let result = SecItemCopyMatching(query as CFDictionary, &item)
|
|
if result == errSecItemNotFound { return nil }
|
|
guard result == errSecSuccess, let data = item as? Data,
|
|
let key = String(data: data, encoding: .utf8) else { throw KeychainError(code: result) }
|
|
return key
|
|
},
|
|
forget: {
|
|
let result = SecItemDelete(query as CFDictionary)
|
|
guard result == errSecSuccess || result == errSecItemNotFound else { throw KeychainError(code: result) }
|
|
}
|
|
)
|
|
}
|
|
|
|
private struct KeychainError: LocalizedError {
|
|
let code: OSStatus
|
|
var errorDescription: String? {
|
|
"Keychain error: \(SecCopyErrorMessageString(code, nil) as String? ?? String(code))"
|
|
}
|
|
}
|
|
}
|