├── .envrc ├── .github ├── dependabot.yml └── workflows │ ├── format-check.yml │ ├── release.yaml │ └── test.yaml ├── .gitignore ├── .goreleaser.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── cmd ├── configure.go ├── exec.go ├── root.go ├── search.go ├── switch.go └── util.go ├── commands.png ├── config └── config.go ├── default.nix ├── dialog ├── params.go ├── util.go └── view.go ├── entity └── entity.go ├── go.mod ├── go.sum ├── justfile ├── main.go ├── misc └── completions │ └── zsh │ └── _qc ├── qc.png ├── scripts ├── package.sh └── pre-commit.sh ├── shell.nix ├── snippet └── snippet.go ├── treefmt.toml └── websocket └── websocket.go /.envrc: -------------------------------------------------------------------------------- 1 | use nix 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: daily 7 | -------------------------------------------------------------------------------- /.github/workflows/format-check.yml: -------------------------------------------------------------------------------- 1 | name: 📄 Check formatting 2 | 3 | on: 4 | push: 5 | tags-ignore: 6 | - "*" 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | format-check: 12 | name: 📄 Check code formatting with "just fmt" 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: cachix/install-nix-action@v31 17 | with: 18 | nix_path: nixpkgs=channel:nixos-unstable 19 | - run: nix-shell -p just --run "just fmt --ci" 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - "v*" 6 | permissions: 7 | contents: write 8 | env: 9 | GO_VERSION: "1.22" 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-20.04 14 | steps: 15 | - name: Install dependencies 16 | run: | 17 | sudo apt-get -y update 18 | sudo apt-get -y install rpm 19 | - name: Setup Go 20 | uses: actions/setup-go@v5 21 | with: 22 | go-version: ${{ env.GO_VERSION }} 23 | - name: Checkout code 24 | uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 0 27 | - name: Release 28 | uses: goreleaser/goreleaser-action@v6 29 | with: 30 | version: v1.0.0 31 | args: release --rm-dist 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths-ignore: 7 | - "*.md" 8 | - "doc/**" 9 | - "LICENSE" 10 | - "justfile" 11 | pull_request: 12 | env: 13 | GO_VERSION: "1.22" 14 | jobs: 15 | test: 16 | name: Test 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Set up Go 22 | uses: actions/setup-go@v5 23 | with: 24 | go-version: ${{ env.GO_VERSION }} 25 | 26 | - name: Build the code 27 | run: go build -o qc main.go 28 | 29 | - name: Run unit tests 30 | run: go test ./... 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | qc 17 | dev.toml 18 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | release: 2 | github: 3 | owner: qownnotes 4 | name: qc 5 | name_template: "{{.Tag}}" 6 | # https://goreleaser.com/customization/homebrew/ 7 | #brews: 8 | # - 9 | # tap: 10 | # owner: qownnotes 11 | # name: homebrew-qc 12 | # description: "QOwnNotes command-line snippet manager" 13 | # homepage: "https://github.com/qownnotes/qc" 14 | # dependencies: 15 | # - fzf 16 | # install: | 17 | # bin.install Dir['qc'] 18 | # zsh_completion.install "misc/completions/zsh/_qc" 19 | # test: | 20 | # system "#{bin}/qc" 21 | builds: 22 | - goos: 23 | - linux 24 | - darwin 25 | - windows 26 | goarch: 27 | - amd64 28 | - arm 29 | - arm64 30 | - "386" 31 | goarm: 32 | - "6" 33 | main: . 34 | ldflags: -s -w -X github.com/qownnotes/qc/cmd.version={{.Version}} 35 | archives: 36 | - format: tar.gz 37 | name_template: 38 | "{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ 39 | .Arm }}{{ end }}" 40 | files: 41 | - LICENSE* 42 | - README* 43 | - CHANGELOG* 44 | - misc/completions/zsh/_qc 45 | nfpms: 46 | - homepage: https://github.com/qownnotes/qc 47 | maintainer: Patrizio Bekerle 48 | description: "QOwnNotes command-line snippet manager" 49 | bindir: /usr/local/bin 50 | license: MIT 51 | formats: 52 | - deb 53 | - rpm 54 | checksum: 55 | name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt" 56 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # QOwnNotes command-line snippet manager changelog 2 | 3 | ## v0.6.1 4 | 5 | - The `--atuin` flag now also add commands to [Atuin](https://atuin.sh/) for multi-line commands 6 | (for [#15](https://github.com/qownnotes/qc/issues/15)) 7 | - The `--color` flag now shows the command description in a calmer green, instead of red 8 | (for [#16](https://github.com/qownnotes/qc/issues/16)) 9 | 10 | ## v0.6.0 11 | 12 | - Add support for storing commands in [Atuin](https://atuin.sh/) on execution 13 | when using the `--atuin` flag (for [#15](https://github.com/qownnotes/qc/issues/15)) 14 | - This only works for single-line commands 15 | - Update dependencies 16 | 17 | ## v0.5.1 18 | 19 | - The last selected command is now only stored when there actually was a command selected and 20 | the dialog wasn't quit without selecting a command (for [#9](https://github.com/qownnotes/qc/issues/9)) 21 | 22 | ## v0.5.0 23 | 24 | - The last executed command is now stored and can be executed again via `qc exec --last` 25 | (for [#9](https://github.com/qownnotes/qc/issues/9)) 26 | - Neovim is now also used to edit the config file 27 | 28 | ## v0.4.0 29 | 30 | - Add support for note folder switching (for [#5](https://github.com/qownnotes/qc/issues/5)) 31 | - Use `qc switch` to get a list of note folders to select which note folder to switch to 32 | - Use `qc switch -f ` to make QOwnNotes switch to another note folder instantly 33 | - Needs OwnNotes at least at version 22.7.1 34 | 35 | ## v0.3.2 36 | 37 | - Add Homebrew tap for qc (`brew install qownnotes/qc/qc`) 38 | 39 | ## v0.3.0 40 | 41 | - Enable sorting of snippets via settings and allow sorting case-insensitively 42 | 43 | ## v0.2.0 44 | 45 | - Cache snippets in case QOwnNotes is not running 46 | - Don't throw an error if selectCmd was exited with an error code (e.g. by `Ctrl + C`) 47 | 48 | ## v0.1.0 49 | 50 | - Support for fetching snippets from QOwnNotes via websocket 51 | - Searching in snippets 52 | - Executing snippets 53 | - Configuring the application 54 | - Generating autocompletion scripts for shells 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # QOwnNotes command-line snippet manager 2 | 3 | [GitHub](https://github.com/qownnotes/qc) | 4 | [Changelog](https://github.com/qownnotes/qc/blob/main/CHANGELOG.md) | 5 | [Releases](https://github.com/qownnotes/qc/releases) 6 | 7 | You can use the **QOwnNotes command-line snippet manager** to **execute command snippets** stored 8 | in **notes** in [QOwnNotes](https://www.qownnotes.org/) from the command line. 9 | 10 | ![qc](qc.png) 11 | 12 | You can use **notes with a special tag** (`commands` by default) to store **command snippets**, which you can 13 | **execute from the command-line snippet manager**. 14 | 15 | ![commands](commands.png) 16 | 17 | For more information on **how to add commands and configuration** please see 18 | [Command-line Snippet Manager](https://www.qownnotes.org/getting-started/command-line-snippet-manager.html). 19 | 20 | The QOwnNotes command-line snippet manager is based on the wonderful 21 | [pet CLI Snippet Manager](https://github.com/knqyf263/pet). 22 | 23 | ## Installation 24 | 25 | Visit the [latest release page](https://github.com/qownnotes/qc/releases/latest) 26 | and download the version you need. 27 | 28 | If you have [jq](https://stedolan.github.io/jq) installed you can also use this snippet 29 | to download and install for example the latest Linux AMD64 binary to `/usr/local/bin/qc`: 30 | 31 | ```bash 32 | curl https://api.github.com/repos/qownnotes/qc/releases/latest | \ 33 | jq '.assets[] | select(.browser_download_url | endswith("_linux_amd64.tar.gz")) | .browser_download_url' | \ 34 | xargs curl -Lo /tmp/qc.tar.gz && \ 35 | tar xfz /tmp/qc.tar.gz -C /tmp && \ 36 | rm /tmp/qc.tar.gz && \ 37 | sudo mv /tmp/qc /usr/local/bin/qc && \ 38 | /usr/local/bin/qc version 39 | ``` 40 | 41 | ### macOS / Homebrew 42 | 43 | You can use homebrew on macOS to install qc. 44 | 45 | ```bash 46 | brew install qownnotes/qc/qc 47 | ``` 48 | 49 | If you receive an error (`Error: qownnotes/qc/qc 64 already installed`) during `brew upgrade`, 50 | try the following command: 51 | 52 | ```bash 53 | brew unlink qc && brew uninstall qc 54 | rm -rf /usr/local/Cellar/qc/64 55 | brew install qownnotes/qc/qc 56 | ``` 57 | 58 | ## Dependencies 59 | 60 | [fzf](https://github.com/junegunn/fzf) (fuzzy search) or [peco](https://github.com/peco/peco) 61 | (older, but more likely to be installed by default) need to be installed to search 62 | for commands on the command-line. 63 | 64 | By default `fzf` is used for searching, but you can use `peco` by setting it with `qc configure`. 65 | 66 | On some system you might need to set `fzf --ansi` when you use the qc `--color` flag. 67 | If you don't want the fuzzy search of fzf, you can use the fzf `--exact` flag. 68 | 69 | ## Usage 70 | 71 | ``` 72 | Usage: 73 | qc [command] 74 | 75 | Available Commands: 76 | completion generate the autocompletion script for the specified shell 77 | configure Edit config file 78 | exec Run the selected commands 79 | help Help about any command 80 | search Search snippets 81 | version Print the version number 82 | 83 | Flags: 84 | --config string config file (default is $HOME/.config/qc/config.toml) 85 | --debug debug mode 86 | -h, --help help for qc 87 | 88 | Use "qc [command] --help" for more information about a command. 89 | ``` 90 | 91 | ## Configuration 92 | 93 | Run `qc configure`. 94 | 95 | ```toml 96 | [General] 97 | editor = "vim" # your favorite text editor 98 | column = 40 # column size for list command 99 | selectcmd = "fzf" # selector command for edit command (fzf or peco) 100 | sortby = "" # specify how snippets get sorted (recency (default), -recency, description, -description, command, -command, output, -output) 101 | 102 | [QOwnNotes] 103 | token = "SECRET" # your QOwnNotes API token 104 | websocket_port = 22222 # websocket port in QOwnNotes 105 | ``` 106 | 107 | ## Shell completion 108 | 109 | You can generate shell completion code for your shell with `qc completion `. 110 | 111 | For example for the Fish shell you can use: 112 | 113 | ```bash 114 | qc completion fish > ~/.config/fish/completions/qc.fish 115 | ``` 116 | 117 | ## Atuin integration 118 | 119 | Executed commands can be stored in [Atuin](https://atuin.sh/) on execution 120 | when using the `--atuin` flag. They will then show up in the Atuin command history. 121 | 122 | ```bash 123 | qc exec --atuin 124 | ``` 125 | -------------------------------------------------------------------------------- /cmd/configure.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/qownnotes/qc/config" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | // configureCmd represents the configure command 9 | var configureCmd = &cobra.Command{ 10 | Use: "configure", 11 | Short: "Edit config file", 12 | Long: `Edit config file (default: opened by vim)`, 13 | RunE: configure, 14 | } 15 | 16 | func configure(cmd *cobra.Command, args []string) (err error) { 17 | editor := config.Conf.General.Editor 18 | return editFile(editor, configFile) 19 | } 20 | 21 | func init() { 22 | RootCmd.AddCommand(configureCmd) 23 | } 24 | -------------------------------------------------------------------------------- /cmd/exec.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/fatih/color" 12 | "github.com/qownnotes/qc/config" 13 | "github.com/spf13/cobra" 14 | "gopkg.in/alessio/shellescape.v1" 15 | ) 16 | 17 | // execCmd represents the exec command 18 | var execCmd = &cobra.Command{ 19 | Use: "exec", 20 | Short: "Run the selected commands", 21 | Long: `Run the selected commands directly`, 22 | RunE: execute, 23 | } 24 | 25 | var ( 26 | lastCmdFile string 27 | ) 28 | 29 | func execute(cmd *cobra.Command, args []string) (err error) { 30 | flag := config.Flag 31 | 32 | var options []string 33 | var command string 34 | var writeLastCmd bool 35 | 36 | if flag.Query != "" { 37 | options = append(options, fmt.Sprintf("--query %s", shellescape.Quote(flag.Query))) 38 | } 39 | 40 | if config.Flag.Last { 41 | command = readLastCmdFile() 42 | } 43 | 44 | if command == "" { 45 | commands, err := filter(options, flag.FilterTag) 46 | if err != nil { 47 | return err 48 | } 49 | command = strings.Join(commands, "; ") 50 | writeLastCmd = true 51 | } 52 | 53 | if config.Flag.Debug { 54 | fmt.Printf("Command: %s\n", command) 55 | } 56 | 57 | if config.Flag.Command { 58 | fmt.Printf("%s: %s\n", color.YellowString("Command"), command) 59 | } 60 | 61 | if command == "" { 62 | return nil 63 | } 64 | 65 | if writeLastCmd { 66 | // store last command 67 | writeLastCmdFile(command) 68 | } 69 | 70 | // Add commands to the Atuin history 71 | if config.Flag.Atuin { 72 | escapedCommand := escapeCommandForShell(command) 73 | command = `histid=$(atuin history start -- ` + escapedCommand + `` + ")\n" + command + 74 | "\natuin history end --exit $? $histid" 75 | } 76 | 77 | return run(command, os.Stdin, os.Stdout) 78 | } 79 | 80 | // escapeCommandForShell escapes a command for use in a shell script. 81 | // It also works for multi-line commands. 82 | func escapeCommandForShell(command string) string { 83 | // Trim and split the command into lines 84 | lines := strings.Split(strings.Trim(command, "\n"), "\n") 85 | 86 | // Quote each line individually and join with literal $'\n' 87 | quotedLines := make([]string, len(lines)) 88 | for i, line := range lines { 89 | // Use single quotes to avoid most escaping issues 90 | quotedLines[i] = "'" + strings.ReplaceAll(line, "'", "'\"'\"'") + "'" 91 | } 92 | 93 | return strings.Join(quotedLines, "$'\\n'") 94 | } 95 | 96 | func init() { 97 | RootCmd.AddCommand(execCmd) 98 | execCmd.Flags().BoolVarP(&config.Flag.Color, "color", "", false, 99 | `Enable colorized output (only fzf)`) 100 | execCmd.Flags().StringVarP(&config.Flag.Query, "query", "q", "", 101 | `Initial value for query`) 102 | execCmd.Flags().StringVarP(&config.Flag.FilterTag, "tag", "t", "", 103 | `Filter tag`) 104 | execCmd.Flags().BoolVarP(&config.Flag.Command, "command", "c", false, 105 | `Show the command with the plain text before executing`) 106 | execCmd.Flags().BoolVarP(&config.Flag.Last, "last", "l", false, 107 | `Execute the last command`) 108 | execCmd.Flags().BoolVarP(&config.Flag.Atuin, "atuin", "a", false, 109 | `Store single-line command in Atuin history`) 110 | 111 | initLastCmdFile() 112 | } 113 | 114 | func initLastCmdFile() { 115 | if lastCmdFile == "" { 116 | dir, err := config.GetDefaultConfigDir() 117 | if err != nil { 118 | fmt.Fprintf(os.Stderr, "%v", err) 119 | os.Exit(1) 120 | } 121 | 122 | lastCmdFile = filepath.Join(dir, "lastcmd") 123 | } 124 | } 125 | 126 | func writeLastCmdFile(cmd string) { 127 | if err := os.WriteFile(lastCmdFile, []byte(cmd), 0600); err != nil { 128 | log.Fatal("Could not write last command file: ", err) 129 | } 130 | } 131 | 132 | func readLastCmdFile() string { 133 | _, err := os.Stat(lastCmdFile) 134 | 135 | if errors.Is(err, os.ErrNotExist) { 136 | return "" 137 | } 138 | 139 | data, err := os.ReadFile(lastCmdFile) 140 | 141 | if err != nil { 142 | log.Fatal("Could not read last command file: ", err) 143 | } 144 | 145 | return string(data) 146 | } 147 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/qownnotes/qc/config" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | var ( 13 | configFile string 14 | version = "dev" 15 | ) 16 | 17 | // RootCmd represents the base command when called without any subcommands 18 | var RootCmd = &cobra.Command{ 19 | Use: "qc", 20 | Short: "QOwnNotes command-line snippet manager.", 21 | Long: `qc - QOwnNotes command-line snippet manager.`, 22 | SilenceErrors: true, 23 | SilenceUsage: true, 24 | } 25 | 26 | // Execute adds all child commands to the root command sets flags appropriately. 27 | func Execute() { 28 | if err := RootCmd.Execute(); err != nil { 29 | fmt.Println(err) 30 | os.Exit(-1) 31 | } 32 | } 33 | 34 | func init() { 35 | cobra.OnInitialize(initConfig) 36 | RootCmd.AddCommand(versionCmd) 37 | 38 | RootCmd.PersistentFlags().StringVar(&configFile, "config", "", "config file (default is $HOME/.config/qc/config.toml)") 39 | RootCmd.PersistentFlags().BoolVarP(&config.Flag.Debug, "debug", "", false, "debug mode") 40 | } 41 | 42 | var versionCmd = &cobra.Command{ 43 | Use: "version", 44 | Short: "Print the version number", 45 | Long: `Print the version number`, 46 | Run: func(cmd *cobra.Command, args []string) { 47 | fmt.Printf("qc version %s\n", version) 48 | }, 49 | } 50 | 51 | // initConfig reads in config file and ENV variables if set. 52 | func initConfig() { 53 | if configFile == "" { 54 | dir, err := config.GetDefaultConfigDir() 55 | if err != nil { 56 | fmt.Fprintf(os.Stderr, "%v", err) 57 | os.Exit(1) 58 | } 59 | configFile = filepath.Join(dir, "config.toml") 60 | } 61 | 62 | if err := config.Conf.Load(configFile); err != nil { 63 | fmt.Fprintf(os.Stderr, "%v", err) 64 | os.Exit(1) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /cmd/search.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/qownnotes/qc/config" 8 | "github.com/spf13/cobra" 9 | "golang.org/x/crypto/ssh/terminal" 10 | "gopkg.in/alessio/shellescape.v1" 11 | ) 12 | 13 | var delimiter string 14 | 15 | // searchCmd represents the search command 16 | var searchCmd = &cobra.Command{ 17 | Use: "search", 18 | Short: "Search snippets", 19 | Long: `Search snippets interactively (default filtering tool: peco)`, 20 | RunE: search, 21 | } 22 | 23 | func search(cmd *cobra.Command, args []string) (err error) { 24 | flag := config.Flag 25 | 26 | var options []string 27 | if flag.Query != "" { 28 | options = append(options, fmt.Sprintf("--query %s", shellescape.Quote(flag.Query))) 29 | } 30 | commands, err := filter(options, flag.FilterTag) 31 | if err != nil { 32 | return err 33 | } 34 | 35 | fmt.Print(strings.Join(commands, flag.Delimiter)) 36 | if terminal.IsTerminal(1) { 37 | fmt.Print("\n") 38 | } 39 | return nil 40 | } 41 | 42 | func init() { 43 | RootCmd.AddCommand(searchCmd) 44 | searchCmd.Flags().BoolVarP(&config.Flag.Color, "color", "", false, 45 | `Enable colorized output (only fzf)`) 46 | searchCmd.Flags().StringVarP(&config.Flag.Query, "query", "q", "", 47 | `Initial value for query`) 48 | searchCmd.Flags().StringVarP(&config.Flag.FilterTag, "tag", "t", "", 49 | `Filter tag`) 50 | searchCmd.Flags().StringVarP(&config.Flag.Delimiter, "delimiter", "d", "; ", 51 | `Use delim as the command delimiter character`) 52 | } 53 | -------------------------------------------------------------------------------- /cmd/switch.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "github.com/qownnotes/qc/config" 7 | "github.com/qownnotes/qc/websocket" 8 | "github.com/spf13/cobra" 9 | "os" 10 | "strconv" 11 | ) 12 | 13 | // switchCmd represents the switch command 14 | var switchCmd = &cobra.Command{ 15 | Use: "switch", 16 | Short: "Switch note folder", 17 | Long: `Switch to a different note folder`, 18 | RunE: switchNoteFolder, 19 | } 20 | 21 | func switchNoteFolder(cmd *cobra.Command, args []string) (err error) { 22 | flag := config.Flag 23 | 24 | if flag.Query != "" { 25 | id, _ := strconv.Atoi(flag.Query) 26 | fmt.Printf("Attempting to switch to note folder number %d!\n", id) 27 | websocket.SwitchNoteFolder(id) 28 | 29 | return nil 30 | } 31 | 32 | noteFolderData, currentId := websocket.FetchNoteFolderData() 33 | 34 | for _, noteFolder := range noteFolderData { 35 | currentText := "" 36 | 37 | if currentId == noteFolder.Id { 38 | currentText = "*" 39 | } 40 | 41 | fmt.Printf("%d%s) %s\n", noteFolder.Id, currentText, noteFolder.Name) 42 | } 43 | 44 | fmt.Print("\nSelect note folder to switch to: ") 45 | 46 | scanner := bufio.NewScanner(os.Stdin) 47 | scanner.Scan() 48 | 49 | if err := scanner.Err(); err != nil { 50 | fmt.Fprintln(os.Stderr, "Error reading standard input:", err) 51 | 52 | return nil 53 | } 54 | 55 | id, _ := strconv.Atoi(scanner.Text()) 56 | 57 | for _, noteFolder := range noteFolderData { 58 | if noteFolder.Id == id { 59 | websocket.SwitchNoteFolder(id) 60 | return nil 61 | } 62 | } 63 | 64 | fmt.Printf("Could not find note folder number %d!\n\n", id) 65 | 66 | return switchNoteFolder(cmd, args) 67 | } 68 | 69 | func init() { 70 | RootCmd.AddCommand(switchCmd) 71 | switchCmd.Flags().StringVarP(&config.Flag.Query, "folder", "f", "", 72 | `Note folder id to switch to`) 73 | } 74 | -------------------------------------------------------------------------------- /cmd/util.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "github.com/qownnotes/qc/entity" 7 | "io" 8 | "os" 9 | "os/exec" 10 | "runtime" 11 | "strings" 12 | 13 | "github.com/fatih/color" 14 | "github.com/qownnotes/qc/config" 15 | "github.com/qownnotes/qc/dialog" 16 | "github.com/qownnotes/qc/snippet" 17 | ) 18 | 19 | func editFile(command, file string) error { 20 | command += " " + file 21 | return run(command, os.Stdin, os.Stdout) 22 | } 23 | 24 | func run(command string, r io.Reader, w io.Writer) error { 25 | var cmd *exec.Cmd 26 | if runtime.GOOS == "windows" { 27 | cmd = exec.Command("cmd", "/c", command) 28 | } else { 29 | cmd = exec.Command("sh", "-c", command) 30 | } 31 | cmd.Stderr = os.Stderr 32 | cmd.Stdout = w 33 | cmd.Stdin = r 34 | return cmd.Run() 35 | } 36 | 37 | func filter(options []string, tag string) (commands []string, err error) { 38 | var snippets snippet.Snippets 39 | if err := snippets.Load(); err != nil { 40 | return commands, fmt.Errorf("Load snippet failed: %v", err) 41 | } 42 | 43 | if 0 < len(tag) { 44 | var filteredSnippets snippet.Snippets 45 | for _, snippet := range snippets.Snippets { 46 | for _, t := range snippet.Tag { 47 | if tag == t { 48 | filteredSnippets.Snippets = append(filteredSnippets.Snippets, snippet) 49 | } 50 | } 51 | } 52 | snippets = filteredSnippets 53 | } 54 | 55 | //log.Printf("snippets: %v", snippets) 56 | 57 | snippetTexts := map[string]entity.SnippetInfo{} 58 | var text string 59 | for _, s := range snippets.Snippets { 60 | command := s.Command 61 | if strings.ContainsAny(command, "\n") { 62 | command = strings.Replace(command, "\n", "\\n", -1) 63 | } 64 | t := fmt.Sprintf("[%s]: %s", s.Description, command) 65 | 66 | tags := "" 67 | for _, tag := range s.Tag { 68 | tags += fmt.Sprintf(" #%s", tag) 69 | } 70 | t += tags 71 | 72 | snippetTexts[t] = s 73 | if config.Flag.Color { 74 | t = fmt.Sprintf("[%s]: %s%s", 75 | color.GreenString(s.Description), command, color.BlueString(tags)) 76 | } 77 | text += t + "\n" 78 | } 79 | 80 | var buf bytes.Buffer 81 | selectCmd := fmt.Sprintf("%s %s", 82 | config.Conf.General.SelectCmd, strings.Join(options, " ")) 83 | err = run(selectCmd, strings.NewReader(text), &buf) 84 | if err != nil { 85 | return nil, nil 86 | } 87 | 88 | lines := strings.Split(strings.TrimSuffix(buf.String(), "\n"), "\n") 89 | 90 | //log.Printf("lines: %v", lines) 91 | 92 | params := dialog.SearchForParams(lines) 93 | if params != nil { 94 | snippetInfo := snippetTexts[lines[0]] 95 | dialog.CurrentCommand = snippetInfo.Command 96 | dialog.GenerateParamsLayout(params, dialog.CurrentCommand) 97 | res := []string{dialog.FinalCommand} 98 | return res, nil 99 | } 100 | for _, line := range lines { 101 | snippetInfo := snippetTexts[line] 102 | commands = append(commands, fmt.Sprint(snippetInfo.Command)) 103 | } 104 | 105 | //log.Printf("commands: %v", commands) 106 | 107 | return commands, nil 108 | } 109 | -------------------------------------------------------------------------------- /commands.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qownnotes/qc/2ebdf36b072bb1121c19116c124d4c09ad83dee4/commands.png -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "runtime" 9 | 10 | "github.com/BurntSushi/toml" 11 | ) 12 | 13 | // Conf is global config variable 14 | var Conf Config 15 | 16 | // Config is a struct of config 17 | type Config struct { 18 | General GeneralConfig 19 | QOwnNotes QOwnNotesConfig 20 | } 21 | 22 | // QOwnNotesConfig is a struct of config for QOwnNotes 23 | type QOwnNotesConfig struct { 24 | Token string `toml:"token"` 25 | WebSocketPort int `toml:"websocket_port"` 26 | } 27 | 28 | // Flag is global flag variable 29 | var Flag FlagConfig 30 | 31 | // FlagConfig is a struct of flag 32 | type FlagConfig struct { 33 | Debug bool 34 | Query string 35 | Command bool 36 | Atuin bool 37 | FilterTag string 38 | Color bool 39 | Delimiter string 40 | Last bool 41 | } 42 | 43 | // GeneralConfig is a struct of general config 44 | type GeneralConfig struct { 45 | Editor string `toml:"editor"` 46 | Column int `toml:"column"` 47 | SelectCmd string `toml:"selectcmd"` 48 | SortBy string `toml:"sortby"` 49 | } 50 | 51 | // Load loads a config toml 52 | func (cfg *Config) Load(file string) error { 53 | _, err := os.Stat(file) 54 | if err == nil { 55 | _, err := toml.DecodeFile(file, cfg) 56 | if err != nil { 57 | return err 58 | } 59 | return nil 60 | } 61 | 62 | if !os.IsNotExist(err) { 63 | return err 64 | } 65 | f, err := os.Create(file) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | cfg.General.Editor = os.Getenv("EDITOR") 71 | if cfg.General.Editor == "" && runtime.GOOS != "windows" { 72 | if isCommandAvailable("sensible-editor") { 73 | cfg.General.Editor = "sensible-editor" 74 | } else if isCommandAvailable("nvim") { 75 | cfg.General.Editor = "nvim" 76 | } else { 77 | cfg.General.Editor = "vim" 78 | } 79 | } 80 | cfg.General.Column = 40 81 | cfg.General.SelectCmd = "fzf" 82 | cfg.QOwnNotes.WebSocketPort = 22222 83 | 84 | return toml.NewEncoder(f).Encode(cfg) 85 | } 86 | 87 | // GetDefaultConfigDir returns the default config directory 88 | func GetDefaultConfigDir() (dir string, err error) { 89 | if runtime.GOOS == "windows" { 90 | dir = os.Getenv("APPDATA") 91 | if dir == "" { 92 | dir = filepath.Join(os.Getenv("USERPROFILE"), "Application Data", "qc") 93 | } 94 | dir = filepath.Join(dir, "qc") 95 | } else { 96 | dir = filepath.Join(os.Getenv("HOME"), ".config", "qc") 97 | } 98 | if err := os.MkdirAll(dir, 0700); err != nil { 99 | return "", fmt.Errorf("cannot create directory: %v", err) 100 | } 101 | return dir, nil 102 | } 103 | 104 | func expandPath(s string) string { 105 | if len(s) >= 2 && s[0] == '~' && os.IsPathSeparator(s[1]) { 106 | if runtime.GOOS == "windows" { 107 | s = filepath.Join(os.Getenv("USERPROFILE"), s[2:]) 108 | } else { 109 | s = filepath.Join(os.Getenv("HOME"), s[2:]) 110 | } 111 | } 112 | return os.Expand(s, os.Getenv) 113 | } 114 | 115 | func isCommandAvailable(name string) bool { 116 | cmd := exec.Command("/bin/sh", "-c", "command -v "+name) 117 | if err := cmd.Run(); err != nil { 118 | return false 119 | } 120 | return true 121 | } 122 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | { 2 | buildGoModule, 3 | installShellFiles, 4 | lib, 5 | }: 6 | 7 | buildGoModule rec { 8 | pname = "qc"; 9 | version = "0.5.1"; 10 | 11 | src = builtins.path { 12 | path = ./.; 13 | name = "qc"; 14 | }; 15 | 16 | vendorSha256 = "sha256-7t5rQliLm6pMUHhtev/kNrQ7AOvmA/rR93SwNQhov6o="; 17 | 18 | ldflags = [ 19 | "-s" 20 | "-w" 21 | "-X=github.com/qownnotes/qc/cmd.version=${version}" 22 | ]; 23 | 24 | doCheck = false; 25 | 26 | subPackages = [ "." ]; 27 | 28 | nativeBuildInputs = [ 29 | installShellFiles 30 | ]; 31 | 32 | postInstall = '' 33 | # for some reason we need a writable home directory, or the completion files will be empty 34 | export HOME=$(mktemp -d) 35 | installShellCompletion --cmd qc \ 36 | --bash <($out/bin/qc completion bash) \ 37 | --fish <($out/bin/qc completion fish) \ 38 | --zsh <($out/bin/qc completion zsh) 39 | ''; 40 | 41 | meta = with lib; { 42 | description = "QOwnNotes command-line snippet manager"; 43 | homepage = "https://github.com/qownnotes/qc"; 44 | license = licenses.mit; 45 | maintainers = with maintainers; [ pbek ]; 46 | platforms = platforms.linux ++ platforms.darwin; 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /dialog/params.go: -------------------------------------------------------------------------------- 1 | package dialog 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | 7 | "github.com/jroimartin/gocui" 8 | ) 9 | 10 | var ( 11 | views = []string{} 12 | layoutStep = 3 13 | curView = -1 14 | idxView = 0 15 | 16 | //CurrentCommand is the command before assigning to variables 17 | CurrentCommand string 18 | //FinalCommand is the command after assigning to variables 19 | FinalCommand string 20 | ) 21 | 22 | func insertParams(command string, params map[string]string) string { 23 | resultCommand := command 24 | for k, v := range params { 25 | resultCommand = strings.Replace(resultCommand, k, v, -1) 26 | } 27 | return resultCommand 28 | } 29 | 30 | // SearchForParams returns variables from a command 31 | func SearchForParams(lines []string) map[string]string { 32 | re := `<([\S].+?[\S])>` 33 | if len(lines) == 1 { 34 | r, _ := regexp.Compile(re) 35 | 36 | params := r.FindAllStringSubmatch(lines[0], -1) 37 | if len(params) == 0 { 38 | return nil 39 | } 40 | 41 | extracted := map[string]string{} 42 | for _, p := range params { 43 | splitted := strings.Split(p[1], "=") 44 | if len(splitted) == 1 { 45 | extracted[p[0]] = "" 46 | } else { 47 | extracted[p[0]] = splitted[1] 48 | } 49 | } 50 | return extracted 51 | } 52 | return nil 53 | } 54 | 55 | func evaluateParams(g *gocui.Gui, _ *gocui.View) error { 56 | paramsFilled := map[string]string{} 57 | for _, v := range views { 58 | view, _ := g.View(v) 59 | res := view.Buffer() 60 | res = strings.Replace(res, "\n", "", -1) 61 | paramsFilled[v] = strings.TrimSpace(res) 62 | } 63 | FinalCommand = insertParams(CurrentCommand, paramsFilled) 64 | return gocui.ErrQuit 65 | } 66 | -------------------------------------------------------------------------------- /dialog/util.go: -------------------------------------------------------------------------------- 1 | package dialog 2 | 3 | func StringInSlice(a string, list []string) bool { 4 | for _, b := range list { 5 | if b == a { 6 | return true 7 | } 8 | } 9 | return false 10 | } 11 | -------------------------------------------------------------------------------- /dialog/view.go: -------------------------------------------------------------------------------- 1 | package dialog 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/jroimartin/gocui" 8 | ) 9 | 10 | func generateView(g *gocui.Gui, desc string, fill string, coords []int, editable bool) error { 11 | if StringInSlice(desc, views) { 12 | return nil 13 | } 14 | if v, err := g.SetView(desc, coords[0], coords[1], coords[2], coords[3]); err != nil { 15 | if err != gocui.ErrUnknownView { 16 | return err 17 | } 18 | fmt.Fprint(v, fill) 19 | } 20 | view, _ := g.View(desc) 21 | view.Title = desc 22 | view.Wrap = false 23 | view.Autoscroll = true 24 | view.Editable = editable 25 | 26 | views = append(views, desc) 27 | idxView++ 28 | 29 | return nil 30 | } 31 | 32 | // GenerateParamsLayout generates CUI to receive params 33 | func GenerateParamsLayout(params map[string]string, command string) { 34 | g, err := gocui.NewGui(gocui.OutputNormal) 35 | if err != nil { 36 | log.Panicln(err) 37 | } 38 | defer g.Close() 39 | 40 | g.Highlight = true 41 | g.Cursor = true 42 | g.SelFgColor = gocui.ColorGreen 43 | 44 | g.SetManagerFunc(layout) 45 | 46 | maxX, maxY := g.Size() 47 | generateView(g, "Command(TAB => Select next, ENTER => Execute command):", 48 | command, []int{maxX / 10, maxY / 10, (maxX / 2) + (maxX / 3), maxY/10 + 5}, false) 49 | idx := 0 50 | for k, v := range params { 51 | generateView(g, k, v, []int{maxX / 10, (maxY / 4) + (idx+1)*layoutStep, 52 | maxX/10 + 20, (maxY / 4) + 2 + (idx+1)*layoutStep}, true) 53 | idx++ 54 | } 55 | 56 | initKeybindings(g) 57 | 58 | curView = 0 59 | if idx > 0 { 60 | curView = 1 61 | } 62 | g.SetCurrentView(views[curView]) 63 | 64 | if err := g.MainLoop(); err != nil && err != gocui.ErrQuit { 65 | log.Panicln(err) 66 | } 67 | } 68 | 69 | func nextView(g *gocui.Gui) error { 70 | next := curView + 1 71 | if next > len(views)-1 { 72 | next = 0 73 | } 74 | 75 | if _, err := g.SetCurrentView(views[next]); err != nil { 76 | return err 77 | } 78 | 79 | curView = next 80 | return nil 81 | } 82 | 83 | func initKeybindings(g *gocui.Gui) error { 84 | if err := g.SetKeybinding("", gocui.KeyCtrlC, gocui.ModNone, quit); err != nil { 85 | return err 86 | } 87 | 88 | if err := g.SetKeybinding("", gocui.KeyEnter, gocui.ModNone, evaluateParams); err != nil { 89 | return err 90 | } 91 | if err := g.SetKeybinding("", gocui.KeyTab, gocui.ModNone, 92 | func(g *gocui.Gui, v *gocui.View) error { 93 | return nextView(g) 94 | }); err != nil { 95 | return err 96 | } 97 | return nil 98 | } 99 | 100 | func layout(g *gocui.Gui) error { 101 | return nil 102 | } 103 | 104 | func quit(_ *gocui.Gui, _ *gocui.View) error { 105 | return gocui.ErrQuit 106 | } 107 | -------------------------------------------------------------------------------- /entity/entity.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | type SnippetInfo struct { 4 | Description string `json:"description"` 5 | Command string `json:"command"` 6 | Tag []string `json:"tags"` 7 | Output string `json:"output"` 8 | } 9 | 10 | type NoteFolderInfo struct { 11 | Name string `json:"text"` 12 | Id int `json:"value"` 13 | } 14 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/qownnotes/qc 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/BurntSushi/toml v1.4.0 7 | github.com/fatih/color v1.17.0 8 | github.com/gorilla/websocket v1.5.3 9 | github.com/jroimartin/gocui v0.5.0 10 | github.com/spf13/cobra v1.8.1 11 | golang.org/x/crypto v0.28.0 12 | gopkg.in/alessio/shellescape.v1 v1.0.0-20170105083845-52074bc9df61 13 | ) 14 | 15 | require ( 16 | github.com/alessio/shellescape v1.4.1 // indirect 17 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 18 | github.com/mattn/go-colorable v0.1.13 // indirect 19 | github.com/mattn/go-isatty v0.0.20 // indirect 20 | github.com/mattn/go-runewidth v0.0.16 // indirect 21 | github.com/nsf/termbox-go v1.1.1 // indirect 22 | github.com/rivo/uniseg v0.4.7 // indirect 23 | github.com/spf13/pflag v1.0.5 // indirect 24 | golang.org/x/sys v0.26.0 // indirect 25 | golang.org/x/term v0.25.0 // indirect 26 | ) 27 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= 2 | github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 3 | github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= 4 | github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= 5 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 6 | github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= 7 | github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= 8 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 9 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 10 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 11 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 12 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 13 | github.com/jroimartin/gocui v0.5.0 h1:DCZc97zY9dMnHXJSJLLmx9VqiEnAj0yh0eTNpuEtG/4= 14 | github.com/jroimartin/gocui v0.5.0/go.mod h1:l7Hz8DoYoL6NoYnlnaX6XCNR62G7J5FfSW5jEogzaxE= 15 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 16 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 17 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 18 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 19 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 20 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 21 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 22 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 23 | github.com/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY= 24 | github.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo= 25 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 26 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 27 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 28 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 29 | github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= 30 | github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 31 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 32 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 33 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 34 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 35 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 36 | golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= 37 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 38 | golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 39 | golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= 40 | golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= 41 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 42 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 43 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 44 | golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 45 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 46 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 47 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 48 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 49 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 50 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 51 | golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= 52 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 53 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 54 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 55 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 56 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 57 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 58 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 59 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 60 | golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 61 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 62 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 63 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 64 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 65 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 66 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 67 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 68 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 69 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 70 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 71 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 72 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 73 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 74 | golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= 75 | golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 76 | golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 77 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 78 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 79 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 80 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 81 | golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= 82 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 83 | golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 84 | golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= 85 | golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= 86 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 87 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 88 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 89 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 90 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 91 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 92 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 93 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 94 | golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= 95 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 96 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 97 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 98 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 99 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 100 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 101 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 102 | gopkg.in/alessio/shellescape.v1 v1.0.0-20170105083845-52074bc9df61 h1:8ajkpB4hXVftY5ko905id+dOnmorcS2CHNxxHLLDcFM= 103 | gopkg.in/alessio/shellescape.v1 v1.0.0-20170105083845-52074bc9df61/go.mod h1:IfMagxm39Ys4ybJrDb7W3Ob8RwxftP0Yy+or/NVz1O8= 104 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 105 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 106 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | # Use `just ` to run a recipe 2 | # https://just.systems/man/en/ 3 | 4 | # By default, run the `--list` command 5 | default: 6 | @just --list 7 | 8 | # Aliases 9 | 10 | alias fmt := format 11 | 12 | # Download dependencies 13 | [group('build')] 14 | dep: 15 | go mod download 16 | 17 | # Update dependencies 18 | [group('build')] 19 | update: 20 | go get -u 21 | go mod tidy 22 | 23 | # Build the project 24 | [group('build')] 25 | build: 26 | go build -o qc main.go 27 | 28 | # Execute the built binary 29 | [group('build')] 30 | exec +ARGS='': 31 | ./qc exec {{ARGS}} 32 | 33 | # Build and execute in one command 34 | [group('build')] 35 | build-exec +ARGS='': build 36 | just exec {{ARGS}} 37 | 38 | # Install the project 39 | [group('build')] 40 | install: 41 | go install 42 | 43 | # Run tests 44 | [group('test')] 45 | test: 46 | go test ./... 47 | 48 | # Run go vet 49 | [group('test')] 50 | vet: 51 | go vet 52 | 53 | # Build using nix 54 | [group('nix')] 55 | nix-build: 56 | nix-build -E '((import {}).callPackage (import ./default.nix) { })' 57 | 58 | # Force build using nix 59 | [group('nix')] 60 | nix-build-force: 61 | nix-build -E '((import {}).callPackage (import ./default.nix) { })' --check 62 | 63 | # Format all justfiles 64 | [group('linter')] 65 | just-format: 66 | #!/usr/bin/env bash 67 | # Find all files named "justfile" recursively and run just --fmt --unstable on them 68 | find . -type f -name "justfile" -print0 | while IFS= read -r -d '' file; do 69 | echo "Formatting $file" 70 | just --fmt --unstable -f "$file" 71 | done 72 | 73 | # Format all files 74 | [group('linter')] 75 | format args='': 76 | nix-shell -p treefmt go nodePackages.prettier shfmt nixfmt-rfc-style statix taplo --run "treefmt {{ args }}" 77 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/qownnotes/qc/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /misc/completions/zsh/_qc: -------------------------------------------------------------------------------- 1 | #compdef qc 2 | # vim: ft=zsh 3 | 4 | _qc () { 5 | local -a _1st_arguments 6 | _1st_arguments=( 7 | 'configure:Edit config file' 8 | 'exec:Run the selected commands' 9 | 'help:Help about any command' 10 | 'search:Search snippets' 11 | 'version:Print the version number' 12 | ) 13 | 14 | _arguments \ 15 | '(--help)--help[show this help message]' \ 16 | '(--config)--config=[config file (default is $HOME/.config/qc/config.toml)]' \ 17 | '(--debug)--debug[debug mode]' \ 18 | '*:: :->subcmds' \ 19 | && return 0 20 | 21 | if (( CURRENT == 1 )); then 22 | _describe -t commands "qc subcommand" _1st_arguments 23 | return 24 | fi 25 | 26 | case "$words[1]" in 27 | ("configure"|"version") 28 | _arguments \ 29 | '(- :)'{-h,--help}'[Show this help and exit]' \ 30 | && return 0 31 | ;; 32 | ("exec") 33 | _arguments \ 34 | '(- :)'{-h,--help}'[Show this help and exit]' \ 35 | '(--color)--color[Enable colorized output (only fzf)]' \ 36 | '(-q --query)'{-q,--query}'=[Initial value for query]' \ 37 | && return 0 38 | ;; 39 | ("search") 40 | _arguments \ 41 | '(- :)'{-h,--help}'[Show this help and exit]' \ 42 | '(--color)--color[Enable colorized output (only fzf)]' \ 43 | '(-d --delimiter)'{-d,--delimiter}'[Use delim as the command delimiter character (default "; ")]' \ 44 | '(-q --query)'{-q,--query}'=[Initial value for query]' \ 45 | && return 0 46 | ;; 47 | ("help") 48 | _values 'help message' ${_1st_arguments[@]%:*} && return 0 49 | ;; 50 | esac 51 | } 52 | 53 | _qc "$@" 54 | -------------------------------------------------------------------------------- /qc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qownnotes/qc/2ebdf36b072bb1121c19116c124d4c09ad83dee4/qc.png -------------------------------------------------------------------------------- /scripts/package.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | set -x 5 | 6 | VERSION=$(grep "version = " cmd/root.go | sed -E 's/.*"(.+)"$/\1/') 7 | REPO="qc" 8 | 9 | rm -rf ./out/ 10 | gox --osarch "windows/386 windows/amd64 darwin/386 darwin/amd64 linux/386 linux/amd64" -output="./out/${REPO}_${VERSION}_{{.OS}}_{{.Arch}}/{{.Dir}}" 11 | 12 | rm -rf ./pkg/ 13 | mkdir ./pkg 14 | 15 | for PLATFORM in $(find ./out -mindepth 1 -maxdepth 1 -type d); do 16 | PLATFORM_NAME=$(basename ${PLATFORM}) 17 | 18 | pushd ${PLATFORM} 19 | cp -r ../../misc ./ 20 | zip -r ../../pkg/${PLATFORM_NAME}.zip ./* 21 | popd 22 | done 23 | -------------------------------------------------------------------------------- /scripts/pre-commit.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # pre-commit hook script 3 | 4 | echo "Running pre-commit hook with treefmt" 5 | 6 | # Run treefmt to format the code and check if there are any changes after formatting 7 | if ! just fmt --fail-on-change; then 8 | echo -e "\nCode may have been formatted. Please review the changes and commit again." 9 | exit 1 10 | fi 11 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | { 2 | pkgs ? import { }, 3 | }: 4 | pkgs.mkShell { 5 | # nativeBuildInputs is usually what you want -- tools you need to run 6 | nativeBuildInputs = with pkgs; [ 7 | just 8 | go 9 | ]; 10 | 11 | shellHook = '' 12 | # Determine the repository root 13 | REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) 14 | 15 | # Check if we are in the repository root 16 | if [ "$REPO_ROOT" = "$(pwd)" ]; then 17 | # Symlink the pre-commit hook into the .git/hooks directory 18 | ln -sf ../../scripts/pre-commit.sh .git/hooks/pre-commit 19 | fi 20 | ''; 21 | } 22 | -------------------------------------------------------------------------------- /snippet/snippet.go: -------------------------------------------------------------------------------- 1 | package snippet 2 | 3 | import ( 4 | "bytes" 5 | "sort" 6 | "strings" 7 | 8 | "github.com/qownnotes/qc/config" 9 | "github.com/qownnotes/qc/entity" 10 | "github.com/qownnotes/qc/websocket" 11 | ) 12 | 13 | type Snippets struct { 14 | Snippets []entity.SnippetInfo 15 | } 16 | 17 | // Load reads snippets. 18 | func (snippets *Snippets) Load() error { 19 | snippets.Snippets = websocket.FetchSnippetsData() 20 | snippets.Order() 21 | 22 | return nil 23 | } 24 | 25 | //// Save saves the snippets to toml file. 26 | //func (snippets *Snippets) Save() error { 27 | // snippetFile := config.Conf.General.SnippetFile 28 | // f, err := os.Create(snippetFile) 29 | // defer f.Close() 30 | // if err != nil { 31 | // return fmt.Errorf("Failed to save snippet file. err: %s", err) 32 | // } 33 | // return toml.NewEncoder(f).Encode(snippets) 34 | //} 35 | 36 | // ToString returns the contents of toml file. 37 | func (snippets *Snippets) ToString() (string, error) { 38 | var buffer bytes.Buffer 39 | //err := toml.NewEncoder(&buffer).Encode(snippets) 40 | //if err != nil { 41 | // return "", fmt.Errorf("Failed to convert struct to TOML string: %v", err) 42 | //} 43 | return buffer.String(), nil 44 | } 45 | 46 | // Order snippets regarding SortBy option defined in config toml 47 | // Prefix "-" reverses the order, default is "recency", "+" is the same as "" 48 | func (snippets *Snippets) Order() { 49 | sortBy := config.Conf.General.SortBy 50 | 51 | switch { 52 | case sortBy == "command" || sortBy == "+command": 53 | sort.Sort(ByCommand(snippets.Snippets)) 54 | case sortBy == "-command": 55 | sort.Sort(sort.Reverse(ByCommand(snippets.Snippets))) 56 | 57 | case sortBy == "description" || sortBy == "+description": 58 | sort.Sort(ByDescription(snippets.Snippets)) 59 | case sortBy == "-description": 60 | sort.Sort(sort.Reverse(ByDescription(snippets.Snippets))) 61 | 62 | case sortBy == "output" || sortBy == "+output": 63 | sort.Sort(ByOutput(snippets.Snippets)) 64 | case sortBy == "-output": 65 | sort.Sort(sort.Reverse(ByOutput(snippets.Snippets))) 66 | 67 | case sortBy == "-recency": 68 | snippets.reverse() 69 | } 70 | } 71 | 72 | func (snippets *Snippets) reverse() { 73 | for i, j := 0, len(snippets.Snippets)-1; i < j; i, j = i+1, j-1 { 74 | snippets.Snippets[i], snippets.Snippets[j] = snippets.Snippets[j], snippets.Snippets[i] 75 | } 76 | } 77 | 78 | type ByCommand []entity.SnippetInfo 79 | 80 | func (a ByCommand) Len() int { return len(a) } 81 | func (a ByCommand) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 82 | func (a ByCommand) Less(i, j int) bool { 83 | return strings.ToLower(a[i].Command) > strings.ToLower(a[j].Command) 84 | } 85 | 86 | type ByDescription []entity.SnippetInfo 87 | 88 | func (a ByDescription) Len() int { return len(a) } 89 | func (a ByDescription) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 90 | func (a ByDescription) Less(i, j int) bool { 91 | return strings.ToLower(a[i].Description) > strings.ToLower(a[j].Description) 92 | } 93 | 94 | type ByOutput []entity.SnippetInfo 95 | 96 | func (a ByOutput) Len() int { return len(a) } 97 | func (a ByOutput) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 98 | func (a ByOutput) Less(i, j int) bool { return a[i].Output > a[j].Output } 99 | -------------------------------------------------------------------------------- /treefmt.toml: -------------------------------------------------------------------------------- 1 | # https://github.com/numtide/treefmt 2 | # https://github.com/numtide/treefmt-nix 3 | 4 | on-unmatched = "info" 5 | 6 | [formatter.go] 7 | command = "gofmt" 8 | options = ["-w"] 9 | includes = ["*.go"] 10 | 11 | [formatter.prettier] 12 | command = "prettier" 13 | options = ["--write"] 14 | includes = ["*.md", "*.yaml", "*.yml"] 15 | 16 | [formatter.shfmt] 17 | command = "shfmt" 18 | excludes = [] 19 | includes = ["*.sh", "*.bash", "*.envrc", "*.envrc.*"] 20 | options = ["-s", "-w", "-i", "2"] 21 | 22 | [formatter.just] 23 | command = "just" 24 | includes = ["*.just"] 25 | 26 | [formatter.taplo] 27 | command = "taplo" 28 | includes = ["*.toml"] 29 | options = ["format"] 30 | 31 | [formatter.nixfmt-rfc-style] 32 | command = "nixfmt" 33 | excludes = [] 34 | includes = ["*.nix"] 35 | options = [] 36 | -------------------------------------------------------------------------------- /websocket/websocket.go: -------------------------------------------------------------------------------- 1 | package websocket 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "github.com/qownnotes/qc/config" 8 | "github.com/qownnotes/qc/entity" 9 | "log" 10 | "os" 11 | "path/filepath" 12 | "strconv" 13 | 14 | "net/url" 15 | "time" 16 | 17 | "github.com/gorilla/websocket" 18 | ) 19 | 20 | var ( 21 | snippetCacheFile string 22 | ) 23 | 24 | type Message struct { 25 | Token string `json:"token"` 26 | Type string `json:"type"` 27 | } 28 | 29 | type NoteFolderSwitchMessage struct { 30 | Token string `json:"token"` 31 | Type string `json:"type"` 32 | Data int `json:"data"` 33 | } 34 | 35 | type ResultMessage struct { 36 | Type string `json:"type"` 37 | CommandSnippets []entity.SnippetInfo `json:"data"` 38 | } 39 | 40 | type NoteFolderResultMessage struct { 41 | Type string `json:"type"` 42 | NoteFolders []entity.NoteFolderInfo `json:"data"` 43 | CurrentId int `json:"currentId"` 44 | } 45 | 46 | type NoteFolderSwitchResultMessage struct { 47 | Type string `json:"type"` 48 | Switched bool `json:"data"` 49 | } 50 | 51 | // type CommandSnippet struct { 52 | // Command string `json:"command"` 53 | // Description string `json:"description"` 54 | // Tags []string `json:"tags"` 55 | // } 56 | 57 | const ( 58 | // Time allowed to write a message to the peer. 59 | writeWait = 10 * time.Second 60 | 61 | // Time allowed to read the next pong message from the peer. 62 | pongWait = 60 * time.Second 63 | 64 | // Send pings to peer with this period. Must be less than pongWait. 65 | pingPeriod = (pongWait * 9) / 10 66 | 67 | // 20MB maximum message size allowed from peer. 68 | maxMessageSize = 20971520 69 | ) 70 | 71 | func FetchSnippetsData() []entity.SnippetInfo { 72 | // log.Printf("Connecting to QOwnNotes on %s", u.String()) 73 | 74 | initSnippetCacheFile() 75 | var snippetData []byte = nil 76 | 77 | c, err := connectSocket() 78 | if err != nil { 79 | snippetData = readSnippetCacheFile() 80 | 81 | if snippetData == nil { 82 | log.Fatal("Couldn't connect to QOwnNotes websocket, did you enable the socket server? Error: ", err) 83 | } else { 84 | log.Println("Couldn't connect to QOwnNotes websocket, but found cached data in " + snippetCacheFile) 85 | } 86 | } 87 | 88 | if snippetData == nil { 89 | defer c.Close() 90 | 91 | message := Message{ 92 | Token: config.Conf.QOwnNotes.Token, 93 | Type: "getCommandSnippets", 94 | } 95 | 96 | m, err := json.Marshal(message) 97 | 98 | err = c.WriteMessage(websocket.TextMessage, m) 99 | if err != nil { 100 | log.Fatal("Couldn't send command to QOwnNotes: ", err) 101 | } 102 | 103 | _, snippetData, err = c.ReadMessage() 104 | if err != nil { 105 | log.Fatalf("Couldn't read message from QOwnNotes: %v", err) 106 | } 107 | } 108 | 109 | var resultMessage ResultMessage 110 | err = json.Unmarshal(snippetData, &resultMessage) 111 | if err != nil { 112 | log.Fatalf("Couldn't interpret message from QOwnNotes: %v", err) 113 | } 114 | 115 | switch resultMessage.Type { 116 | case "tokenQuery": 117 | log.Fatal("Please execute \"qc configure\" and configure your token from QOwnNotes!") 118 | case "commandSnippets": 119 | writeSnippetCacheFile(snippetData) 120 | 121 | // log.Printf("CommandSnippets: %v", resultMessage.CommandSnippets) 122 | return resultMessage.CommandSnippets 123 | default: 124 | log.Fatal("Did not understand response from QOwnNotes!") 125 | } 126 | 127 | return []entity.SnippetInfo{} 128 | } 129 | 130 | func FetchNoteFolderData() (noteFolderInfo []entity.NoteFolderInfo, currentId int) { 131 | var noteFolderData []byte = nil 132 | 133 | c, err := connectSocket() 134 | if err != nil { 135 | log.Fatal("Couldn't connect to QOwnNotes websocket, did you enable the socket server? Error: ", err) 136 | } 137 | 138 | defer c.Close() 139 | 140 | message := Message{ 141 | Token: config.Conf.QOwnNotes.Token, 142 | Type: "getNoteFolders", 143 | } 144 | 145 | m, err := json.Marshal(message) 146 | 147 | err = c.WriteMessage(websocket.TextMessage, m) 148 | if err != nil { 149 | log.Fatal("Couldn't send command to QOwnNotes: ", err) 150 | } 151 | 152 | _, noteFolderData, err = c.ReadMessage() 153 | if err != nil { 154 | log.Fatalf("Couldn't read message from QOwnNotes: %v", err) 155 | } 156 | 157 | var resultMessage NoteFolderResultMessage 158 | // log.Printf("Connecting to QOwnNotes on vs", noteFolderData) 159 | err = json.Unmarshal(noteFolderData, &resultMessage) 160 | if err != nil { 161 | log.Fatalf("Couldn't interpret message from QOwnNotes: %v\nYou need at least QOwnNotes 22.7.1!", err) 162 | } 163 | 164 | switch resultMessage.Type { 165 | case "tokenQuery": 166 | log.Fatal("Please execute \"qc configure\" and configure your token from QOwnNotes!") 167 | case "noteFolders": 168 | // log.Printf("NoteFolders: %v", resultMessage.NoteFolders) 169 | return resultMessage.NoteFolders, resultMessage.CurrentId 170 | default: 171 | log.Fatal("Did not understand response from QOwnNotes!") 172 | } 173 | 174 | return []entity.NoteFolderInfo{}, 0 175 | } 176 | 177 | func SwitchNoteFolder(id int) { 178 | var noteFolderData []byte = nil 179 | c, err := connectSocket() 180 | if err != nil { 181 | log.Fatal("Couldn't connect to QOwnNotes websocket, did you enable the socket server? Error: ", err) 182 | } 183 | 184 | defer c.Close() 185 | 186 | message := NoteFolderSwitchMessage{ 187 | Token: config.Conf.QOwnNotes.Token, 188 | Type: "switchNoteFolder", 189 | Data: id, 190 | } 191 | 192 | m, err := json.Marshal(message) 193 | 194 | err = c.WriteMessage(websocket.TextMessage, m) 195 | if err != nil { 196 | log.Fatal("Couldn't send command to QOwnNotes: ", err) 197 | } 198 | 199 | _, noteFolderData, err = c.ReadMessage() 200 | if err != nil { 201 | log.Fatalf("Couldn't read message from QOwnNotes: %v", err) 202 | } 203 | 204 | var resultMessage NoteFolderSwitchResultMessage 205 | err = json.Unmarshal(noteFolderData, &resultMessage) 206 | if err != nil { 207 | log.Fatalf("Couldn't interpret message from QOwnNotes: %v\nYou need at least QOwnNotes 22.7.1!", err) 208 | } 209 | 210 | switch resultMessage.Type { 211 | case "tokenQuery": 212 | log.Fatal("Please execute \"qc configure\" and configure your token from QOwnNotes!") 213 | case "switchedNoteFolder": 214 | if resultMessage.Switched { 215 | fmt.Println("Note folder was switched.") 216 | } else { 217 | fmt.Println("Note folder was not switched.") 218 | } 219 | default: 220 | log.Fatal("Did not understand response from QOwnNotes!") 221 | } 222 | } 223 | 224 | func connectSocket() (*websocket.Conn, error) { 225 | u := getSocketUrl() 226 | c, _, err := websocket.DefaultDialer.Dial(u.String(), nil) 227 | return c, err 228 | } 229 | 230 | func getSocketUrl() url.URL { 231 | u := url.URL{Scheme: "ws", Host: "127.0.0.1:" + strconv.Itoa(config.Conf.QOwnNotes.WebSocketPort)} 232 | return u 233 | } 234 | 235 | func initSnippetCacheFile() { 236 | if snippetCacheFile == "" { 237 | dir, err := config.GetDefaultConfigDir() 238 | if err != nil { 239 | fmt.Fprintf(os.Stderr, "%v", err) 240 | os.Exit(1) 241 | } 242 | 243 | snippetCacheFile = filepath.Join(dir, "snippets.cache") 244 | } 245 | } 246 | 247 | func writeSnippetCacheFile(data []byte) { 248 | if err := os.WriteFile(snippetCacheFile, data, 0600); err != nil { 249 | log.Fatal("Could not write snippet cache file: ", err) 250 | } 251 | } 252 | 253 | func readSnippetCacheFile() []byte { 254 | _, err := os.Stat(snippetCacheFile) 255 | 256 | if errors.Is(err, os.ErrNotExist) { 257 | return nil 258 | } 259 | 260 | data, err := os.ReadFile(snippetCacheFile) 261 | 262 | if err != nil { 263 | log.Fatal("Could not read snippet cache file: ", err) 264 | } 265 | 266 | return data 267 | } 268 | --------------------------------------------------------------------------------