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

        }
      },
      {
        "context" : {

        }
      },
      {
        "storage" : {
          "megabytes" : 16
        }
      }
    ],
    "homepage" : "https://github.com/navbytes/stackbar-plugins/tree/main/Productivity/Snippets",
    "hotkey" : "ctrl+option+s",
    "id" : "io.navbytes.stackbar.snippets",
    "minimumStackbar" : "0.11.0",
    "name" : "Snippets",
    "summary" : "Paste text by name, with placeholders filled in.",
    "surfaces" : [
      "panel",
      "settings"
    ],
    "triggers" : [
      {
        "launch" : {

        }
      }
    ],
    "version" : "1.0.1"
  },
  "publisher" : {
    "name" : "navbytes",
    "publicKey" : "4dpLtXEpFXdi+6cviY/KdYuUulJPbEmCxxIK0EEN2/w="
  },
  "signature" : "uokXbIm2h32a32AM5OswIpkmz9MopXsBoP5iN4zLz9xHOIwSEmdJbVsqj+CaoC2ePWhdfRzviVp6V7YJcf+uBQ==",
  "source" : "import StackbarKit\nimport Foundation\n\n/// Snippets: text you paste by name, with placeholders filled in.\n///\n/// Each snippet becomes a command of its own that pastes into the app you\n/// were in, then puts your clipboard back. Its text is a template in\n/// Raycast's syntax: `{clipboard}`, `{selection}`, `{date format=\"d MMM\"}`,\n/// `{argument name=\"who\"}` asks first, `{cursor}` leaves the caret there, and\n/// `{snippet name=\"Signature\"}` pulls in another snippet. \"Search Snippets\"\n/// (⌃⌥S) lists them with a preview. Raycast exports import as they are.\n///\n/// Keywords are kept but not expanded as you type: that needs a system-wide\n/// keyboard monitor, which Stackbar doesn't have.\n@main\nfinal class Snippets: StackbarPlugin {\n    static let manifest = Manifest(\n        id: \"io.navbytes.stackbar.snippets\",\n        name: \"Snippets\",\n        version: \"1.0.1\",\n        summary: \"Paste text by name, with placeholders filled in.\",\n        surfaces: [.panel, .settings],\n        triggers: [.launch],\n        // Pasting; the selection for {selection}; room for thousands of\n        // snippets at Raycast's 65,536-character limit.\n        capabilities: [.clipboard, .context, .storage(megabytes: 16)],\n        hotkey: \"ctrl+option+s\",\n        minimumStackbar: \"0.11.0\",\n        homepage: \"https://github.com/navbytes/stackbar-plugins/tree/main/Productivity/Snippets\"\n    )\n\n    static let maxLength = 65_536\n\n    struct Snippet: Codable, Equatable, Identifiable {\n        var id: UUID\n        var name: String\n        /// A template, at most 65,536 characters.\n        var text: String\n        /// Kept for expansion later, and matched by search.\n        var keyword: String?\n        var tags: [String] = []\n\n        var commandID: String { \"sn-\" + id.uuidString.lowercased() }\n        var template: Template? { try? Template(text) }\n    }\n\n    @Stored(\"snippets\") var snippets: [Snippet] = []\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 textError: String?\n        var keywordError: String?\n    }\n\n    // MARK: Rendering\n\n    /// What `{snippet name=\"…\"}` can name: every snippet by name, and by\n    /// keyword too.\n    func library(excluding id: UUID? = nil) -> [String: String] {\n        var library: [String: String] = [:]\n        for snippet in snippets where snippet.id != id {\n            if let keyword = snippet.keyword, !keyword.isEmpty { library[keyword] = snippet.text }\n            library[snippet.name] = snippet.text\n        }\n        return library\n    }\n\n    /// The names a template pulls in with `{snippet name=\"…\"}`.\n    static func included(by template: Template) -> [String] {\n        template.placeholders.filter { $0.name == \"snippet\" }.compactMap { $0.options[\"name\"] }\n    }\n\n    /// Whether the snippet, or one it includes, uses a placeholder.\n    func uses(_ name: String, in snippet: Snippet) -> Bool {\n        guard let template = snippet.template else { return false }\n        if template.uses(name) { return true }\n        let library = library(excluding: snippet.id)\n        return Self.included(by: template).contains { (library[$0].flatMap { try? Template($0) })?.uses(name) ?? false }\n    }\n\n    /// Renders a snippet with what the user typed, copied and selected.\n    func render(_ snippet: Snippet, arguments: [String: String], context: InvocationContext?) async throws -> Template.Rendered {\n        let template = try Template(snippet.text)\n        var environment = Template.Environment(selection: context?.selectedText, snippets: library(excluding: snippet.id))\n        if uses(\"clipboard\", in: snippet) { environment.clipboard = try await Stackbar.clipboard()?.text }\n        if environment.selection == nil, context == nil, uses(\"selection\", in: snippet) { environment.selection = try await Stackbar.context().selectedText }\n        return try template.render(arguments, in: environment)\n    }\n\n    /// The text with today's values, and markers for what is only known\n    /// when it is pasted.\n    func preview(_ snippet: Snippet) -> String {\n        guard let template = snippet.template else { return snippet.text }\n        let values = Dictionary(arguments(of: snippet).map { ($0.name, $0.defaultValue ?? $0.options?.first ?? \"‹\\($0.name)›\") }, uniquingKeysWith: { first, _ in first })\n        let environment = Template.Environment(clipboard: \"‹clipboard›\", selection: \"‹selection›\", snippets: library(excluding: snippet.id))\n        return (try? template.render(values, in: environment).text) ?? snippet.text\n    }\n\n    /// What the snippet asks for: its own arguments, then those of the\n    /// snippets it includes, which get the same values.\n    func arguments(of snippet: Snippet) -> [ArgumentSpec] {\n        guard let template = snippet.template else { return [] }\n        var arguments = template.arguments\n        let library = library(excluding: snippet.id)\n        for name in Self.included(by: template) {\n            for argument in (library[name].flatMap { try? Template($0) })?.arguments ?? [] where !arguments.contains(where: { $0.name == argument.name }) {\n                arguments.append(argument)\n            }\n        }\n        return Array(arguments.prefix(Template.maxArguments))\n    }\n\n    func paste(_ snippet: Snippet) async throws {\n        let rendered = try await render(snippet, arguments: [:], context: nil)\n        try await Stackbar.paste(.text(rendered.text), restoringClipboard: true, cursorFromEnd: rendered.cursorFromEnd ?? 0)\n    }\n\n    // MARK: Commands\n\n    var allTags: [String] {\n        Array(Set(snippets.flatMap(\\.tags))).sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }\n    }\n\n    var commands: [Command] {\n        for snippet in snippets { command(for: snippet) }\n        Command(\"search\", title: \"Search Snippets\", subtitle: \"Paste, copy or edit a snippet\", symbol: \"text.quote\", keywords: [\"snippet\", \"text\", \"paste\"])\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 Snippet\", symbol: \"text.badge.plus\", keywords: [\"snippet\", \"new\", \"add\"])\n            .argument(\"id\", placeholder: \"Snippet\", required: false)\n            .stackbarOnly()\n            .view { invocation in self.form(editing: invocation[\"id\"].flatMap(UUID.init(uuidString:))) }\n        Command(\"import\", title: \"Import Snippets\", subtitle: \"From a Raycast export\", symbol: \"square.and.arrow.down\", keywords: [\"snippet\", \"raycast\"]) { _ in\n            .message(try await self.importSnippets())\n        }\n        .stackbarOnly()\n        Command(\"export\", title: \"Export Snippets\", subtitle: \"To a file Raycast reads too\", symbol: \"square.and.arrow.up\", keywords: [\"snippet\", \"raycast\", \"backup\"]) { _ in\n            .message(try await self.exportSnippets())\n        }\n        .stackbarOnly()\n    }\n\n    /// A snippet's own command: its arguments come from its template, and\n    /// running it pastes.\n    func command(for snippet: Snippet) -> Command {\n        let keywords = snippet.tags + [snippet.keyword].compactMap { $0 } + [\"snippet\"]\n        var command = Command(snippet.commandID, title: snippet.name, subtitle: Self.firstLine(of: snippet.text), symbol: \"text.quote\", keywords: keywords) { invocation in\n            do {\n                let rendered = try await self.render(snippet, arguments: invocation.arguments, context: invocation.context)\n                return .paste(rendered.text, cursorFromEnd: rendered.cursorFromEnd ?? 0)\n            } catch {\n                return .failure(\"\\(snippet.name): \\(error)\")\n            }\n        }\n        for argument in arguments(of: snippet) {\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    static func firstLine(of text: String) -> String {\n        let line = text.split(separator: \"\\n\", omittingEmptySubsequences: true).first.map(String.init) ?? \"\"\n        return line.count > 80 ? String(line.prefix(79)) + \"…\" : line\n    }\n\n    @ViewBuilder func searchView(tag: String?) -> ViewContent {\n        let shown = snippets.filter { tag == nil || tag == \"All\" || $0.tags.contains(tag!) }\n            .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }\n        if snippets.isEmpty {\n            ContentUnavailableView(\"No snippets yet\", symbol: \"text.quote\", message: \"Create one, or import a Raycast export from Settings.\")\n        } else {\n            List(searchable: true) {\n                ForEach(shown) { snippet in self.row(snippet) }\n            }\n        }\n    }\n\n    func row(_ snippet: Snippet) -> Row {\n        var row = Row(snippet.name, subtitle: snippet.keyword, symbol: \"text.quote\", key: snippet.id.uuidString)\n        for tag in snippet.tags { row = row.badge(tag) }\n        let preview = preview(snippet)\n        row = row.preview(text: preview, caption: \"\\(snippet.text.count) characters\" + (snippet.keyword.map { \" · keyword \\($0)\" } ?? \"\"))\n        let needsInput = arguments(of: snippet).contains(where: \\.required)\n        // With arguments to fill in, Return opens the snippet's own command,\n        // which asks for them and then pastes.\n        row = needsInput\n            ? row.navigate(\"Paste\", to: snippet.commandID)\n            : row.action(\"Paste\") { try await self.paste(snippet) }\n        return row\n            .action(\"Copy\", shortcut: \"cmd+c\") {\n                let text = needsInput ? snippet.text : ((try? await self.render(snippet, arguments: [:], context: nil).text) ?? snippet.text)\n                Stackbar.copy(text)\n            }\n            .navigate(\"Edit\", to: \"create\", arguments: [\"id\": snippet.id.uuidString], shortcut: \"cmd+e\")\n            .action(\"Duplicate\", shortcut: \"cmd+d\") {\n                var copy = snippet\n                copy.id = UUID()\n                copy.name = snippet.name + \" copy\"\n                copy.keyword = nil\n                self.snippets.append(copy)\n            }\n            .action(\"Delete\", shortcut: \"ctrl+x\", role: .critical) {\n                self.snippets.removeAll { $0.id == snippet.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 snippets.first { $0.id == id } }\n        Form(submit: existing == nil ? \"Create Snippet\" : \"Save\", key: id?.uuidString ?? \"new\") {\n            FormField.text(\"Name\", name: \"name\", value: existing?.name ?? \"\", placeholder: \"Signature\", error: draft.nameError)\n            FormField.multiline(\"Snippet\", name: \"text\", value: existing?.text ?? \"\", placeholder: \"Thanks,\\n{argument name=\\\"name\\\" default=\\\"Nav\\\"}\", error: draft.textError)\n            FormField.text(\"Keyword\", name: \"keyword\", value: existing?.keyword ?? \"\", placeholder: \";sig (kept for later; not expanded as you type)\", error: draft.keywordError)\n            FormField.text(\"Tags\", name: \"tags\", value: existing?.tags.joined(separator: \", \") ?? \"\", placeholder: \"work, email\")\n        } onSubmit: { values in\n            self.save(values, editing: id)\n        }\n        Text(#\"Placeholders: {clipboard}, {selection}, {cursor}, {date format=\"yyyy-MM-dd\" offset=\"+1d\"}, {time}, {day}, {uuid}, {argument name=\"x\" default=\"y\"}, {snippet name=\"Other\"}. Modifiers: {clipboard | trim | uppercase}.\"#).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(\"text\") ?? \"\"\n        let keyword = (values.string(\"keyword\") ?? \"\").trimmingCharacters(in: .whitespaces)\n        draft = Draft()\n        if name.isEmpty { draft.nameError = \"Give it a name\" }\n        draft.textError = problem(with: text, name: name, editing: id)\n        draft.keywordError = Self.problem(withKeyword: keyword)\n        if draft.keywordError == nil, !keyword.isEmpty, snippets.contains(where: { $0.keyword == keyword && $0.id != id }) {\n            draft.keywordError = \"Another snippet has the keyword \\(keyword)\"\n        }\n        guard draft.nameError == nil, draft.textError == nil, draft.keywordError == nil else { return }\n        let tags = (values.string(\"tags\") ?? \"\").split(separator: \",\").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }\n        if let id, let index = snippets.firstIndex(where: { $0.id == id }) {\n            snippets[index].name = name\n            snippets[index].text = text\n            snippets[index].keyword = keyword.isEmpty ? nil : keyword\n            snippets[index].tags = tags\n        } else {\n            snippets.append(Snippet(id: UUID(), name: name, text: text, keyword: keyword.isEmpty ? nil : keyword, tags: tags))\n        }\n    }\n\n    /// Why a snippet's text can't be saved, or nil.\n    func problem(with text: String, name: String, editing id: UUID?) -> String? {\n        if text.isEmpty { return \"Write the snippet\" }\n        if text.count > Self.maxLength { return \"A snippet is at most 65,536 characters\" }\n        let template: Template\n        do { template = try Template(text) } catch { return \"\\(error)\" }\n        if let typo = template.unrecognized.first { return \"\\(typo) is not a placeholder; see the list below\" }\n        if template.placeholders.filter({ $0.name == \"cursor\" }).count > 1 { return \"Use {cursor} once\" }\n        let library = library(excluding: id)\n        for included in Self.included(by: template) {\n            if included == name { return \"A snippet can't include itself\" }\n            guard let other = library[included] else { return \"There's no snippet named \\(included)\" }\n            if let inner = try? Template(other), !Self.included(by: inner).isEmpty {\n                return \"\\(included) includes another snippet itself; snippets nest one level deep\"\n            }\n        }\n        return nil\n    }\n\n    /// Raycast's rule: no whitespace, quotes or backticks.\n    static func problem(withKeyword keyword: String) -> String? {\n        if keyword.contains(where: { $0.isWhitespace || $0 == \"\\\"\" || $0 == \"'\" || $0 == \"`\" }) {\n            return \"A keyword has no spaces, quotes or backticks\"\n        }\n        return keyword.count > 64 ? \"A keyword is at most 64 characters\" : nil\n    }\n\n    // MARK: Import and export\n\n    /// Raycast's export: `[{name, text, keyword?}]`.\n    struct Exported: Codable {\n        var name: String\n        var text: String\n        var keyword: String?\n        var tags: [String]?\n    }\n\n    /// Raycast's older `{date: yyyy-MM-dd}` shorthand, as `{date format=\"…\"}`.\n    static func modernized(_ text: String) -> String {\n        var result = \"\"\n        var rest = Substring(text)\n        while let open = rest.firstIndex(of: \"{\") {\n            result += rest[..<open]\n            rest = rest[open...]\n            var converted = false\n            for name in [\"datetime\", \"date\", \"time\"] where rest.hasPrefix(\"{\\(name):\") {\n                let body = rest.dropFirst(name.count + 2)\n                if let close = body.firstIndex(of: \"}\") {\n                    let format = body[..<close].trimmingCharacters(in: .whitespaces)\n                    if !format.isEmpty, !format.contains(\"\\\"\") {\n                        result += \"{\\(name) format=\\\"\\(format)\\\"}\"\n                        rest = body[body.index(after: close)...]\n                        converted = true\n                    }\n                }\n                break\n            }\n            if !converted {\n                result.append(\"{\")\n                rest = rest.dropFirst()\n            }\n        }\n        return result + rest\n    }\n\n    func importSnippets() 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 snippets export: \\(error.localizedDescription)\")\n        }\n        var imported = 0\n        var duplicates = 0\n        var problems: [String] = []\n        var droppedKeywords: [String] = []\n        for item in items {\n            let text = Self.modernized(item.text)\n            if snippets.contains(where: { $0.name == item.name && ($0.text == text || $0.text == item.text) }) {\n                duplicates += 1\n                continue\n            }\n            if let problem = Self.importProblem(with: text) {\n                problems.append(\"\\(item.name): \\(problem)\")\n                continue\n            }\n            var keyword = item.keyword.flatMap { $0.isEmpty ? nil : $0 }\n            if let candidate = keyword, Self.problem(withKeyword: candidate) != nil || snippets.contains(where: { $0.keyword == candidate }) {\n                droppedKeywords.append(candidate)\n                keyword = nil\n            }\n            snippets.append(Snippet(id: UUID(), name: item.name, text: text, keyword: keyword, 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 !droppedKeywords.isEmpty { summary += \"; left out keywords already taken or not allowed: \" + droppedKeywords.prefix(5).joined(separator: \", \") }\n        notice = summary\n        return summary\n    }\n\n    /// The checks that don't depend on the other snippets, which may arrive\n    /// later in the same file.\n    static func importProblem(with text: String) -> String? {\n        if text.isEmpty { return \"it is empty\" }\n        if text.count > maxLength { return \"it is over 65,536 characters\" }\n        do { _ = try Template(text) } catch { return \"\\(error)\" }\n        return nil\n    }\n\n    func exportSnippets() async throws -> String {\n        guard !snippets.isEmpty else { return \"No snippets to export\" }\n        let items = snippets.map { Exported(name: $0.name, text: $0.text, keyword: $0.keyword, 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(\"Snippets.json\", data: try encoder.encode(items)) else { return \"Export cancelled\" }\n        return \"Exported \\(snippets.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(\"Snippets\") {\n            LabeledContent(\"Saved\", value: \"\\(snippets.count)\")\n            if let notice { Text(notice).style(.caption) }\n            Button(\"Import from Raycast…\", symbol: \"square.and.arrow.down\") { _ = try await self.importSnippets() }\n            Button(\"Export…\", symbol: \"square.and.arrow.up\") { self.notice = try await self.exportSnippets() }\n        }\n        Section(\"Pasting\") {\n            Text(\"⌃⌥S opens Search Snippets. Give a snippet you use often its own alias or hotkey in Settings > Commands. Pasting needs Accessibility access for Stackbar, and puts back what you had copied about half a second later. Snippet pastes don't go into Clipboard History.\").style(.caption)\n            Text(\"Keywords are kept, but not expanded as you type: that needs a keyboard monitor Stackbar doesn't run. For plain text, macOS expands keywords itself in System Settings > Keyboard > Text Replacements.\").style(.caption)\n        }\n    }\n}\n",
  "sourceDigest" : "d4157fbf70d848223cd7ed4cfdb02e500b83cfdfa3e9b99754d2c664c1207c46"
}