{
  "format" : 1,
  "manifest" : {
    "accent" : "teal",
    "capabilities" : [
      {
        "context" : {

        }
      },
      {
        "clipboard" : {

        }
      },
      {
        "network" : {
          "hosts" : [
            "translate.googleapis.com",
            "api-free.deepl.com",
            "api.deepl.com"
          ]
        }
      },
      {
        "secret" : {
          "name" : "DEEPL_API_KEY"
        }
      }
    ],
    "homepage" : "https://github.com/navbytes/stackbar-plugins/tree/main/Productivity/Translate",
    "id" : "io.navbytes.stackbar.plugins.translate",
    "minimumStackbar" : "0.17.0",
    "name" : "Translate",
    "summary" : "Translate the selected text in place, on your Mac or with Google or DeepL.",
    "surfaces" : [
      "panel",
      "settings"
    ],
    "symbol" : "character.bubble.fill",
    "triggers" : [
      {
        "launch" : {

        }
      }
    ],
    "version" : "1.4.0"
  },
  "publisher" : {
    "name" : "navbytes",
    "publicKey" : "4dpLtXEpFXdi+6cviY/KdYuUulJPbEmCxxIK0EEN2/w="
  },
  "signature" : "W+ZDIoSznro3KoM0+HVl/8vp3Ix05S6eDtJbPGjjEveSzpG5V5jVtB6NoQFNf0xIQ7TqMSz/JmQIHBHj2cZlCQ==",
  "source" : "import StackbarKit\nimport Foundation\n#if canImport(Translation)\nimport NaturalLanguage\nimport Translation\n#endif\n\n/// Translates the text you selected, from the palette, a hotkey or\n/// Spotlight.\n///\n/// With no setup it translates in place: on your Mac with Apple's\n/// translation when the language pair is downloaded, and with Google\n/// Translate otherwise. DeepL (with an API key) and opening Google Translate\n/// in the browser are the other engines. Paste the result over the\n/// selection or copy it. When an app doesn't share its selection, it uses\n/// what you last copied.\n@main\nfinal class Translate: StackbarPlugin {\n    static let manifest = Manifest(\n        id: \"io.navbytes.stackbar.plugins.translate\",\n        name: \"Translate\",\n        version: \"1.4.0\",\n        summary: \"Translate the selected text in place, on your Mac or with Google or DeepL.\",\n        surfaces: [.panel, .settings],\n        triggers: [.launch],\n        // The selection; the clipboard as a fallback and for pasting the\n        // result; Google Translate's keyless endpoint, used when the Mac\n        // can't translate the pair itself; DeepL's two API hosts (free and\n        // paid keys).\n        capabilities: [.context, .clipboard, .network(\"translate.googleapis.com\", \"api-free.deepl.com\", \"api.deepl.com\"), .secret(name: \"DEEPL_API_KEY\")],\n        minimumStackbar: \"0.17.0\",\n        homepage: \"https://github.com/navbytes/stackbar-plugins/tree/main/Productivity/Translate\",\n        accent: .teal,\n        symbol: \"character.bubble.fill\"\n    )\n\n    // MARK: Previews\n\n    static let previews: [PreviewScene<Translate>] = [\n        PreviewScene(\"hero\", \"Translate on your Mac, without leaving the app you're in\") { plugin in\n            plugin.result = (source: \"Können wir das Meeting auf Donnerstag verschieben? Ich schicke dir vorher die Folien.\",\n                             text: \"Can we move the meeting to Thursday? I'll send you the slides beforehand.\",\n                             detected: \"German\", target: \"English\", by: \"On your Mac\")\n        },\n    ]\n\n    struct Language: Equatable {\n        var name: String\n        var google: String\n        var deepl: String\n    }\n\n    static let languages: [Language] = [\n        .init(name: \"English\", google: \"en\", deepl: \"EN-US\"),\n        .init(name: \"English (UK)\", google: \"en\", deepl: \"EN-GB\"),\n        .init(name: \"German\", google: \"de\", deepl: \"DE\"),\n        .init(name: \"French\", google: \"fr\", deepl: \"FR\"),\n        .init(name: \"Spanish\", google: \"es\", deepl: \"ES\"),\n        .init(name: \"Italian\", google: \"it\", deepl: \"IT\"),\n        .init(name: \"Portuguese (Brazil)\", google: \"pt\", deepl: \"PT-BR\"),\n        .init(name: \"Portuguese\", google: \"pt-PT\", deepl: \"PT-PT\"),\n        .init(name: \"Dutch\", google: \"nl\", deepl: \"NL\"),\n        .init(name: \"Polish\", google: \"pl\", deepl: \"PL\"),\n        .init(name: \"Swedish\", google: \"sv\", deepl: \"SV\"),\n        .init(name: \"Danish\", google: \"da\", deepl: \"DA\"),\n        .init(name: \"Finnish\", google: \"fi\", deepl: \"FI\"),\n        .init(name: \"Czech\", google: \"cs\", deepl: \"CS\"),\n        .init(name: \"Turkish\", google: \"tr\", deepl: \"TR\"),\n        .init(name: \"Ukrainian\", google: \"uk\", deepl: \"UK\"),\n        .init(name: \"Russian\", google: \"ru\", deepl: \"RU\"),\n        .init(name: \"Indonesian\", google: \"id\", deepl: \"ID\"),\n        .init(name: \"Japanese\", google: \"ja\", deepl: \"JA\"),\n        .init(name: \"Korean\", google: \"ko\", deepl: \"KO\"),\n        .init(name: \"Chinese (Simplified)\", google: \"zh-CN\", deepl: \"ZH-HANS\"),\n        .init(name: \"Chinese (Traditional)\", google: \"zh-TW\", deepl: \"ZH-HANT\"),\n    ]\n\n    static let maxCharacters = 5_000\n\n    /// \"Automatic\" (on the Mac, else Google), \"DeepL\" or \"Browser\".\n    @Stored(\"engine\") var storedEngine = \"Automatic\"\n    @Stored(\"target\") var target = \"English\"\n    @Stored(\"second\") var second = \"German\"\n    @Stored(\"formality\") var formality = \"Default\"\n\n    var engine: String {\n        // Before 1.3 the only keyless engine opened the browser; those users get the new default.\n        [\"DeepL\", \"Browser\"].contains(storedEngine) ? storedEngine : \"Automatic\"\n    }\n    var useDeepL: Bool { engine == \"DeepL\" }\n    /// Whether the translation shows here, rather than in the browser.\n    var inPlace: Bool { engine != \"Browser\" }\n\n    /// The last translation, for the view. `by` names the engine that did it.\n    var result: (source: String, text: String, detected: String?, target: String, by: String)?\n    var problem: String?\n\n    static func language(_ name: String?) -> Language {\n        languages.first { $0.name == name } ?? languages[0]\n    }\n\n    /// The selection, or the clipboard when the app doesn't share it.\n    func input(_ invocation: CommandInvocation) async throws -> String? {\n        if !invocation.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return invocation.text }\n        if let selected = invocation.context?.selectedText, !selected.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return selected }\n        if let copied = try await Stackbar.clipboard(), !copied.concealed, let text = copied.text, !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return text }\n        return nil\n    }\n\n    static func googleURL(_ text: String, to language: Language) -> String {\n        var allowed = CharacterSet.urlQueryAllowed\n        allowed.remove(charactersIn: \"&+=?#\")\n        let encoded = String(text.prefix(maxCharacters)).addingPercentEncoding(withAllowedCharacters: allowed) ?? \"\"\n        return \"https://translate.google.com/?sl=auto&tl=\\(language.google)&op=translate&text=\\(encoded)\"\n    }\n\n    /// The language as Apple's Translation names it.\n    static func appleCode(_ language: Language) -> String {\n        switch language.deepl {\n        case \"EN-US\": return \"en-US\"\n        case \"EN-GB\": return \"en-GB\"\n        case \"PT-BR\": return \"pt-BR\"\n        case \"ZH-HANS\": return \"zh-Hans\"\n        case \"ZH-HANT\": return \"zh-Hant\"\n        default: return language.google\n        }\n    }\n\n    /// Translates on the Mac, with nothing sent anywhere, when Apple's\n    /// Translation has this language pair downloaded. Nil means it can't\n    /// (another OS, an unknown source language, or the pair isn't installed).\n    func onDevice(_ text: String, to language: Language) async throws -> (text: String, detected: String?)? {\n        #if canImport(Translation)\n        let recognizer = NLLanguageRecognizer()\n        recognizer.processString(text)\n        guard let detected = recognizer.dominantLanguage?.rawValue else { return nil }\n        let source = Locale.Language(identifier: detected)\n        let target = Locale.Language(identifier: Self.appleCode(language))\n        guard await LanguageAvailability().status(from: source, to: target) == .installed else { return nil }\n        let session = TranslationSession(installedSource: source, target: target)\n        let response = try await session.translate(text)\n        return (response.targetText, detected)\n        #else\n        return nil\n        #endif\n    }\n\n    /// Translates with Google Translate's keyless endpoint, the one its\n    /// browser extension uses. It answers nested arrays: the translated\n    /// pieces first, the detected language third.\n    func google(_ text: String, to language: Language) async throws -> (text: String, detected: String?) {\n        var allowed = CharacterSet.urlQueryAllowed\n        allowed.remove(charactersIn: \"&+=?#\")\n        let query = String(text.prefix(Self.maxCharacters)).addingPercentEncoding(withAllowedCharacters: allowed) ?? \"\"\n        let response = try await Stackbar.http(HTTPRequest(url: \"https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=\\(language.google)&dt=t&q=\\(query)\"))\n        guard (200..<300).contains(response.status) else {\n            throw HostError(response.status == 429 ? \"Google Translate is busy; try again in a moment\" : \"Google Translate answered \\(response.status)\")\n        }\n        guard let reply = try JSONSerialization.jsonObject(with: response.body) as? [Any], let pieces = reply.first as? [Any] else {\n            throw HostError(\"Google Translate sent something unexpected\")\n        }\n        let translated = pieces.compactMap { ($0 as? [Any])?.first as? String }.joined()\n        guard !translated.isEmpty else { throw HostError(\"Google Translate sent no translation\") }\n        return (translated, reply.count > 2 ? reply[2] as? String : nil)\n    }\n\n    /// The engine's translation, and which engine it was.\n    func run(_ text: String, to language: Language) async throws -> (text: String, detected: String?, by: String) {\n        if useDeepL {\n            let result = try await deepL(text, to: language)\n            return (result.text, result.detected, \"DeepL\")\n        }\n        // Apple's translation can fail for reasons of its own; Google is the fallback.\n        if let local = try? await onDevice(text, to: language) { return (local.text, local.detected, \"your Mac\") }\n        let result = try await google(text, to: language)\n        return (result.text, result.detected, \"Google Translate\")\n    }\n\n    /// Whether `detected` (a DeepL, Google or Apple code) is `language`.\n    static func same(_ detected: String?, _ language: Language) -> Bool {\n        guard let detected = detected?.lowercased(), !detected.isEmpty else { return false }\n        let code = language.google.lowercased()\n        let base = detected.split(separator: \"-\").first.map(String.init) ?? detected\n        return code == detected || code.hasPrefix(base + \"-\") || code == base\n    }\n\n    /// Translates with DeepL. A key ending in `:fx` is a free-plan key.\n    func deepL(_ text: String, to language: Language) async throws -> (text: String, detected: String?) {\n        guard let key = try await Stackbar.secret(\"DEEPL_API_KEY\"), !key.isEmpty else {\n            throw HostError(\"Add your DeepL API key (DEEPL_API_KEY), or switch to Google Translate in Settings\")\n        }\n        let host = key.hasSuffix(\":fx\") ? \"api-free.deepl.com\" : \"api.deepl.com\"\n        var body: [String: Any] = [\"text\": [String(text.prefix(Self.maxCharacters))], \"target_lang\": language.deepl]\n        if formality != \"Default\" { body[\"formality\"] = formality == \"Formal\" ? \"prefer_more\" : \"prefer_less\" }\n        let response = try await Stackbar.http(HTTPRequest(url: \"https://\\(host)/v2/translate\", method: \"POST\", headers: [\n            \"Authorization\": \"DeepL-Auth-Key \\(key)\", \"Content-Type\": \"application/json\",\n        ], body: try JSONSerialization.data(withJSONObject: body)))\n        switch response.status {\n        case 200..<300: break\n        case 403: throw HostError(\"DeepL rejected the key (403); check DEEPL_API_KEY\")\n        case 456: throw HostError(\"Your DeepL quota for this month is used up\")\n        case 429: throw HostError(\"DeepL is busy; try again in a moment\")\n        default: throw HostError(\"DeepL answered \\(response.status)\")\n        }\n        struct Reply: Decodable {\n            struct Item: Decodable { let detected_source_language: String?; let text: String }\n            let translations: [Item]\n        }\n        let reply = try JSONDecoder().decode(Reply.self, from: response.body)\n        guard let first = reply.translations.first else { throw HostError(\"DeepL sent no translation\") }\n        return (first.text, first.detected_source_language)\n    }\n\n    /// Translating into the language the text is already in switches to\n    /// the second language, so one command goes both ways.\n    func translate(_ text: String, to name: String?) async throws {\n        var language = Self.language(name ?? target)\n        var translated = try await run(text, to: language)\n        if name == nil, Self.same(translated.detected, language) {\n            language = Self.language(second)\n            translated = try await run(text, to: language)\n        }\n        result = (text, translated.text, translated.detected, language.name, translated.by)\n    }\n\n    // MARK: Commands\n\n    var commands: [Command] {\n        Command(\"translate\", title: \"Translate Selection\", subtitle: \"The selected text, or what you copied\", symbol: \"character.bubble\", keywords: [\"translate\", \"deepl\", \"google\", \"language\"])\n            .argument(\"to\", placeholder: \"Into\", required: false, options: Self.languages.map(\\.name))\n            .view(search: { invocation in\n                self.problem = nil\n                guard let text = try await self.input(invocation) else {\n                    self.result = nil\n                    self.problem = \"Select some text first (or copy it), or type it here\"\n                    return\n                }\n                if self.inPlace {\n                    do { try await self.translate(text, to: invocation[\"to\"]) } catch { self.problem = \"\\(error)\" }\n                } else {\n                    self.result = (text, \"\", nil, invocation[\"to\"] ?? self.target, \"\")\n                }\n            }) { invocation in\n                self.view(into: invocation[\"to\"])\n            }\n        Command(\"open-google\", title: \"Translate Selection in Google Translate\", symbol: \"globe\", keywords: [\"translate\", \"google\"]) { invocation in\n            guard let text = try await self.input(invocation) else { return .failure(\"Select some text first, or copy it\") }\n            return .open(url: Self.googleURL(text, to: Self.language(invocation[\"to\"] ?? self.target)), app: nil)\n        }\n        .argument(\"to\", placeholder: \"Into\", required: false, options: Self.languages.map(\\.name))\n    }\n\n    @ViewBuilder func view(into name: String?) -> ViewContent {\n        if let problem {\n            ContentUnavailableView(\"Can't translate\", symbol: \"character.bubble\", message: problem)\n        } else if let result {\n            if inPlace {\n                List {\n                    Row(result.text, subtitle: \"\\(result.detected.map { \"From \\($0) \" } ?? \"\")into \\(result.target)\", symbol: \"character.bubble\", key: \"translation\")\n                        .preview(text: result.text, caption: \"\\(result.source.count) characters translated by \\(result.by)\")\n                        .action(\"Paste\") {\n                            try await Stackbar.paste(.text(result.text), restoringClipboard: true)\n                        }\n                        .action(\"Copy\", shortcut: \"cmd+c\") { Stackbar.copy(result.text) }\n                        .action(\"Open in Google Translate\", shortcut: \"cmd+o\") {\n                            Stackbar.open(Self.googleURL(result.source, to: Self.language(result.target)))\n                        }\n                }\n            } else {\n                Text(String(result.source.prefix(400))).style(.body)\n                Button(\"Open in Google Translate (\\(result.target))\", symbol: \"globe\", role: .accent) {\n                    Stackbar.open(Self.googleURL(result.source, to: Self.language(result.target)))\n                }\n                Text(\"Choose Automatic in Settings to translate here instead.\").style(.caption)\n            }\n        } else {\n            ContentUnavailableView(inPlace ? \"Translating…\" : \"Reading the selection…\", symbol: \"character.bubble\")\n        }\n    }\n\n    // MARK: Panel and settings\n\n    let theme = Theme.teal\n\n    /// Translates what was typed or picked up in the panel: in place with\n    /// DeepL, or by opening Google Translate.\n    func translateHere(_ text: String) async {\n        let text = text.trimmingCharacters(in: .whitespacesAndNewlines)\n        guard !text.isEmpty else { return }\n        problem = nil\n        if inPlace {\n            do { try await translate(text, to: nil) } catch { problem = \"\\(error)\" }\n        } else {\n            Stackbar.open(Self.googleURL(text, to: Self.language(target)))\n        }\n    }\n\n    var panel: ViewContent {\n        PanelHeader(\"Translate\", subtitle: useDeepL ? \"DeepL\" + (formality == \"Default\" ? \"\" : \" · \\(formality.lowercased())\") : inPlace ? \"On your Mac, or Google Translate\" : \"Google Translate · opens in your browser\",\n                    symbol: \"character.bubble.fill\", theme: theme)\n        HStack(spacing: Space.s) {\n            Pill(\"Detect\", theme: .neutral)\n            Image(systemName: \"arrow.right\").font(size: 11, weight: .semibold).foreground(.role(.secondary))\n            Picker(\"Into\", options: Self.languages.map(\\.name), selection: target) { self.target = $0 }\n            Spacer()\n            IconButton(\"arrow.left.arrow.right\", label: \"Swap languages\", theme: theme, prominence: .tinted) {\n                (self.target, self.second) = (self.second, self.target)\n            }\n        }\n        TextField(\"Type or paste, then Return\") { text in await self.translateHere(text) }\n        if useDeepL, problem != nil, result == nil {\n            Text(\"Or switch to Automatic in Settings.\").role(.secondary).typography(.caption)\n        }\n        HStack(spacing: Space.xs + 2) {\n            Chip(\"Translate clipboard\", symbol: \"doc.on.clipboard\", theme: theme) {\n                guard let copied = try await Stackbar.clipboard(), !copied.concealed, let text = copied.text else {\n                    self.problem = \"The clipboard has no text\"\n                    return\n                }\n                await self.translateHere(text)\n            }\n            Chip(\"Translate selection\", symbol: \"text.cursor\", theme: theme) {\n                guard let text = try await Stackbar.context().selectedText, !text.isEmpty else {\n                    self.problem = \"Nothing is selected in the app you were in\"\n                    return\n                }\n                await self.translateHere(text)\n            }\n            Spacer()\n        }\n        if let problem {\n            Banner(problem)\n        } else if let result, inPlace {\n            // Two cards: what went in, quiet, and what came out, lit.\n            VStack(spacing: Space.xxs) {\n                Text((result.detected.map { Self.languageName($0) + \" · detected\" } ?? \"Original\").uppercased() + \" · BY \\(result.by.uppercased())\")\n                    .role(.secondary).typography(.overline)\n                Text(Self.clipped(result.source, 220)).role(.secondary).typography(.body)\n            }\n            .padding(Space.m)\n            .frame(fillWidth: true, alignment: .leading)\n            .background(.surface, in: .roundedRectangle(cornerRadius: Radius.large))\n            HeroCard(theme: theme, look: .glow, spacing: Space.s) {\n                HStack(spacing: Space.xs) {\n                    Image(systemName: \"sparkles\").font(size: 10, weight: .bold).foreground(theme.textPaint).symbolEffect(.bounce)\n                    Text(result.target.uppercased()).typography(.overline).foreground(theme.textPaint)\n                }\n                Text(result.text).typography(Typography(size: 17, weight: .semibold))\n                HStack(spacing: Space.s) {\n                    ActionButton(\"Paste\", symbol: \"doc.on.clipboard.fill\", theme: theme, prominent: true) {\n                        try await Stackbar.paste(.text(result.text), restoringClipboard: true)\n                    }\n                    ActionButton(\"Copy\", symbol: \"doc.on.doc\", theme: theme) { Stackbar.copy(result.text) }\n                    Spacer()\n                    IconButton(\"globe\", label: \"Open in Google Translate\", theme: theme) {\n                        Stackbar.open(Self.googleURL(result.source, to: Self.language(result.target)))\n                    }\n                }\n            }\n        }\n    }\n\n    /// The text cut to `limit` characters, with an ellipsis.\n    static func clipped(_ text: String, _ limit: Int) -> String {\n        text.count > limit ? String(text.prefix(limit - 1)) + \"…\" : text\n    }\n\n    /// DeepL's source code (\"DE\") as a name (\"German\").\n    static func languageName(_ code: String) -> String {\n        languages.first { $0.deepl.hasPrefix(code.uppercased()) || same(code, $0) }?.name.replacingOccurrences(of: \" (UK)\", with: \"\") ?? code\n    }\n\n    var settings: ViewContent {\n        Section(\"Engine\") {\n            Picker(\"Translate with\", options: [\"Automatic\", \"DeepL\", \"Browser\"], selection: engine, style: .segmented) { choice in\n                self.storedEngine = choice\n                self.result = nil\n                self.problem = nil\n            }\n            if engine == \"Automatic\" {\n                Text(\"Translates on your Mac when the language pair is downloaded (System Settings > General > Language & Region > Translation Languages), so nothing leaves it. Otherwise the text goes to Google Translate, with no account or key.\").style(.caption)\n            } else if useDeepL {\n                Text(\"DeepL needs an API key: the free plan at deepl.com/pro-api covers 500,000 characters a month. Stackbar asks for it the first time and keeps it in your Keychain.\").style(.caption)\n                Picker(\"Formality\", options: [\"Default\", \"Formal\", \"Informal\"], selection: formality, style: .segmented) { self.formality = $0 }\n            } else {\n                Text(\"Opens translate.google.com in your browser with the text filled in.\").style(.caption)\n            }\n        }\n        Section(\"Languages\") {\n            Picker(\"When it's already in \\(target), into\", options: Self.languages.map(\\.name), selection: second) { self.second = $0 }\n            Text(\"Pick the main language in the panel.\").style(.caption).role(.secondary)\n        }\n    }\n}\n",
  "sourceDigest" : "62a681e32313c32e6cdff5db2a7592ed10a1dd8b0fdf2469ebe792004268b3fb"
}