{
  "format" : 1,
  "manifest" : {
    "capabilities" : [
      {
        "openURLs" : {
          "schemes" : [
            "*"
          ]
        }
      },
      {
        "clipboard" : {

        }
      },
      {
        "context" : {

        }
      }
    ],
    "homepage" : "https://github.com/navbytes/stackbar-plugins/tree/main/Productivity/Quicklinks",
    "id" : "io.navbytes.stackbar.quicklinks",
    "minimumStackbar" : "0.11.0",
    "name" : "Quicklinks",
    "summary" : "Open links and folders by name, with placeholders filled in.",
    "surfaces" : [
      "panel",
      "settings"
    ],
    "triggers" : [
      {
        "launch" : {

        }
      }
    ],
    "version" : "1.0.1"
  },
  "publisher" : {
    "name" : "navbytes",
    "publicKey" : "4dpLtXEpFXdi+6cviY/KdYuUulJPbEmCxxIK0EEN2/w="
  },
  "signature" : "KaNVq2MxvaGWP7vjcHhwMQXQ1WZcrUsh69hSTH6G13hEt7AsEn16EgHSMyHfnobM1G+LLe12w18XzvIUL1cuDA==",
  "source" : "import StackbarKit\nimport Foundation\n\n/// Quicklinks: links and folders you open by name from the palette,\n/// Spotlight, a hotkey or a link, with placeholders filled in as you go.\n///\n/// Each quicklink becomes a command of its own. Its link is a template in\n/// Raycast's syntax: `https://github.com/search?q={argument name=\"query\"}`\n/// asks for a query, `{clipboard}` and `{selection}` take what you copied or\n/// selected, and `{date format=\"yyyy-MM-dd\"}` today's date. \"Search\n/// Quicklinks\" lists them all; \"Create Quicklink\" adds one. Raycast's\n/// exported quicklinks import as they are, and export the same way.\n@main\nfinal class Quicklinks: StackbarPlugin {\n    static let manifest = Manifest(\n        id: \"io.navbytes.stackbar.quicklinks\",\n        name: \"Quicklinks\",\n        version: \"1.0.1\",\n        summary: \"Open links and folders by name, with placeholders filled in.\",\n        surfaces: [.panel, .settings],\n        triggers: [.launch],\n        // Any link or folder, in the app each quicklink names; the clipboard\n        // and selection for {clipboard} and {selection}.\n        capabilities: [.openURLs(schemes: [\"*\"]), .clipboard, .context],\n        minimumStackbar: \"0.11.0\",\n        homepage: \"https://github.com/navbytes/stackbar-plugins/tree/main/Productivity/Quicklinks\"\n    )\n\n    struct Link: Codable, Equatable, Identifiable {\n        var id: UUID\n        var name: String\n        /// A template: a URL, a path, or text that becomes https://….\n        var link: String\n        /// A bundle id; the default app when nil.\n        var openWith: String?\n        var tags: [String] = []\n        var symbol: String?\n\n        var commandID: String { \"ql-\" + id.uuidString.lowercased() }\n        var template: Template? { try? Template(link) }\n    }\n\n    @Stored(\"quicklinks\") var links: [Link] = []\n    var draft = Draft()\n    var notice: String?\n\n    /// The create and edit form's errors, kept between its renders.\n    struct Draft {\n        var nameError: String?\n        var linkError: String?\n    }\n\n    // MARK: Links and paths\n\n    enum Kind { case web, path, app }\n\n    /// `~/x` and `/x` are paths; `scheme:` is a URL; anything else gets\n    /// `https://` in front, so `github.com` works.\n    static func kind(of link: String) -> Kind {\n        if link.hasPrefix(\"/\") || link.hasPrefix(\"~\") { return .path }\n        if let scheme = scheme(of: link) { return [\"http\", \"https\"].contains(scheme) ? .web : .app }\n        return .web\n    }\n\n    static func scheme(of link: String) -> String? {\n        guard let colon = link.firstIndex(of: \":\") else { return nil }\n        let scheme = link[..<colon]\n        guard let first = scheme.first, first.isLetter, scheme.allSatisfy({ $0.isLetter || $0.isNumber || \"+-.\".contains($0) }) else { return nil }\n        return scheme.lowercased()\n    }\n\n    static func target(_ rendered: String) -> String {\n        let trimmed = rendered.trimmingCharacters(in: .whitespacesAndNewlines)\n        switch kind(of: trimmed) {\n        case .path, .app: return trimmed\n        case .web: return scheme(of: trimmed) == nil ? \"https://\" + trimmed : trimmed\n        }\n    }\n\n    static func symbol(for link: Link) -> String {\n        if let symbol = link.symbol, !symbol.isEmpty { return symbol }\n        switch kind(of: link.link) {\n        case .web: return \"globe\"\n        case .path: return \"folder\"\n        case .app: return \"app.badge\"\n        }\n    }\n\n    static func host(of link: String) -> String? {\n        guard kind(of: link) == .web else { return nil }\n        let full = target(link)\n        guard let start = full.range(of: \"://\")?.upperBound else { return nil }\n        let rest = full[start...]\n        let host = rest.prefix { $0 != \"/\" && $0 != \"?\" && $0 != \"#\" && $0 != \"{\" }\n        return host.isEmpty ? nil : String(host)\n    }\n\n    /// Renders a link's template with what the user typed, copied and\n    /// selected. Values are URL-encoded for web and app links, not paths.\n    func resolve(_ link: Link, arguments: [String: String], context: InvocationContext?) async throws -> String {\n        let template = try Template(link.link)\n        var environment = Template.Environment(selection: context?.selectedText)\n        if template.uses(\"clipboard\") { environment.clipboard = try await Stackbar.clipboard()?.text }\n        let encoding: Template.Encoding = Self.kind(of: link.link) == .path ? .none : .url\n        return Self.target(try template.render(arguments, in: environment, encoding: encoding).text)\n    }\n\n    func open(_ link: Link, arguments: [String: String] = [:], context: InvocationContext? = nil) async throws {\n        try await Stackbar.open(try await resolve(link, arguments: arguments, context: context), with: link.openWith)\n    }\n\n    // MARK: Commands\n\n    var allTags: [String] {\n        Array(Set(links.flatMap(\\.tags))).sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }\n    }\n\n    var commands: [Command] {\n        for link in links { command(for: link) }\n        Command(\"search\", title: \"Search Quicklinks\", subtitle: \"Open, edit or copy a quicklink\", symbol: \"link\", keywords: [\"quicklink\", \"bookmark\", \"url\"])\n            .argument(\"tag\", placeholder: \"Tag\", required: false, default: \"All\", options: [\"All\"] + allTags)\n            .view { invocation in self.searchView(tag: invocation[\"tag\"]) }\n        Command(\"create\", title: \"Create Quicklink\", symbol: \"link.badge.plus\", keywords: [\"quicklink\", \"new\", \"add\"])\n            .argument(\"id\", placeholder: \"Quicklink\", required: false)\n            .stackbarOnly()\n            .view { invocation in self.form(editing: invocation[\"id\"].flatMap(UUID.init(uuidString:))) }\n        Command(\"import\", title: \"Import Quicklinks\", subtitle: \"From a Raycast export\", symbol: \"square.and.arrow.down\", keywords: [\"quicklink\", \"raycast\"]) { _ in\n            .message(try await self.importLinks())\n        }\n        .stackbarOnly()\n        Command(\"export\", title: \"Export Quicklinks\", subtitle: \"To a file Raycast reads too\", symbol: \"square.and.arrow.up\", keywords: [\"quicklink\", \"raycast\", \"backup\"]) { _ in\n            .message(try await self.exportLinks())\n        }\n        .stackbarOnly()\n    }\n\n    /// A quicklink's own command: its arguments come from its template.\n    func command(for link: Link) -> Command {\n        var command = Command(link.commandID, title: link.name, subtitle: link.link, symbol: Self.symbol(for: link), keywords: link.tags + [Self.host(of: link.link)].compactMap { $0 } + [\"quicklink\"]) { invocation in\n            do {\n                return .open(url: try await self.resolve(link, arguments: invocation.arguments, context: invocation.context), app: link.openWith)\n            } catch {\n                return .failure(\"\\(link.name): \\(error)\")\n            }\n        }\n        for argument in link.template?.arguments ?? [] {\n            command = command.argument(argument.name, placeholder: argument.placeholder, required: argument.required, default: argument.defaultValue, options: argument.options)\n        }\n        return command\n    }\n\n    @ViewBuilder func searchView(tag: String?) -> ViewContent {\n        let shown = links.filter { tag == nil || tag == \"All\" || $0.tags.contains(tag!) }\n            .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }\n        if links.isEmpty {\n            ContentUnavailableView(\"No quicklinks yet\", symbol: \"link\", message: \"Create one, or import a Raycast export from Settings.\")\n        } else {\n            List(searchable: true) {\n                ForEach(shown) { link in self.row(link) }\n            }\n        }\n    }\n\n    func row(_ link: Link) -> Row {\n        var row = Row(link.name, subtitle: link.link, symbol: Self.symbol(for: link), key: link.id.uuidString).appIcon(link.openWith)\n        for tag in link.tags { row = row.badge(tag) }\n        let needsInput = link.template?.arguments.contains(where: \\.required) ?? false\n        // With arguments to fill in, Return opens the quicklink's own\n        // command, which asks for them.\n        row = needsInput\n            ? row.navigate(\"Open\", to: link.commandID)\n            : row.action(\"Open\") { try await self.open(link) }\n        return row\n            .action(\"Copy Link\", shortcut: \"cmd+shift+c\") {\n                Stackbar.copy(needsInput ? link.link : ((try? await self.resolve(link, arguments: [:], context: nil)) ?? link.link))\n            }\n            .navigate(\"Edit\", to: \"create\", arguments: [\"id\": link.id.uuidString], shortcut: \"cmd+e\")\n            .action(\"Duplicate\", shortcut: \"cmd+d\") {\n                var copy = link\n                copy.id = UUID()\n                copy.name = link.name + \" copy\"\n                self.links.append(copy)\n            }\n            .action(\"Delete\", shortcut: \"ctrl+x\", role: .critical) {\n                self.links.removeAll { $0.id == link.id }\n            }\n    }\n\n    // MARK: Creating and editing\n\n    @ViewBuilder func form(editing id: UUID?) -> ViewContent {\n        let existing = id.flatMap { id in links.first { $0.id == id } }\n        Form(submit: existing == nil ? \"Create Quicklink\" : \"Save\", key: id?.uuidString ?? \"new\") {\n            FormField.text(\"Name\", name: \"name\", value: existing?.name ?? \"\", placeholder: \"GitHub search\", error: draft.nameError)\n            FormField.text(\"Link\", name: \"link\", value: existing?.link ?? \"\", placeholder: #\"https://github.com/search?q={argument name=\"query\"}\"#, error: draft.linkError)\n            FormField.application(\"Open with\", name: \"app\", bundleID: existing?.openWith)\n            FormField.text(\"Tags\", name: \"tags\", value: existing?.tags.joined(separator: \", \") ?? \"\", placeholder: \"work, docs\")\n            FormField.text(\"Symbol\", name: \"symbol\", value: existing?.symbol ?? \"\", placeholder: \"An SF Symbol, like star\")\n        } onSubmit: { values in\n            self.save(values, editing: id)\n        }\n        Text(#\"Placeholders: {argument name=\"x\"}, {clipboard}, {selection}, {date format=\"yyyy-MM-dd\"}, {uuid}. Paths start with / or ~/.\"#).style(.caption)\n    }\n\n    /// Checks the fields; on success saves and clears the errors, which\n    /// lets the palette close the form.\n    func save(_ values: [String: EventValue], editing id: UUID?) {\n        let name = (values.string(\"name\") ?? \"\").trimmingCharacters(in: .whitespaces)\n        let text = (values.string(\"link\") ?? \"\").trimmingCharacters(in: .whitespacesAndNewlines)\n        draft = Draft()\n        if name.isEmpty { draft.nameError = \"Give it a name\" }\n        draft.linkError = Self.problem(with: text)\n        guard draft.nameError == nil, draft.linkError == nil else { return }\n        let tags = (values.string(\"tags\") ?? \"\").split(separator: \",\").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }\n        let app = values.string(\"app\").flatMap { $0.isEmpty ? nil : $0 }\n        let symbol = values.string(\"symbol\").flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0.trimmingCharacters(in: .whitespaces) }\n        if let id, let index = links.firstIndex(where: { $0.id == id }) {\n            links[index].name = name\n            links[index].link = text\n            links[index].openWith = app\n            links[index].tags = tags\n            links[index].symbol = symbol\n        } else {\n            links.append(Link(id: UUID(), name: name, link: text, openWith: app, tags: tags, symbol: symbol))\n        }\n    }\n\n    /// Why a link can't be saved, or nil.\n    static func problem(with link: String) -> String? {\n        if link.isEmpty { return \"Give it a link\" }\n        let template: Template\n        do { template = try Template(link) } catch { return \"\\(error)\" }\n        if let typo = template.unrecognized.first { return \"\\(typo) is not a placeholder; see the list below\" }\n        if link.contains(where: \\.isNewline) { return \"A link is one line\" }\n        if kind(of: link) == .web, scheme(of: link) == nil, link.contains(\" \"), !link.contains(\"{\") { return \"Spaces need a scheme in front, like https://\" }\n        return nil\n    }\n\n    // MARK: Import and export\n\n    /// Raycast's export: `[{name, link, openWith?, iconName?}]`.\n    struct Exported: Codable {\n        var name: String\n        var link: String\n        var openWith: String?\n        var iconName: String?\n        var tags: [String]?\n    }\n\n    func importLinks() async throws -> String {\n        guard let file = try await Stackbar.chooseFile(types: [\"json\"]) else { return \"Import cancelled\" }\n        let items: [Exported]\n        do {\n            items = try JSONDecoder().decode([Exported].self, from: file.data)\n        } catch {\n            throw HostError(\"\\(file.name) is not a quicklinks export: \\(error.localizedDescription)\")\n        }\n        var imported = 0\n        var duplicates = 0\n        var unknownApps: [String] = []\n        var problems: [String] = []\n        for item in items {\n            if links.contains(where: { $0.name == item.name && $0.link == item.link }) {\n                duplicates += 1\n                continue\n            }\n            if let problem = Self.problem(with: item.link) {\n                problems.append(\"\\(item.name): \\(problem)\")\n                continue\n            }\n            var app: String?\n            if let openWith = item.openWith, !openWith.isEmpty {\n                app = try await Stackbar.resolveApplication(openWith)\n                if app == nil { unknownApps.append(openWith) }\n            }\n            links.append(Link(id: UUID(), name: item.name, link: item.link, openWith: app, tags: item.tags ?? []))\n            imported += 1\n        }\n        var summary = \"Imported \\(imported)\"\n        if duplicates > 0 { summary += \", skipped \\(duplicates) duplicate\\(duplicates == 1 ? \"\" : \"s\")\" }\n        if !problems.isEmpty { summary += \", skipped \\(problems.count) that didn't parse (\\(problems.prefix(3).joined(separator: \"; \")))\" }\n        if !unknownApps.isEmpty { summary += \"; opening with the default app instead of \" + Set(unknownApps).sorted().joined(separator: \", \") + \", which isn't installed\" }\n        notice = summary\n        return summary\n    }\n\n    func exportLinks() async throws -> String {\n        guard !links.isEmpty else { return \"No quicklinks to export\" }\n        let items = links.map { Exported(name: $0.name, link: $0.link, openWith: $0.openWith, iconName: nil, tags: $0.tags.isEmpty ? nil : $0.tags) }\n        let encoder = JSONEncoder()\n        encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]\n        guard let path = try await Stackbar.saveFile(\"Quicklinks.json\", data: try encoder.encode(items)) else { return \"Export cancelled\" }\n        return \"Exported \\(links.count) to \\(path)\"\n    }\n\n    // MARK: Panel and settings\n\n    var panel: ViewContent {\n        searchView(tag: nil)\n    }\n\n    var settings: ViewContent {\n        Section(\"Quicklinks\") {\n            LabeledContent(\"Saved\", value: \"\\(links.count)\")\n            if let notice { Text(notice).style(.caption) }\n            Button(\"Import from Raycast…\", symbol: \"square.and.arrow.down\") { _ = try await self.importLinks() }\n            Button(\"Export…\", symbol: \"square.and.arrow.up\") { self.notice = try await self.exportLinks() }\n        }\n        Section(\"Opening\") {\n            Text(\"Links without an app open in your default browser, which you choose in System Settings > Desktop & Dock. Give each quicklink a name you'll type, and an alias or hotkey in Settings > Commands.\").style(.caption)\n        }\n    }\n}\n",
  "sourceDigest" : "967d8669efa76c597b199c8ee7a478493c1b1a17cc9756ffedcbb8c71305fc6f"
}