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

        }
      },
      {
        "clipboard" : {

        }
      },
      {
        "network" : {
          "hosts" : [
            "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.11.0",
    "name" : "Translate",
    "summary" : "Translate the selected text, in your browser or in place with DeepL.",
    "surfaces" : [
      "panel",
      "settings"
    ],
    "triggers" : [
      {
        "launch" : {

        }
      }
    ],
    "version" : "1.0.0"
  },
  "publisher" : {
    "name" : "navbytes",
    "publicKey" : "4dpLtXEpFXdi+6cviY/KdYuUulJPbEmCxxIK0EEN2/w="
  },
  "signature" : "ng9mlm6LwWuyJqq747b1wEAr+0buYcSC67oXnbN546Nn0ua6T308STsN67b4xSEye0qFzpBAUjJZpKFr/a2XCQ==",
  "source" : "import StackbarKit\nimport Foundation\n\n/// Translates the text you selected, from the palette, a hotkey or\n/// Spotlight.\n///\n/// With no setup it opens Google Translate in your browser with the text\n/// filled in. With a DeepL API key (the free plan is enough) it translates\n/// in place, so you can paste the result over the selection or copy it.\n/// When an app doesn't share its selection, it uses 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.0.0\",\n        summary: \"Translate the selected text, in your browser or in place with DeepL.\",\n        surfaces: [.panel, .settings],\n        triggers: [.launch],\n        // The selection; the clipboard as a fallback and for pasting the\n        // result; DeepL's two API hosts (free and paid keys).\n        capabilities: [.context, .clipboard, .network(\"api-free.deepl.com\", \"api.deepl.com\"), .secret(name: \"DEEPL_API_KEY\")],\n        minimumStackbar: \"0.11.0\",\n        homepage: \"https://github.com/navbytes/stackbar-plugins/tree/main/Productivity/Translate\"\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    @Stored(\"engine\") var engine = \"Google Translate (browser)\"\n    @Stored(\"target\") var target = \"English\"\n    @Stored(\"second\") var second = \"German\"\n    @Stored(\"formality\") var formality = \"Default\"\n\n    var useDeepL: Bool { engine == \"DeepL\" }\n\n    /// The last translation, for the view.\n    var result: (source: String, text: String, detected: String?, target: 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    /// 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 deepL(text, to: language)\n        if name == nil, let detected = translated.detected, language.deepl.hasPrefix(detected) {\n            language = Self.language(second)\n            translated = try await deepL(text, to: language)\n        }\n        result = (text, translated.text, translated.detected, language.name)\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.useDeepL {\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 useDeepL {\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 DeepL\")\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(\"Add a DeepL API key in Settings to translate here instead.\").style(.caption)\n            }\n        } else {\n            ContentUnavailableView(\"Translating…\", symbol: \"character.bubble\")\n        }\n    }\n\n    // MARK: Panel and settings\n\n    var panel: ViewContent {\n        Text(\"Select text in any app, then run Translate Selection from the palette, or give it a hotkey in Settings > Commands.\").style(.caption)\n        LabeledContent(\"Engine\", value: engine)\n        LabeledContent(\"Into\", value: \"\\(target), or \\(second) when the text is already in \\(target)\")\n    }\n\n    var settings: ViewContent {\n        Section(\"Engine\") {\n            Picker(\"Translate with\", options: [\"Google Translate (browser)\", \"DeepL\"], selection: engine) { self.engine = $0 }\n            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) { self.formality = $0 }\n            } else {\n                Text(\"Opens translate.google.com with the text filled in. Nothing is sent until you look at it there.\").style(.caption)\n            }\n        }\n        Section(\"Languages\") {\n            Picker(\"Into\", options: Self.languages.map(\\.name), selection: target) { self.target = $0 }\n            Picker(\"Or, when it's already in that\", options: Self.languages.map(\\.name), selection: second) { self.second = $0 }\n        }\n    }\n}\n",
  "sourceDigest" : "26b1e235ab0728e4308b3d8d4f9922da6d089c3f7028e8a5c893a8a887527e9e"
}