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

        }
      },
      {
        "context" : {

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

        }
      }
    ],
    "version" : "1.4.0"
  },
  "publisher" : {
    "name" : "navbytes",
    "publicKey" : "4dpLtXEpFXdi+6cviY/KdYuUulJPbEmCxxIK0EEN2/w="
  },
  "signature" : "/vJJdfxxR7ynye84Gh+jKTpuGpNTI0vGMEDthLnIPx8S5kh3bQnEqrqFrAPlO8v1IOOJlr+xKgprRjftWcTTDA==",
  "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.4.0\",\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        // Site icons come from DuckDuckGo's icon service, which sees the\n        // domain of each web link shown in the launcher (off in Settings).\n        capabilities: [.openURLs(schemes: [\"*\"]), .clipboard, .context, .network(\"icons.duckduckgo.com\")],\n        minimumStackbar: \"0.17.0\",\n        homepage: \"https://github.com/navbytes/stackbar-plugins/tree/main/Productivity/Quicklinks\",\n        accent: .cyan,\n        symbol: \"link\"\n    )\n\n    // MARK: Previews\n\n    static let previews: [PreviewScene<Quicklinks>] = [\n        PreviewScene(\"hero\", \"Your searches, sites and folders by name\") { plugin in\n            plugin.links = samples\n            plugin.usage = Dictionary(uniqueKeysWithValues: samples.enumerated().map { index, link in\n                (link.commandID, Usage(count: 40 - index * 4, last: Date().addingTimeInterval(-Double(index) * 3600)))\n            })\n        },\n    ]\n\n    static let samples: [Link] = {\n        func link(_ number: Int, _ name: String, _ template: String, tags: [String], symbol: String? = nil) -> Link {\n            Link(id: UUID(uuidString: String(format: \"51C00000-0000-4000-8000-%012d\", number)) ?? UUID(), name: name, link: template, tags: tags, symbol: symbol)\n        }\n        return [\n            link(1, \"Search GitHub\", \"https://github.com/search?q={Query}\", tags: [\"Dev\"]),\n            link(2, \"Jira Ticket\", \"https://acme.atlassian.net/browse/{Key}\", tags: [\"Work\"]),\n            link(3, \"Google Maps\", \"https://maps.google.com/?q={Place}\", tags: [\"Everyday\"]),\n            link(4, \"Translate\", \"https://translate.google.com/?sl=auto&tl=en&text={Text}\", tags: [\"Everyday\"]),\n            link(5, \"Stack Overflow\", \"https://stackoverflow.com/search?q={Query}\", tags: [\"Dev\"]),\n            link(6, \"Downloads\", \"~/Downloads\", tags: [\"Folders\"], symbol: \"folder.fill\"),\n            link(7, \"Team Calendar\", \"https://calendar.google.com\", tags: [\"Work\"]),\n            link(8, \"Swift Docs\", \"https://developer.apple.com/search/?q={Query}\", tags: [\"Dev\"]),\n        ]\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    /// The tag the panel filters by; nil shows them all.\n    @Stored(\"panelTag\") var panelTag: String? = nil\n    @Stored(\"siteIcons\") var siteIcons = true\n    /// How often and how lately each quicklink was opened, by id, so the\n    /// ones you use come first.\n    @Stored(\"usage\") var usage: [String: Usage] = [:]\n\n    struct Usage: Codable, Equatable {\n        var count: Int\n        var last: Date\n    }\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: Library\n\n    /// A ready-made quicklink: what a new user can add in one click.\n    struct Starter: Identifiable {\n        var name: String\n        var link: String\n        var tag: String\n        var id: String { link }\n    }\n\n    static func ask(_ name: String) -> String { #\"{argument name=\"\"# + name + #\"\"}\"# }\n\n    /// Starters by tag, the everyday ones first.\n    static let library: [Starter] = [\n        Starter(name: \"Google\", link: \"https://www.google.com/search?q=\" + ask(\"query\"), tag: \"Search\"),\n        Starter(name: \"DuckDuckGo\", link: \"https://duckduckgo.com/?q=\" + ask(\"query\"), tag: \"Search\"),\n        Starter(name: \"Kagi\", link: \"https://kagi.com/search?q=\" + ask(\"query\"), tag: \"Search\"),\n        Starter(name: \"Perplexity\", link: \"https://www.perplexity.ai/search?q=\" + ask(\"query\"), tag: \"Search\"),\n        Starter(name: \"Ask Claude\", link: \"https://claude.ai/new?q=\" + ask(\"prompt\"), tag: \"Search\"),\n        Starter(name: \"Wikipedia\", link: \"https://en.wikipedia.org/w/index.php?search=\" + ask(\"query\"), tag: \"Search\"),\n        Starter(name: \"YouTube\", link: \"https://www.youtube.com/results?search_query=\" + ask(\"query\"), tag: \"Search\"),\n        Starter(name: \"Google Maps\", link: \"https://www.google.com/maps/search/\" + ask(\"place\"), tag: \"Search\"),\n        Starter(name: \"Google Images\", link: \"https://www.google.com/search?tbm=isch&q=\" + ask(\"query\"), tag: \"Search\"),\n        Starter(name: \"Reddit\", link: \"https://www.reddit.com/search/?q=\" + ask(\"query\"), tag: \"Search\"),\n        Starter(name: \"Amazon\", link: \"https://www.amazon.com/s?k=\" + ask(\"query\"), tag: \"Search\"),\n        Starter(name: \"GitHub Repository\", link: \"https://github.com/\" + ask(\"owner\") + \"/\" + ask(\"repo\"), tag: \"Developer\"),\n        Starter(name: \"GitHub Search\", link: \"https://github.com/search?type=repositories&q=\" + ask(\"query\"), tag: \"Developer\"),\n        Starter(name: \"GitHub Code Search\", link: \"https://github.com/search?type=code&q=\" + ask(\"query\"), tag: \"Developer\"),\n        Starter(name: \"My Pull Requests\", link: \"https://github.com/pulls\", tag: \"Developer\"),\n        Starter(name: \"GitHub Notifications\", link: \"https://github.com/notifications\", tag: \"Developer\"),\n        Starter(name: \"Stack Overflow\", link: \"https://stackoverflow.com/search?q=\" + ask(\"query\"), tag: \"Developer\"),\n        Starter(name: \"MDN\", link: \"https://developer.mozilla.org/en-US/search?q=\" + ask(\"query\"), tag: \"Developer\"),\n        Starter(name: \"Apple Developer Docs\", link: \"https://developer.apple.com/search/?q=\" + ask(\"query\"), tag: \"Developer\"),\n        Starter(name: \"Swift Package Index\", link: \"https://swiftpackageindex.com/search?query=\" + ask(\"query\"), tag: \"Developer\"),\n        Starter(name: \"npm\", link: \"https://www.npmjs.com/search?q=\" + ask(\"package\"), tag: \"Developer\"),\n        Starter(name: \"PyPI\", link: \"https://pypi.org/search/?q=\" + ask(\"package\"), tag: \"Developer\"),\n        Starter(name: \"crates.io\", link: \"https://crates.io/search?q=\" + ask(\"crate\"), tag: \"Developer\"),\n        Starter(name: \"Homebrew Formula\", link: \"https://formulae.brew.sh/formula/\" + ask(\"formula\"), tag: \"Developer\"),\n        Starter(name: \"Docker Hub\", link: \"https://hub.docker.com/search?q=\" + ask(\"image\"), tag: \"Developer\"),\n        Starter(name: \"Can I Use\", link: \"https://caniuse.com/?search=\" + ask(\"feature\"), tag: \"Developer\"),\n        Starter(name: \"regex101\", link: \"https://regex101.com\", tag: \"Developer\"),\n        Starter(name: \"crontab guru\", link: \"https://crontab.guru\", tag: \"Developer\"),\n        Starter(name: \"JWT Debugger\", link: \"https://jwt.io\", tag: \"Developer\"),\n        Starter(name: \"Gmail\", link: \"https://mail.google.com\", tag: \"Productivity\"),\n        Starter(name: \"Google Calendar\", link: \"https://calendar.google.com\", tag: \"Productivity\"),\n        Starter(name: \"Google Drive Search\", link: \"https://drive.google.com/drive/search?q=\" + ask(\"query\"), tag: \"Productivity\"),\n        Starter(name: \"Google Translate\", link: \"https://translate.google.com/?sl=auto&tl=en&text=\" + ask(\"text\"), tag: \"Productivity\"),\n        Starter(name: \"Dictionary\", link: \"https://www.merriam-webster.com/dictionary/\" + ask(\"word\"), tag: \"Productivity\"),\n        Starter(name: \"Thesaurus\", link: \"https://www.thesaurus.com/browse/\" + ask(\"word\"), tag: \"Productivity\"),\n        Starter(name: \"Weather\", link: \"https://wttr.in/\" + ask(\"city\"), tag: \"Productivity\"),\n        Starter(name: \"Downloads\", link: \"~/Downloads\", tag: \"Mac\"),\n        Starter(name: \"Desktop\", link: \"~/Desktop\", tag: \"Mac\"),\n        Starter(name: \"Documents\", link: \"~/Documents\", tag: \"Mac\"),\n        Starter(name: \"Applications\", link: \"/Applications\", tag: \"Mac\"),\n    ]\n\n    /// Opening and everyday sites, added by the empty state's one button.\n    static let essentials = [\"Google\", \"YouTube\", \"Wikipedia\", \"Google Maps\", \"GitHub Repository\", \"Gmail\", \"Google Calendar\", \"Downloads\"]\n\n    static let libraryTags = [\"Search\", \"Developer\", \"Productivity\", \"Mac\"]\n\n    /// Whether a starter's link is already saved.\n    func has(_ starter: Starter) -> Bool { links.contains { $0.link == starter.link } }\n\n    func add(_ starters: [Starter]) -> Int {\n        let new = starters.filter { !has($0) }\n        links += new.map { Link(id: UUID(), name: $0.name, link: $0.link, tags: [$0.tag]) }\n        return new.count\n    }\n\n    @ViewBuilder func libraryView(tag: String?) -> ViewContent {\n        let shown = Self.library.filter { tag == nil || tag == \"All\" || $0.tag == tag }\n        let missing = shown.filter { !has($0) }\n        if !missing.isEmpty {\n            Button(missing.count == shown.count ? \"Add all \\(shown.count)\" : \"Add the \\(missing.count) you don't have\", symbol: \"plus.circle\", key: \"library-add-all\") {\n                let added = self.add(missing)\n                self.notice = \"Added \\(added) quicklink\\(added == 1 ? \"\" : \"s\")\"\n            }\n        }\n        List(searchable: true) {\n            ForEach(Self.libraryTags.filter { tag == nil || tag == \"All\" || $0 == tag }) { group in\n                Section(group) {\n                    ForEach(shown.filter { $0.tag == group }) { starter in\n                        let link = Link(id: UUID(), name: starter.name, link: starter.link, tags: [starter.tag])\n                        let row = Row(starter.name, subtitle: Self.readable(link), symbol: Self.symbol(for: link), key: \"starter-\\(starter.link)\")\n                        if self.has(starter) {\n                            row.badge(\"Added\", role: .secondary)\n                        } else {\n                            row.action(\"Add\") {\n                                _ = self.add([starter])\n                                self.notice = \"Added \\(starter.name)\"\n                            }\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    // MARK: Ranking\n\n    /// Opens, worth less as they age: a link used daily this week beats\n    /// one used a lot last year.\n    func score(_ link: Link, now: Date = Date()) -> Double {\n        guard let use = usage[link.id.uuidString] else { return 0 }\n        let days = max(0, now.timeIntervalSince(use.last) / 86400)\n        return Double(use.count) * pow(0.5, days / 14)\n    }\n\n    func ranked(_ shown: [Link]) -> [Link] {\n        let now = Date()\n        return shown.sorted {\n            let (a, b) = (score($0, now: now), score($1, now: now))\n            return a != b ? a > b : $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending\n        }\n    }\n\n    func noteOpened(_ link: Link) {\n        var use = usage[link.id.uuidString] ?? Usage(count: 0, last: Date())\n        use.count += 1\n        use.last = Date()\n        var all = usage\n        all[link.id.uuidString] = use\n        // Forget deleted links.\n        let ids = Set(links.map { $0.id.uuidString })\n        usage = all.filter { ids.contains($0.key) }\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    /// The site's icon for a web link, when site icons are on.\n    func favicon(_ link: Link) -> String? {\n        guard siteIcons, let host = Self.host(of: link.link), !host.contains(\"{\"), host.contains(\".\") else { return nil }\n        return \"https://icons.duckduckgo.com/ip3/\\(host).ico\"\n    }\n\n    /// A host without \"www.\" and its registry suffix: \"calendar.google\".\n    static func shortHost(_ host: String) -> String {\n        var parts = host.replacingOccurrences(of: \"www.\", with: \"\").split(separator: \".\").map(String.init)\n        if parts.count > 1 { parts.removeLast() }\n        // Still too long for a tile: drop the subdomain (\"news.ycombinator\").\n        if parts.count > 1, parts.joined(separator: \".\").count > 15 { parts.removeFirst() }\n        return parts.joined(separator: \".\")\n    }\n\n    /// One line short enough for a tile, so tiles in a row match.\n    static func clip(_ text: String, _ limit: Int) -> String {\n        text.count > limit ? String(text.prefix(limit - 1)) + \"…\" : text\n    }\n\n    /// The launcher: up to six links that open without asking anything, as\n    /// tiles with their site's icon.\n    @ViewBuilder func launcher(_ shown: [Link]) -> ViewContent {\n        let ready = ranked(shown).filter { !($0.template?.arguments.contains(where: \\.required) ?? false) }.prefix(6)\n        if ready.count >= 3 {\n            Grid(columns: 3, spacing: Space.s) {\n                ForEach(Array(ready)) { link in\n                    Tile(Self.clip(link.name, 14), subtitle: Self.host(of: link.link).map { Self.clip(Self.shortHost($0), 17) } ?? (link.link.hasPrefix(\"/\") || link.link.hasPrefix(\"~\") ? \"Folder\" : \"Link\"), symbol: Self.symbol(for: link), imageURL: favicon(link), theme: theme, key: \"tile-\\(link.id.uuidString)\") {\n                        try await self.open(link)\n                    }\n                }\n            }\n        }\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        noteOpened(link)\n    }\n\n    // MARK: Commands\n\n    var allTags: [String] {\n        Array(Set(links.flatMap(\\.tags))).sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }\n    }\n\n    /// Tags, the most used first, for the panel's chips.\n    var tagsByUse: [String] {\n        let counts = Dictionary(links.flatMap(\\.tags).map { ($0, 1) }, uniquingKeysWith: +)\n        return allTags.sorted { (counts[$0] ?? 0, $1) > (counts[$1] ?? 0, $0) }\n    }\n\n    /// The host and path, without the template's placeholders.\n    static func readable(_ link: Link) -> String {\n        let arguments = link.template?.arguments.map(\\.name) ?? []\n        guard kind(of: link.link) == .web else { return link.link }\n        let shown = host(of: link.link) ?? link.link\n        return arguments.isEmpty ? shown + pathHint(link.link) : \"\\(shown) · asks for \\(arguments.joined(separator: \", \"))\"\n    }\n\n    static func pathHint(_ link: String) -> String {\n        let full = target(link)\n        guard let start = full.range(of: \"://\")?.upperBound, let slash = full[start...].firstIndex(of: \"/\") else { return \"\" }\n        let path = full[slash...]\n        return path.count <= 1 ? \"\" : path.count > 24 ? String(path.prefix(23)) + \"…\" : String(path)\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(\"library\", title: \"Browse Quicklink Library\", subtitle: \"\\(Self.library.count) ready-made searches, sites and folders\", symbol: \"books.vertical\", keywords: [\"quicklink\", \"starter\", \"add\", \"template\"])\n            .argument(\"tag\", placeholder: \"Tag\", required: false, default: \"All\", options: [\"All\"] + Self.libraryTags)\n            .stackbarOnly()\n            .view { invocation in self.libraryView(tag: invocation[\"tag\"]) }\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                let url = try await self.resolve(link, arguments: invocation.arguments, context: invocation.context)\n                self.noteOpened(link)\n                return .open(url: url, 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 = ranked(links.filter { tag == nil || tag == \"All\" || $0.tags.contains(tag!) })\n        if links.isEmpty {\n            EmptyState(\"No quicklinks yet\", symbol: \"link\", message: \"Open searches, sites and folders by name. Start with a few everyday ones, browse \\(Self.library.count) ready-made ones, or bring in a Raycast export.\", theme: theme) {\n                ActionButton(\"Add \\(Self.essentials.count) Everyday Links\", symbol: \"sparkles\", theme: theme, prominent: true) {\n                    let added = self.add(Self.library.filter { Self.essentials.contains($0.name) })\n                    self.notice = \"Added \\(added) quicklinks. Type a name in the palette to open one.\"\n                }\n                ActionButton(\"Browse Library\", symbol: \"books.vertical\", theme: theme) { self.openCommand(\"library\") }\n                ActionButton(\"Create\", symbol: \"plus\", theme: theme) { self.openCommand(\"create\") }\n                ActionButton(\"Import\", symbol: \"square.and.arrow.down\", theme: theme) { self.notice = try await self.importLinks() }\n            }\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: Self.readable(link), symbol: Self.symbol(for: link), key: link.id.uuidString).appIcon(link.openWith)\n        if let tag = link.tags.first { 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                // \"Name 2\" sorts next to the original.\n                copy.name = link.name + \" 2\"\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.picker(\"Symbol\", name: \"symbol\", options: Self.symbols, selection: existing?.symbol.flatMap { Self.symbols.contains($0) ? $0 : nil } ?? \"Automatic\")\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.isEmpty || $0 == \"Automatic\" ? nil : $0 }\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    let theme = Theme.cyan\n\n    /// \"Automatic\" is the link kind's own symbol.\n    static let symbols = [\"Automatic\", \"star\", \"globe\", \"folder\", \"doc.text\", \"book\", \"magnifyingglass\", \"chevron.left.forwardslash.chevron.right\",\n                          \"terminal\", \"server.rack\", \"cloud\", \"envelope\", \"calendar\", \"person.2\", \"cart\", \"music.note\", \"map\"]\n\n    func openCommand(_ command: String) {\n        Stackbar.open(\"stackbar://commands/\\(Self.manifest.id)/\\(command)\")\n    }\n\n    var panel: ViewContent {\n        if !links.isEmpty {\n            PanelHeader(links.count == 1 ? \"1 quicklink\" : \"\\(links.count) quicklinks\", subtitle: \"Type a name in the palette to open one\", symbol: \"link\", theme: theme) {\n                HStack(spacing: Space.xs) {\n                    IconButton(\"books.vertical\", label: \"Browse Library\", theme: theme) { self.openCommand(\"library\") }\n                    IconButton(\"plus\", label: \"New Quicklink\", theme: theme, prominence: .tinted) { self.openCommand(\"create\") }\n                }\n            }\n            if let notice { Banner(notice, symbol: \"checkmark.circle.fill\", theme: .success) }\n            if !allTags.isEmpty {\n                TagChips(tagsByUse, selection: panelTag.flatMap { allTags.contains($0) ? $0 : nil }, theme: theme) { self.panelTag = $0 }\n            }\n        }\n        let tag = panelTag.flatMap { allTags.contains($0) ? $0 : nil }\n        launcher(links.filter { tag == nil || tag == \"All\" || $0.tags.contains(tag!) })\n        searchView(tag: tag)\n    }\n\n    var settings: ViewContent {\n        Section(\"Quicklinks\") {\n            LabeledContent(\"Saved\", value: \"\\(links.count)\")\n            Toggle(\"Show site icons\", isOn: siteIcons) { self.siteIcons = $0 }\n            Text(\"Icons come from DuckDuckGo, which sees the domains of the links in the launcher.\").style(.caption).role(.secondary)\n            if let notice { Text(notice).style(.caption) }\n            Button(\"Browse the library…\", symbol: \"books.vertical\") { self.openCommand(\"library\") }\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" : "923b534316e28fc85049da94acd6831356e45e734d39e7ef1bd6bb4cf5f7e796"
}