├── .github └── workflows │ └── push.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cmd └── gofind │ └── main.go ├── config └── config.go ├── cover.html ├── cover.out ├── docker-compose.yml ├── go.mod ├── go.sum ├── internal ├── database │ ├── commands.go │ └── db.go ├── handlers │ ├── apis.go │ ├── commands.go │ ├── query.go │ └── utilities.go ├── helpers │ └── helpers.go ├── server │ └── server.go └── templates │ └── templates.go ├── models └── command.go ├── static ├── css │ ├── input.css │ └── styles.css ├── opensearch.xml ├── styles.css └── templates │ ├── base64.html │ ├── command_tabs.html │ ├── filtered_commands_list.html │ ├── list_commands.html │ ├── message.html │ ├── multi_query.html │ ├── notification.html │ └── sha256.html ├── tailwind.config.js ├── tasks.md └── tests ├── command_operations_test.go ├── main_test.go ├── query_handler_test.go ├── static ├── templates_test.go └── utils_test.go /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: gofind-pipeline 2 | on: push 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags') 7 | steps: 8 | - uses: actions/checkout@v2 9 | - name: Run Unit Tests 10 | run: go test ./tests 11 | 12 | deploy: 13 | runs-on: ubuntu-latest 14 | needs: test 15 | if: startsWith(github.ref, 'refs/tags') 16 | steps: 17 | - name: Extract Version 18 | id: version_step 19 | run: | 20 | echo "##[set-output name=version;]VERSION=${GITHUB_REF#$"refs/tags/v"}" 21 | echo "##[set-output name=version_tag;]$GITHUB_REPOSITORY:${GITHUB_REF#$"refs/tags/v"}" 22 | echo "##[set-output name=latest_tag;]$GITHUB_REPOSITORY:latest" 23 | - name: Print Version 24 | run: | 25 | echo ${{steps.version_step.outputs.version_tag}} 26 | echo ${{steps.version_step.outputs.latest_tag}} 27 | - name: Set up QEMU 28 | uses: docker/setup-qemu-action@v1 29 | 30 | - name: Set up Docker Buildx 31 | uses: docker/setup-buildx-action@v1 32 | 33 | - name: Login to DockerHub 34 | uses: docker/login-action@v1 35 | with: 36 | username: ${{ secrets.DOCKER_USERNAME }} 37 | password: ${{ secrets.DOCKER_ACCESS_CODE }} 38 | 39 | - name: PrepareReg Names 40 | id: read-docker-image-identifiers 41 | run: | 42 | echo VERSION_TAG=$(echo ${{ steps.version_step.outputs.version_tag }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV 43 | echo LATEST_TAG=$(echo ${{ steps.version_step.outputs.latest_tag }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV 44 | - name: Build and push 45 | id: docker_build 46 | uses: docker/build-push-action@v2 47 | with: 48 | push: true 49 | tags: | 50 | ${{env.VERSION_TAG}} 51 | ${{env.LATEST_TAG}} 52 | build-args: | 53 | ${{steps.version_step.outputs.version}} 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | db.sqlite 3 | tmp 4 | .air.toml 5 | bin 6 | tests/gofind.db 7 | gofind.db 8 | user_scripts/ 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23.2 AS builder 2 | 3 | WORKDIR /app 4 | 5 | COPY . . 6 | RUN go mod download 7 | 8 | RUN CGO_ENABLED=0 GOOS=linux go build -o /main ./cmd/gofind/main.go 9 | 10 | FROM alpine:latest 11 | COPY --from=builder /main /main 12 | COPY --from=builder /app/static /static 13 | 14 | RUN ls -l / 15 | EXPOSE 3005 16 | ENTRYPOINT ["/main"] 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | dev: 2 | ./bin/wgo -file=.html -file=.go -file=.css -xfile=./static/css/output.css ./bin/tailwindcss-linux-x64 -i static/css/input.css -o static/css/styles.css :: go run cmd/gofind/main.go 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GoFind 2 | 3 | GoFind supercharges your browser address bar by providing short predictable aliases for performing any kind of searches. 4 | 5 | Inspired from: [GoLinks](https://git.mills.io/prologic/golinks) 6 | 7 | ## Features 8 | 9 | - Create short aliases for searching your favorite sites or for bookmarking commonly used web pages 10 | - Provides additional aliases for performing common tasks like base64 encoding, decoding, sha256 encoding, etc. 11 | - Stores all your data locally in an sqlite database allowing easy backup for your data 12 | - Lightweight and fast since it is built with technologies like Go and HTMX 13 | 14 | ## Demo 15 | 16 | https://github.com/user-attachments/assets/20d01905-e114-48c3-9845-52deb55af0ee 17 | 18 | ## Example usages 19 | - Invoke ChatGPT from the address bar by typing 'c' followed by your query: `c how to build a spaceship`. [Command: `#a c https://chatgpt.com/?q=%s`] 20 | - Directly open a specific email inbox by typing `gm personal` or `gm work` or just `gm` which would default to open the personal inbox [Command: `#a gm https://mail.google.com/mail/u/{work:0,personal$(default):1}/#inbox`] 21 | - If you have a long command, you can trigger it by typing part of the command as long as there are no other commands with conflicting name. Eg: Command added this way: `#a commandWithLongAlias https://google.com` can be triggered by typing `com` and pressing enter 22 | - You can run custom bash scripts from the address bar 23 | - You can open any file that can be viewed in a browser like pdf, txt, etc. by opening the file in the browser and then prefixing it with `#a `, like `#a f file://home/path/to/file.pdf`, then you can use the alias to directly open the file in the browser 24 | - Run multiple commands at once in multiple tabs by separating the commands with `;;`. Example, `g search something in google;;#a alias https://test.com add an alias;;gm work` would run all the commands in 3 different tabs 25 | - And much more: 26 | - `#a tiny https://tinyurl.com/api-create.php?url={1}` - Use URL shortener service by visiting a website and prefixing the URL with `tiny https://...` to generate a shortened URL 27 | - `#a insta https://www.instapaper.com/text?u={1}` - View current page in instapaper reader by prefixing the url with `insta https://...` 28 | - `#a pkt https://getpocket.com/save?url={1}` - Save current page to Pocket by prefixing the url with `pkt https://...` 29 | 30 | ## Requirements 31 | 32 | - Docker or Docker Compose 33 | 34 | ## Getting Started 35 | 36 | ### Create a Compose File 37 | 38 | #### Docker Compose 39 | 40 | Copy the following into a new `docker-compose.yml` file and make any moditications as necessary: 41 | 42 | ```yml 43 | services: 44 | gofind: 45 | image: vigneshrajj/gofind:latest 46 | ports: 47 | - 3005:3005 48 | volumes: 49 | - ./db:/db 50 | - ./path/to/local/files:/files # Optional, files located inside this folder can be opened directly using a command 51 | - ./path/to/bash/scripts:/user_scripts # Optional, executable scripts located inside the below folder can be run with commands 52 | environment: 53 | - ENABLE_ADDITIONAL_COMMANDS=false 54 | - IT_TOOLS_URL=http://localhost:8081 # Optionally, add this url variable along with the below image for enabling IT Tools restart: unless-stopped 55 | - HOST_URL=http://localhost:3005 # Optional, specify the url where gofind is hosted (for enabling firefox autosuggestions) 56 | # Optionally, enable my custom IT Tools fork for accessing lots of developer tools right from the address bar 57 | it-tools: 58 | image: 'vigneshrajj/it-tools:latest' 59 | ports: 60 | - '8081:80' 61 | restart: unless-stopped 62 | container_name: it-tools 63 | ``` 64 | 65 | You can run the application using the following command: 66 | ```bash 67 | docker compose up -d 68 | ``` 69 | 70 | #### Docker 71 | 72 | Alternatively, you can run the following command in your terminal to achieve the same result: 73 | 74 | ```bash 75 | docker run -d \ 76 | --name gofind \ 77 | -p 3005:3005 \ 78 | -v ./db:/app/db \ 79 | -e ENABLE_ADDITIONAL_COMMANDS=true \ 80 | --restart unless-stopped \ 81 | vigneshrajj/gofind:latest 82 | ``` 83 | 84 | ### Set as default search engine 85 | 86 | Set GoFind as your default browser by setting the url where gofind is hosted, such as: http://localhost:3005/search?query=%s 87 | 88 | - [Instructions for Chrome](https://support.google.com/chrome/answer/95426) 89 | - [Instructions for Firefox](https://support.mozilla.org/en-US/kb/add-or-remove-search-engine-firefox#w_add-a-search-engine-from-the-address-bar) 90 | 91 | Type #l in address bar and press enter to see a list of all available commands. 92 | 93 | ### Additional Configurations 94 | 95 | `ENABLE_ADDITIONAL_COMMANDS` - Adds many commonly used search aliases to your database 96 | 97 | ## Usage 98 | 99 | ### Commands 100 | 101 | - `#l` - Lists all available commands 102 | - `#a ` - Adds a new command 103 | - Example: `#a g google.com/search?q=%s` 104 | - Example: `#a g google.com/search?q={1}&q2={2}` 105 | - Example: `#a gm https://mail.google.com/mail/u/{r:0,vr:1}/#inbox` 106 | - Example: `#a d https://test.com Some description for the query` 107 | - Example: `#a file /home/path/to/file.pdf Open a file directly using an alias` 108 | - `#d ` - Deletes an existing command 109 | - Example: `#d gm` 110 | - `#cmd ` - Runs a user executable script located at the folder provided in the docker compose file 111 | - Example: `#cmd bm https://google.com some text as argument` The script named `bm`(or `bm.sh` or with any file extension) would be called with the rest of the string as a single argument. So `https://google.com some text as argument` would be passed entirely as a single string to the `bm` script 112 | - ` ...` - Searches the website denoted by the alias along with the provided arguments 113 | - Example: `g how to build a spaceship` 114 | - Example: `gm vr` 115 | 116 | ##### Types of Arguments 117 | 118 | - **Search String arguments** - `#a g google.com/search?q=%s` 119 | - Use alias g followed by any number of arguments for searching 120 | - `g how to make a hello world program in go` 121 | - **Numbered arguments** - `#a g google.com/search?q={1}&q2={2}` 122 | - Use alias g followed by 2 arguments and each of them will be placed at the respective places for searching 123 | - `g abc efg` would become `https://google.com/search?q=abc&q2=efg` 124 | - **Key Value arguments** - `#a gm https://mail.google.com/mail/u/{r:0,vr:1}/#inbox` 125 | - Use alias gm followed by specific key from the keys provided in the command (keys from above example are r, vr) and it will be replaced by the corresponding value (values from above example are 0, 1): 126 | - `gm vr` would become `https://mail.google.com/mail/u/1/#inbox` 127 | - Mark a value as default value if no arguments are passed: 128 | - `#a gm https://mail.google.com/mail/u/{r$(default):0,vr:1}/#inbox` - Creating a command with `$(default)` before the colon specifies it as the default 129 | - For the above command, `gm` would become `https://mail.google.com/mail/u/0/#inbox` 130 | 131 | *Note:* You cannot use multiple types of arguments in a single command. So `#a g https://google.com/%s?q={1}&q2={key:val}` may not work. 132 | 133 | ##### Additional Utilities 134 | 135 | Some examples for additional utilities provided by IT Tools include: 136 | 137 | | Utility | Alias | Example | 138 | |------------------|--------|------------------------------| 139 | | Hash Text | hash | `!hash abcd` | 140 | | Base64 Encoding | b64 | `!b64 true abcd` | 141 | | Base64 Decoding | d64 | `!b64 false ` | 142 | 143 | ## Development 144 | 145 | ### Running Locally 146 | 147 | Built with Go v1.23.2 148 | - Clone the repository: 149 | ```bash 150 | git clone https://github.com/vigneshrajj/gofind && cd gofind 151 | ``` 152 | - Install dependencies: 153 | ```bash 154 | go mod download 155 | ``` 156 | - Run the application 157 | ```bash 158 | go run ./cmd/gofind/main.go 159 | ``` 160 | The application will start on http://localhost:3005. 161 | 162 | ### Running Tests 163 | 164 | - Run the following command to run the tests: 165 | ```bash 166 | go test ./tests 167 | ``` 168 | 169 | ### Contributing 170 | 171 | Contributions are welcome! Please open an issue or submit a pull request for any improvements or features. 172 | 173 | ### License 174 | 175 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. 176 | 177 | ### Author 178 | 179 | Vignesh Raj 180 | -------------------------------------------------------------------------------- /cmd/gofind/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/vigneshrajj/gofind/config" 5 | "github.com/vigneshrajj/gofind/internal/server" 6 | ) 7 | 8 | func main() { 9 | if err := server.StartServer(config.DbPath, config.Port); err != nil { 10 | panic(err) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "os" 4 | 5 | 6 | var DbPath = "db/gofind.db" 7 | var Port = ":3005" 8 | var EnableAdditionalCommands = os.Getenv("ENABLE_ADDITIONAL_COMMANDS") == "true" 9 | var ItToolsUrl = os.Getenv("IT_TOOLS_URL") 10 | var ScriptsPath = "user_scripts" 11 | var HostUrl = os.Getenv("HOST_URL") 12 | -------------------------------------------------------------------------------- /cover.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | database: Go Coverage Report 7 | 52 | 53 | 54 |
55 | 76 |
77 | not tracked 78 | 79 | not covered 80 | covered 81 | 82 |
83 |
84 |
85 | 86 | 151 | 152 | 264 | 265 | 345 | 346 | 483 | 484 | 535 | 536 | 561 | 562 | 611 | 612 | 696 | 697 |
698 | 699 | 726 | 727 | -------------------------------------------------------------------------------- /cover.out: -------------------------------------------------------------------------------- 1 | mode: set 2 | github.com/vigneshrajj/gofind/internal/database/commands.go:10.63,11.50 1 1 3 | github.com/vigneshrajj/gofind/internal/database/commands.go:11.50,13.3 1 1 4 | github.com/vigneshrajj/gofind/internal/database/commands.go:14.2,14.12 1 1 5 | github.com/vigneshrajj/gofind/internal/database/commands.go:17.64,19.2 1 1 6 | github.com/vigneshrajj/gofind/internal/database/commands.go:21.53,22.124 1 1 7 | github.com/vigneshrajj/gofind/internal/database/commands.go:22.124,24.3 1 1 8 | github.com/vigneshrajj/gofind/internal/database/commands.go:25.2,25.12 1 1 9 | github.com/vigneshrajj/gofind/internal/database/commands.go:28.49,32.2 3 1 10 | github.com/vigneshrajj/gofind/internal/database/commands.go:34.81,36.18 2 1 11 | github.com/vigneshrajj/gofind/internal/database/commands.go:36.18,38.3 1 1 12 | github.com/vigneshrajj/gofind/internal/database/commands.go:38.8,40.3 1 1 13 | github.com/vigneshrajj/gofind/internal/database/commands.go:41.2,41.16 1 1 14 | github.com/vigneshrajj/gofind/internal/database/commands.go:44.52,48.2 3 1 15 | github.com/vigneshrajj/gofind/internal/database/commands.go:50.57,53.78 3 1 16 | github.com/vigneshrajj/gofind/internal/database/commands.go:53.78,55.3 1 1 17 | github.com/vigneshrajj/gofind/internal/database/commands.go:56.2,56.96 1 1 18 | github.com/vigneshrajj/gofind/internal/database/commands.go:56.96,58.3 1 1 19 | github.com/vigneshrajj/gofind/internal/database/commands.go:59.2,63.12 5 1 20 | github.com/vigneshrajj/gofind/internal/database/db.go:11.68,13.16 2 1 21 | github.com/vigneshrajj/gofind/internal/database/db.go:13.16,15.3 1 1 22 | github.com/vigneshrajj/gofind/internal/database/db.go:17.2,20.23 3 1 23 | github.com/vigneshrajj/gofind/internal/database/db.go:23.44,25.2 1 1 24 | github.com/vigneshrajj/gofind/internal/database/db.go:27.46,58.42 2 1 25 | github.com/vigneshrajj/gofind/internal/database/db.go:58.42,60.3 1 1 26 | github.com/vigneshrajj/gofind/internal/database/db.go:63.49,108.45 2 1 27 | github.com/vigneshrajj/gofind/internal/database/db.go:108.45,110.3 1 1 28 | github.com/vigneshrajj/gofind/internal/handlers/commands.go:15.74,16.19 1 1 29 | github.com/vigneshrajj/gofind/internal/handlers/commands.go:16.19,20.3 3 1 30 | github.com/vigneshrajj/gofind/internal/handlers/commands.go:22.2,26.19 2 1 31 | github.com/vigneshrajj/gofind/internal/handlers/commands.go:26.19,28.3 1 1 32 | github.com/vigneshrajj/gofind/internal/handlers/commands.go:30.2,31.16 2 1 33 | github.com/vigneshrajj/gofind/internal/handlers/commands.go:31.16,35.3 3 1 34 | github.com/vigneshrajj/gofind/internal/handlers/commands.go:36.2,36.75 1 1 35 | github.com/vigneshrajj/gofind/internal/handlers/commands.go:39.76,40.20 1 1 36 | github.com/vigneshrajj/gofind/internal/handlers/commands.go:40.20,44.3 3 1 37 | github.com/vigneshrajj/gofind/internal/handlers/commands.go:45.2,46.45 2 1 38 | github.com/vigneshrajj/gofind/internal/handlers/commands.go:49.80,53.16 4 1 39 | github.com/vigneshrajj/gofind/internal/handlers/commands.go:53.16,56.3 2 1 40 | github.com/vigneshrajj/gofind/internal/handlers/commands.go:57.2,57.27 1 1 41 | github.com/vigneshrajj/gofind/internal/handlers/commands.go:60.77,61.20 1 1 42 | github.com/vigneshrajj/gofind/internal/handlers/commands.go:61.20,65.3 3 1 43 | github.com/vigneshrajj/gofind/internal/handlers/commands.go:66.2,67.35 2 1 44 | github.com/vigneshrajj/gofind/internal/handlers/commands.go:67.35,71.3 3 1 45 | github.com/vigneshrajj/gofind/internal/handlers/commands.go:72.2,72.77 1 1 46 | github.com/vigneshrajj/gofind/internal/handlers/commands.go:72.77,76.3 3 1 47 | github.com/vigneshrajj/gofind/internal/handlers/commands.go:77.2,78.48 2 1 48 | github.com/vigneshrajj/gofind/internal/handlers/query.go:18.39,20.24 2 1 49 | github.com/vigneshrajj/gofind/internal/handlers/query.go:20.24,22.3 1 1 50 | github.com/vigneshrajj/gofind/internal/handlers/query.go:23.2,24.22 2 1 51 | github.com/vigneshrajj/gofind/internal/handlers/query.go:24.22,26.3 1 1 52 | github.com/vigneshrajj/gofind/internal/handlers/query.go:27.2,27.13 1 1 53 | github.com/vigneshrajj/gofind/internal/handlers/query.go:30.71,38.37 6 1 54 | github.com/vigneshrajj/gofind/internal/handlers/query.go:38.37,40.22 2 1 55 | github.com/vigneshrajj/gofind/internal/handlers/query.go:40.22,42.4 1 1 56 | github.com/vigneshrajj/gofind/internal/handlers/query.go:45.2,45.42 1 1 57 | github.com/vigneshrajj/gofind/internal/handlers/query.go:45.42,47.3 1 1 58 | github.com/vigneshrajj/gofind/internal/handlers/query.go:47.8,49.3 1 1 59 | github.com/vigneshrajj/gofind/internal/handlers/query.go:52.94,56.35 3 1 60 | github.com/vigneshrajj/gofind/internal/handlers/query.go:56.35,60.3 3 1 61 | github.com/vigneshrajj/gofind/internal/handlers/query.go:62.2,62.40 1 1 62 | github.com/vigneshrajj/gofind/internal/handlers/query.go:62.40,65.3 2 1 63 | github.com/vigneshrajj/gofind/internal/handlers/query.go:67.2,70.21 3 1 64 | github.com/vigneshrajj/gofind/internal/handlers/query.go:70.21,72.3 1 1 65 | github.com/vigneshrajj/gofind/internal/handlers/query.go:74.2,74.35 1 1 66 | github.com/vigneshrajj/gofind/internal/handlers/query.go:74.35,78.3 3 1 67 | github.com/vigneshrajj/gofind/internal/handlers/query.go:80.2,80.26 1 1 68 | github.com/vigneshrajj/gofind/internal/handlers/query.go:80.26,83.34 3 1 69 | github.com/vigneshrajj/gofind/internal/handlers/query.go:83.34,87.4 3 1 70 | github.com/vigneshrajj/gofind/internal/handlers/query.go:89.3,89.34 1 1 71 | github.com/vigneshrajj/gofind/internal/handlers/query.go:89.34,92.18 3 1 72 | github.com/vigneshrajj/gofind/internal/handlers/query.go:92.18,96.5 3 1 73 | github.com/vigneshrajj/gofind/internal/handlers/query.go:98.3,99.9 2 1 74 | github.com/vigneshrajj/gofind/internal/handlers/query.go:102.2,103.33 2 1 75 | github.com/vigneshrajj/gofind/internal/handlers/query.go:103.33,106.3 2 1 76 | github.com/vigneshrajj/gofind/internal/handlers/query.go:108.2,110.41 3 1 77 | github.com/vigneshrajj/gofind/internal/handlers/query.go:110.41,114.3 3 1 78 | github.com/vigneshrajj/gofind/internal/handlers/query.go:116.2,116.46 1 1 79 | github.com/vigneshrajj/gofind/internal/handlers/query.go:119.85,120.17 1 1 80 | github.com/vigneshrajj/gofind/internal/handlers/query.go:120.17,124.3 3 1 81 | github.com/vigneshrajj/gofind/internal/handlers/query.go:125.2,126.17 2 1 82 | github.com/vigneshrajj/gofind/internal/handlers/query.go:127.12,128.32 1 1 83 | github.com/vigneshrajj/gofind/internal/handlers/query.go:129.12,130.35 1 1 84 | github.com/vigneshrajj/gofind/internal/handlers/query.go:131.12,132.34 1 1 85 | github.com/vigneshrajj/gofind/internal/handlers/query.go:133.10,134.38 1 1 86 | github.com/vigneshrajj/gofind/internal/handlers/utilities.go:10.62,12.15 2 1 87 | github.com/vigneshrajj/gofind/internal/handlers/utilities.go:13.13,14.25 1 1 88 | github.com/vigneshrajj/gofind/internal/handlers/utilities.go:15.13,16.25 1 1 89 | github.com/vigneshrajj/gofind/internal/handlers/utilities.go:17.16,18.28 1 1 90 | github.com/vigneshrajj/gofind/internal/handlers/utilities.go:22.61,23.20 1 1 91 | github.com/vigneshrajj/gofind/internal/handlers/utilities.go:23.20,27.3 3 1 92 | github.com/vigneshrajj/gofind/internal/handlers/utilities.go:28.2,29.38 2 1 93 | github.com/vigneshrajj/gofind/internal/handlers/utilities.go:32.58,33.20 1 1 94 | github.com/vigneshrajj/gofind/internal/handlers/utilities.go:33.20,37.3 3 1 95 | github.com/vigneshrajj/gofind/internal/handlers/utilities.go:38.2,39.44 2 1 96 | github.com/vigneshrajj/gofind/internal/handlers/utilities.go:42.58,43.20 1 1 97 | github.com/vigneshrajj/gofind/internal/handlers/utilities.go:43.20,47.3 3 1 98 | github.com/vigneshrajj/gofind/internal/handlers/utilities.go:48.2,49.38 2 1 99 | github.com/vigneshrajj/gofind/internal/helpers/helpers.go:9.33,11.2 1 1 100 | github.com/vigneshrajj/gofind/internal/helpers/helpers.go:13.39,16.2 2 1 101 | github.com/vigneshrajj/gofind/internal/helpers/helpers.go:18.33,24.2 5 1 102 | github.com/vigneshrajj/gofind/internal/server/server.go:14.32,17.74 3 1 103 | github.com/vigneshrajj/gofind/internal/server/server.go:17.74,20.3 2 1 104 | github.com/vigneshrajj/gofind/internal/server/server.go:21.2,21.87 1 1 105 | github.com/vigneshrajj/gofind/internal/server/server.go:21.87,23.3 1 1 106 | github.com/vigneshrajj/gofind/internal/server/server.go:25.2,25.68 1 1 107 | github.com/vigneshrajj/gofind/internal/server/server.go:25.68,27.3 1 1 108 | github.com/vigneshrajj/gofind/internal/server/server.go:30.52,32.16 2 1 109 | github.com/vigneshrajj/gofind/internal/server/server.go:32.16,34.3 1 1 110 | github.com/vigneshrajj/gofind/internal/server/server.go:36.2,38.37 2 1 111 | github.com/vigneshrajj/gofind/internal/server/server.go:38.37,40.3 1 1 112 | github.com/vigneshrajj/gofind/internal/server/server.go:42.2,46.12 4 1 113 | github.com/vigneshrajj/gofind/internal/templates/templates.go:13.85,16.35 2 1 114 | github.com/vigneshrajj/gofind/internal/templates/templates.go:16.35,18.3 1 1 115 | github.com/vigneshrajj/gofind/internal/templates/templates.go:20.2,20.24 1 1 116 | github.com/vigneshrajj/gofind/internal/templates/templates.go:23.77,29.2 3 1 117 | github.com/vigneshrajj/gofind/internal/templates/templates.go:35.57,41.2 3 1 118 | github.com/vigneshrajj/gofind/internal/templates/templates.go:55.60,62.2 3 1 119 | github.com/vigneshrajj/gofind/internal/templates/templates.go:64.66,71.2 3 1 120 | github.com/vigneshrajj/gofind/internal/templates/templates.go:77.59,83.2 3 1 121 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | gofind: 3 | image: vigneshrajj/gofind:latest 4 | build: . 5 | ports: 6 | - "3005:3005" 7 | volumes: 8 | - ./db:/db 9 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/vigneshrajj/gofind 2 | 3 | go 1.23.2 4 | 5 | require ( 6 | github.com/glebarez/sqlite v1.11.0 7 | gorm.io/gorm v1.25.12 8 | ) 9 | 10 | require ( 11 | github.com/dustin/go-humanize v1.0.1 // indirect 12 | github.com/glebarez/go-sqlite v1.21.2 // indirect 13 | github.com/google/uuid v1.6.0 // indirect 14 | github.com/jinzhu/inflection v1.0.0 // indirect 15 | github.com/jinzhu/now v1.1.5 // indirect 16 | github.com/mattn/go-isatty v0.0.20 // indirect 17 | github.com/ncruces/go-strftime v0.1.9 // indirect 18 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 19 | golang.org/x/sys v0.22.0 // indirect 20 | golang.org/x/text v0.19.0 // indirect 21 | modernc.org/libc v1.55.3 // indirect 22 | modernc.org/mathutil v1.6.0 // indirect 23 | modernc.org/memory v1.8.0 // indirect 24 | modernc.org/sqlite v1.33.1 // indirect 25 | ) 26 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 2 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 3 | github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= 4 | github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= 5 | github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= 6 | github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= 7 | github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= 8 | github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= 9 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 10 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 11 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 12 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 13 | github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 14 | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 15 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 16 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 17 | github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= 18 | github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 19 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 20 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 21 | golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= 22 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 23 | golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= 24 | golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 25 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 26 | golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= 27 | golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 28 | golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= 29 | golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= 30 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= 31 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 32 | gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= 33 | gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= 34 | modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= 35 | modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= 36 | modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= 37 | modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= 38 | modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= 39 | modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= 40 | modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= 41 | modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= 42 | modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= 43 | modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= 44 | modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= 45 | modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= 46 | modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= 47 | modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= 48 | modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= 49 | modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= 50 | modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= 51 | modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= 52 | modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM= 53 | modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= 54 | modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= 55 | modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= 56 | modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= 57 | modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= 58 | -------------------------------------------------------------------------------- /internal/database/commands.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/vigneshrajj/gofind/models" 7 | "gorm.io/gorm" 8 | ) 9 | 10 | func CreateCommand(db *gorm.DB, command models.Command) error { 11 | if err := db.Create(&command).Error; err != nil { 12 | return err 13 | } 14 | return nil 15 | } 16 | 17 | func FirstOrCreateCommand(db *gorm.DB, command models.Command) { 18 | db.FirstOrCreate(&command) 19 | } 20 | 21 | func DeleteCommand(db *gorm.DB, alias string) error { 22 | if rowsAffected := db.Delete(&models.Command{}, "alias=? AND is_default=?", alias, false).RowsAffected; rowsAffected == 0 { 23 | return errors.New("Command not found") 24 | } 25 | return nil 26 | } 27 | 28 | func ListCommands(db *gorm.DB) []models.Command { 29 | var commands []models.Command 30 | db.Find(&commands) 31 | return commands 32 | } 33 | 34 | func ListSuggestedCommands(db *gorm.DB, query string, items int) []models.Command { 35 | var commands []models.Command 36 | db.Where("alias LIKE ?", "%"+query+"%").Limit(items).Find(&commands) 37 | return commands 38 | } 39 | 40 | func FilteredListCommands(db *gorm.DB, query string, pageSize int, offset int, command_type string) (*[]models.Command, error) { 41 | var commands []models.Command 42 | 43 | switch { 44 | case pageSize > 100: 45 | pageSize = 100 46 | case pageSize <= 0: 47 | pageSize = 10 48 | } 49 | 50 | if query != "" { 51 | db = db.Where("alias LIKE ? OR query LIKE ? OR description LIKE ?", "%"+query+"%", "%"+query+"%", "%"+query+"%") 52 | } 53 | 54 | if command_type != "" { 55 | db = db.Where("type=?", command_type) 56 | } else { 57 | db = db.Where("type=?", models.SearchCommand) 58 | } 59 | 60 | db = db.Offset(offset).Limit(pageSize) 61 | 62 | if err := db.Find(&commands).Error; err != nil { 63 | return nil, err 64 | } 65 | 66 | return &commands, nil 67 | } 68 | 69 | func SearchCommand(db *gorm.DB, alias string, partialMatch bool) models.Command { 70 | var command models.Command 71 | if partialMatch { 72 | db.Where("alias LIKE ?", alias+"%").Order("LENGTH(alias) ASC").Find(&command) 73 | } else { 74 | db.Where("alias=?", alias).Find(&command) 75 | } 76 | return command 77 | } 78 | 79 | func GetDefaultCommand(db *gorm.DB) models.Command { 80 | var command models.Command 81 | db.Where("is_default=?", true).Find(&command) 82 | return command 83 | } 84 | 85 | func SetDefaultCommand(db *gorm.DB, alias string) error { 86 | var command models.Command 87 | var defaultCommand models.Command 88 | if db.Where("alias=?", alias).Find(&command); command == (models.Command{}) { 89 | return errors.New("Command not found") 90 | } 91 | if db.Where("is_default=?", true).Find(&defaultCommand); defaultCommand == (models.Command{}) { 92 | return errors.New("Default Command not found") 93 | } 94 | command.IsDefault = true 95 | defaultCommand.IsDefault = false 96 | db.Save(&command) 97 | db.Save(&defaultCommand) 98 | return nil 99 | } 100 | -------------------------------------------------------------------------------- /internal/database/db.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/vigneshrajj/gofind/config" 7 | "github.com/vigneshrajj/gofind/models" 8 | "github.com/glebarez/sqlite" 9 | "gorm.io/gorm" 10 | ) 11 | 12 | func NewDBConnection(dbFileName string) (*sql.DB, *gorm.DB, error) { 13 | db, err := gorm.Open(sqlite.Open(dbFileName), &gorm.Config{}) 14 | if err != nil { 15 | return nil, nil, err 16 | } 17 | 18 | EnsureCommandTableExists(db) 19 | 20 | dbSql, _ := db.DB() 21 | return dbSql, db, nil 22 | } 23 | 24 | func EnsureCommandTableExists(db *gorm.DB) { 25 | db.AutoMigrate(&models.Command{}) 26 | } 27 | 28 | func EnsureDefaultCommandsExist(db *gorm.DB) { 29 | defaultCommands := []models.Command{ 30 | { 31 | Alias: "g", 32 | Query: "https://www.google.com/search?q=%s", 33 | Type: models.SearchCommand, 34 | Description: sql.NullString{String: "Google Search", Valid: true}, 35 | IsDefault: true, 36 | }, 37 | } 38 | for _, command := range defaultCommands { 39 | FirstOrCreateCommand(db, command) 40 | } 41 | } 42 | 43 | func EnsureAdditionalCommandsExist(db *gorm.DB) { 44 | additionalCommands := []models.Command{ 45 | { 46 | Alias: "y", 47 | Query: "https://www.youtube.com/results?search_query=%s", 48 | Type: models.SearchCommand, 49 | Description: sql.NullString{String: "Youtube", Valid: true}, 50 | IsDefault: false, 51 | }, 52 | { 53 | Alias: "ddg", 54 | Query: "https://duckduckgo.com/?q=%s", 55 | Type: models.SearchCommand, 56 | Description: sql.NullString{String: "DuckDuckGo", Valid: true}, 57 | IsDefault: false, 58 | }, 59 | { 60 | Alias: "ddl", 61 | Query: "https://lite.duckduckgo.com/lite/?q=%s", 62 | Type: models.SearchCommand, 63 | Description: sql.NullString{String: "DuckDuckGo Lite", Valid: true}, 64 | IsDefault: false, 65 | }, 66 | { 67 | Alias: "gh", 68 | Query: "https://github.com/search?q=%s&type=repositories", 69 | Type: models.SearchCommand, 70 | Description: sql.NullString{String: "Github Repos", Valid: true}, 71 | IsDefault: false, 72 | }, 73 | { 74 | Alias: "npm", 75 | Query: "https://www.npmjs.com/search?q=%s", 76 | Type: models.SearchCommand, 77 | Description: sql.NullString{String: "Node Package Manager (NPM)", Valid: true}, 78 | IsDefault: false, 79 | }, 80 | { 81 | Alias: "m", 82 | Query: "https://mail.google.com/mail/u/{r:0,vr:1}/#inbox", 83 | Type: models.SearchCommand, 84 | Description: sql.NullString{String: "GMail", Valid: true}, 85 | IsDefault: false, 86 | }, 87 | } 88 | for _, command := range additionalCommands { 89 | FirstOrCreateCommand(db, command) 90 | } 91 | } 92 | 93 | func EnsureUtilCommandsExist(db *gorm.DB) { 94 | utilCommands := []models.Command{ 95 | { 96 | Alias: "!it", 97 | Query: config.ItToolsUrl, 98 | Type: models.UtilCommand, 99 | Description: sql.NullString{String: "IT Tools", Valid: true}, 100 | IsDefault: false, 101 | }, 102 | { 103 | Alias: "!bcrypt", 104 | Query: config.ItToolsUrl+"/bcrypt?defaultText={1}", 105 | Type: models.UtilCommand, 106 | Description: sql.NullString{String: "IT Tools: Generate Bcrypt", Valid: true}, 107 | IsDefault: false, 108 | }, 109 | { 110 | Alias: "!b64", 111 | Query: config.ItToolsUrl+"/base64-string-converter?defaultText={2}&shouldEncode={1}", 112 | Type: models.UtilCommand, 113 | Description: sql.NullString{String: "IT Tools: Base 64 Encoded Decoder", Valid: true}, 114 | IsDefault: false, 115 | }, 116 | { 117 | Alias: "!case", 118 | Query: config.ItToolsUrl+"/case-converter?defaultText={1}", 119 | Type: models.UtilCommand, 120 | Description: sql.NullString{String: "IT Tools: Case Converter", Valid: true}, 121 | IsDefault: false, 122 | }, 123 | { 124 | Alias: "!color", 125 | Query: config.ItToolsUrl+"/color-converter?defaultText={1}", 126 | Type: models.UtilCommand, 127 | Description: sql.NullString{String: "IT Tools: Color Converter", Valid: true}, 128 | IsDefault: false, 129 | }, 130 | { 131 | Alias: "!dt", 132 | Query: config.ItToolsUrl+"/date-converter?defaultText={1}", 133 | Type: models.UtilCommand, 134 | Description: sql.NullString{String: "IT Tools: Date Converter", Valid: true}, 135 | IsDefault: false, 136 | }, 137 | { 138 | Alias: "!emoji", 139 | Query: config.ItToolsUrl+"/emoji-picker?defaultText={1}", 140 | Type: models.UtilCommand, 141 | Description: sql.NullString{String: "IT Tools: Search Emoji", Valid: true}, 142 | IsDefault: false, 143 | }, 144 | { 145 | Alias: "!hash", 146 | Query: config.ItToolsUrl+"/hash-text?defaultText={1}", 147 | Type: models.UtilCommand, 148 | Description: sql.NullString{String: "IT Tools: Hash Text", Valid: true}, 149 | IsDefault: false, 150 | }, 151 | { 152 | Alias: "!http", 153 | Query: config.ItToolsUrl+"/http-status-codes?defaultText={1}", 154 | Type: models.UtilCommand, 155 | Description: sql.NullString{String: "IT Tools: HTTP Status Code Lookup", Valid: true}, 156 | IsDefault: false, 157 | }, 158 | { 159 | Alias: "!jwt", 160 | Query: config.ItToolsUrl+"/jwt-parser?defaultText={1}", 161 | Type: models.UtilCommand, 162 | Description: sql.NullString{String: "IT Tools: JWT Parser", Valid: true}, 163 | IsDefault: false, 164 | }, 165 | { 166 | Alias: "!qr", 167 | Query: config.ItToolsUrl+"/qr-code-generator?defaultText={1}", 168 | Type: models.UtilCommand, 169 | Description: sql.NullString{String: "IT Tools: QR Code Generator", Valid: true}, 170 | IsDefault: false, 171 | }, 172 | { 173 | Alias: "!slug", 174 | Query: config.ItToolsUrl+"/slugify-string?defaultText={1}", 175 | Type: models.UtilCommand, 176 | Description: sql.NullString{String: "IT Tools: Slugify String", Valid: true}, 177 | IsDefault: false, 178 | }, 179 | { 180 | Alias: "!url", 181 | Query: config.ItToolsUrl+"/url-parser?defaultText={1}", 182 | Type: models.UtilCommand, 183 | Description: sql.NullString{String: "IT Tools: Parse URL", Valid: true}, 184 | IsDefault: false, 185 | }, 186 | } 187 | for _, command := range utilCommands { 188 | FirstOrCreateCommand(db, command) 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /internal/handlers/apis.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/vigneshrajj/gofind/internal/helpers" 7 | "github.com/vigneshrajj/gofind/internal/templates" 8 | ) 9 | 10 | func HandleApiCommands(w http.ResponseWriter, data []string) { 11 | alias := data[0] 12 | switch alias { 13 | case "todo": 14 | HandleTodoistApi(w, data) 15 | } 16 | } 17 | 18 | func HandleTodoistApi(w http.ResponseWriter, data []string) { 19 | if len(data) <= 2 { 20 | w.WriteHeader(http.StatusBadRequest) 21 | templates.MessageTemplate(w, "Invalid number of arguments provided. Todoist command usage: todo add pri: due: labels:[cat,dog]") 22 | return 23 | } 24 | encoded := helpers.Sha256(data[1]) 25 | templates.Sha256Template(w, encoded) 26 | } 27 | -------------------------------------------------------------------------------- /internal/handlers/commands.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "strconv" 11 | "strings" 12 | 13 | "github.com/vigneshrajj/gofind/config" 14 | "github.com/vigneshrajj/gofind/internal/database" 15 | "github.com/vigneshrajj/gofind/internal/templates" 16 | "github.com/vigneshrajj/gofind/models" 17 | 18 | "gorm.io/gorm" 19 | ) 20 | 21 | func GetHostFromRequest(r *http.Request) string { 22 | protocol := "http" 23 | if r.TLS != nil { 24 | protocol = "https" 25 | } 26 | return fmt.Sprintf("%s://%s", protocol, r.Host) 27 | } 28 | 29 | func convertToFileURL(r *http.Request, filePath string) string { 30 | return fmt.Sprintf("%s/files/%s", GetHostFromRequest(r), filepath.Base(filePath)) 31 | } 32 | 33 | func HandleAddCommand(w http.ResponseWriter, r *http.Request, data []string, db *gorm.DB) { 34 | if len(data) < 3 { 35 | w.WriteHeader(http.StatusBadRequest) 36 | templates.MessageTemplate(w, "Invalid number of arguments provided. Add command usage:\n#a ") 37 | return 38 | } 39 | 40 | command := models.Command{ 41 | Alias: data[1], 42 | Query: data[2], 43 | Type: models.SearchCommand, 44 | } 45 | if len(data) > 3 { 46 | command.Description = sql.NullString{String: strings.Join(data[3:], " "), Valid: true} 47 | } 48 | 49 | isFile := strings.HasPrefix(command.Query, "file://") 50 | if isFile { 51 | command.Query = convertToFileURL(r, command.Query) 52 | } 53 | 54 | err := database.CreateCommand(db, command) 55 | if err != nil { 56 | w.WriteHeader(http.StatusBadRequest) 57 | templates.MessageTemplate(w, err.Error()) 58 | return 59 | } 60 | templates.MessageTemplate(w, "Added Command: "+data[1]+", URL: "+data[2]) 61 | } 62 | 63 | func HandleListCommands(w http.ResponseWriter, data []string, db *gorm.DB) { 64 | if len(data) != 1 { 65 | w.WriteHeader(http.StatusBadRequest) 66 | templates.MessageTemplate(w, "Invalid number of arguments provided. List command usage: #l") 67 | return 68 | } 69 | templates.ListCommandsTemplate(w, models.SearchCommand) 70 | } 71 | 72 | func HandleFilteredListCommands(w http.ResponseWriter, r *http.Request, db *gorm.DB) { 73 | page_size := 10 74 | offset := 0 75 | searchQuery := r.URL.Query().Get("search_query") 76 | command_type := r.URL.Query().Get("command_type") 77 | 78 | if r.URL.Query().Get("page_size") != "" { 79 | var err error 80 | page_size, err = strconv.Atoi(r.URL.Query().Get("page_size")) 81 | if err != nil { 82 | w.WriteHeader(http.StatusBadRequest) 83 | templates.MessageTemplate(w, "Invalid page_size provided.") 84 | return 85 | } 86 | } 87 | if r.URL.Query().Get("offset") != "" { 88 | var err error 89 | offset, err = strconv.Atoi(r.URL.Query().Get("offset")) 90 | if err != nil { 91 | w.WriteHeader(http.StatusBadRequest) 92 | templates.MessageTemplate(w, "Invalid offset provided.") 93 | return 94 | } 95 | } 96 | 97 | commands, err := database.FilteredListCommands(db, searchQuery, page_size, offset, command_type) 98 | if err != nil { 99 | w.WriteHeader(http.StatusBadRequest) 100 | templates.MessageTemplate(w, "Could not fetch commands." + err.Error()) 101 | return 102 | } 103 | 104 | templates.FilteredListCommandsTemplate(w, *commands, offset) 105 | } 106 | 107 | func ChangeDefaultCommand(w http.ResponseWriter, r *http.Request, db *gorm.DB) { 108 | alias := r.URL.Query().Get("default") 109 | response := "Default Command has been changed successfully to " + alias 110 | err := database.SetDefaultCommand(db, alias) 111 | if err != nil { 112 | w.WriteHeader(http.StatusBadRequest) 113 | response = "Error setting default command." 114 | } 115 | templates.NotificationTemplate(w, response) 116 | } 117 | 118 | func HandleDeleteCommand(w http.ResponseWriter, data []string, db *gorm.DB) { 119 | if len(data) != 2 { 120 | w.WriteHeader(http.StatusBadRequest) 121 | templates.MessageTemplate(w, "Invalid number of arguments provided. Delete command usage: #d ") 122 | return 123 | } 124 | command := database.SearchCommand(db, data[1], false) 125 | if command == (models.Command{}) { 126 | w.WriteHeader(http.StatusBadRequest) 127 | templates.MessageTemplate(w, "Command not found.") 128 | return 129 | } 130 | if command.Type == models.ApiCommand || command.Type == models.UtilCommand { 131 | w.WriteHeader(http.StatusBadRequest) 132 | templates.MessageTemplate(w, "Cannot delete built-in utilities or api commands.") 133 | return 134 | } 135 | database.DeleteCommand(db, data[1]) 136 | fmt.Fprintf(w, "Deleted Command: %s", data[1]) 137 | } 138 | 139 | func HandleUserCommands(w http.ResponseWriter, data []string, db *gorm.DB) { 140 | if len(data) < 3 { 141 | w.WriteHeader(http.StatusBadRequest) 142 | templates.MessageTemplate(w, "Invalid number of arguments provided") 143 | return 144 | } 145 | name := data[1] 146 | argument := strings.Join(data[2:], " ") 147 | scriptsDir := config.ScriptsPath 148 | 149 | files, err := os.ReadDir(scriptsDir) 150 | if err != nil { 151 | w.WriteHeader(http.StatusBadRequest) 152 | templates.MessageTemplate(w, "Couldn't read scripts directory: " + err.Error()) 153 | return 154 | } 155 | var scriptPath string 156 | 157 | for _, file := range files { 158 | baseName := strings.TrimSuffix(file.Name(), filepath.Ext(file.Name())) 159 | if baseName == name { 160 | scriptPath = filepath.Join(scriptsDir, file.Name()) 161 | break 162 | } 163 | } 164 | if scriptPath == "" { 165 | w.WriteHeader(http.StatusBadRequest) 166 | templates.MessageTemplate(w, "Couldn't find the specified script.") 167 | return 168 | } 169 | 170 | cmd := exec.Command(scriptPath, argument) 171 | // cmd.Env = append(os.Environ(), "SHELL=/bin/bash") 172 | output, err := cmd.CombinedOutput() 173 | if err != nil { 174 | w.WriteHeader(http.StatusBadRequest) 175 | templates.MessageTemplate(w, "Couldn't execute the script: " + err.Error()) 176 | return 177 | } 178 | 179 | templates.MessageTemplate(w, "Executed the script: " + string(output)) 180 | } 181 | -------------------------------------------------------------------------------- /internal/handlers/query.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | "regexp" 9 | "strings" 10 | 11 | "github.com/vigneshrajj/gofind/internal/database" 12 | "github.com/vigneshrajj/gofind/internal/templates" 13 | 14 | "github.com/vigneshrajj/gofind/models" 15 | "gorm.io/gorm" 16 | ) 17 | 18 | func isKeyValueArg(query string) bool { 19 | bracketIndex := strings.Index(query, "{") 20 | if bracketIndex == -1 { 21 | return false 22 | } 23 | colonIndex := strings.Index(query[bracketIndex:], ":") 24 | if colonIndex == -1 { 25 | return false 26 | } 27 | return true 28 | } 29 | 30 | func replaceKeyWithValue(input string, choice string) (string, error) { 31 | re := regexp.MustCompile(`{([^}]*)}`) 32 | matches := re.FindStringSubmatch(input) 33 | 34 | if len(matches) < 2 { 35 | return input, nil 36 | } 37 | content := matches[1] 38 | keyValuePairs := strings.Split(content, ",") 39 | 40 | kvMap := make(map[string]string) 41 | for _, pair := range keyValuePairs { 42 | parts := strings.SplitN(pair, ":", 2) 43 | if len(parts) != 2 { 44 | continue 45 | } 46 | key := strings.Split(parts[0], "$(default)")[0] 47 | kvMap[strings.TrimSpace(key)] = strings.TrimSpace(parts[1]) 48 | } 49 | 50 | if value, found := kvMap[choice]; found { 51 | return strings.Replace(input, matches[0], value, 1), nil 52 | } else { 53 | return input, nil 54 | } 55 | } 56 | 57 | func replaceKeyWithDefaults(input string) string { 58 | re := regexp.MustCompile(`\{[^{}]*\$\(\bdefault\b\):([^,{}]*)[^{}]*\}`) 59 | 60 | result := re.ReplaceAllStringFunc(input, func(match string) string { 61 | parts := re.FindStringSubmatch(match) 62 | if len(parts) > 1 { 63 | return parts[1] 64 | } 65 | return match 66 | }) 67 | 68 | return result 69 | } 70 | 71 | 72 | func HandleRedirectQuery(w http.ResponseWriter, r *http.Request, data []string, db *gorm.DB) { 73 | alias := data[0] 74 | command := database.SearchCommand(db, alias, true) 75 | 76 | if command == (models.Command{}) { 77 | defaultCommand := database.GetDefaultCommand(db) 78 | command = defaultCommand 79 | data = append([]string{command.Alias}, data...) 80 | } 81 | 82 | query := command.Query 83 | 84 | startsWithValidProtocol := strings.HasPrefix(query, "http://") || strings.HasPrefix(query, "https://") 85 | 86 | if !startsWithValidProtocol { 87 | query = "https://" + query 88 | } 89 | 90 | if strings.Contains(query, "%s") { 91 | query = fmt.Sprintf(query, url.QueryEscape(strings.Join(data[1:], " "))) 92 | http.Redirect(w, r, query, http.StatusFound) 93 | return 94 | } 95 | 96 | if isKeyValueArg(query) { 97 | argsCount := strings.Count(query, "{") - strings.Count(query, "$(default)") 98 | inputArgsCount := len(data) - 1 99 | if argsCount > inputArgsCount { 100 | w.WriteHeader(http.StatusBadRequest) 101 | templates.MessageTemplate(w, "Invalid arguments provided") 102 | return 103 | } 104 | 105 | for i := 1; i < len(data); { 106 | var err error 107 | query, err = replaceKeyWithValue(query, data[i]) 108 | if err != nil { 109 | w.WriteHeader(http.StatusBadRequest) 110 | templates.MessageTemplate(w, err.Error()) 111 | return 112 | } 113 | i++ 114 | } 115 | query = replaceKeyWithDefaults(query) 116 | if strings.Contains(query, "{") { 117 | w.WriteHeader(http.StatusBadRequest) 118 | templates.MessageTemplate(w, "Couldn't find all required arguments.") 119 | return 120 | } 121 | 122 | http.Redirect(w, r, query, http.StatusFound) 123 | return 124 | } 125 | 126 | argCount := len(data) - 1 127 | for i := argCount; i >= 1; i-- { 128 | query = strings.Replace(query, fmt.Sprintf("{%d}", i), data[i], -1) 129 | query = strings.Replace(query, fmt.Sprintf("{%d}", i), data[i], -1) 130 | } 131 | 132 | argCountInQuery := strings.Count(query, "{") 133 | isNArgQuery := strings.Count(query, "%s") == 1 134 | if argCountInQuery > 0 && !isNArgQuery { 135 | w.WriteHeader(http.StatusBadRequest) 136 | templates.MessageTemplate(w, "Invalid number of arguments provided") 137 | return 138 | } 139 | 140 | http.Redirect(w, r, query, http.StatusFound) 141 | } 142 | 143 | func HandleQuery(w http.ResponseWriter, r *http.Request, query string, db *gorm.DB) { 144 | if query == "" { 145 | w.WriteHeader(http.StatusBadRequest) 146 | templates.MessageTemplate(w, "Query cannot be empty") 147 | return 148 | } 149 | 150 | multiQuery := strings.Split(query, ";;") 151 | if len(multiQuery) > 1 { 152 | GetHostFromRequest(r) 153 | for idx := range multiQuery { 154 | multiQuery[idx] = fmt.Sprintf("%s/search?query=%s", GetHostFromRequest(r), url.QueryEscape(multiQuery[idx])) 155 | } 156 | templates.MultiQueryTemplate(w, multiQuery) 157 | return 158 | } 159 | 160 | data := strings.Split(query, " ") 161 | switch data[0] { 162 | case "#a": 163 | HandleAddCommand(w, r, data, db) 164 | case "#d": 165 | HandleDeleteCommand(w, data, db) 166 | case "#l": 167 | HandleListCommands(w, data, db) 168 | case "#cmd": 169 | HandleUserCommands(w, data, db) 170 | default: 171 | HandleRedirectQuery(w, r, data, db) 172 | } 173 | } 174 | 175 | func HandleOpenSearchSuggestions(w http.ResponseWriter, query string, db *gorm.DB) { 176 | w.Header().Set("Content-Type", "application/json") 177 | 178 | results := database.ListSuggestedCommands(db, query, 5) 179 | 180 | aliases := make([]string, 0) 181 | for _, result := range results { 182 | aliases = append(aliases, result.Alias) 183 | } 184 | 185 | resp := []interface{}{ query, aliases } 186 | 187 | respJSON, err := json.Marshal(resp) 188 | if err != nil { 189 | http.Error(w, err.Error(), http.StatusInternalServerError) 190 | return 191 | } 192 | w.Write(respJSON) 193 | } 194 | -------------------------------------------------------------------------------- /internal/handlers/utilities.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/vigneshrajj/gofind/internal/helpers" 7 | "github.com/vigneshrajj/gofind/internal/templates" 8 | ) 9 | 10 | func HandleUtilCommand(w http.ResponseWriter, data []string) { 11 | alias := data[0] 12 | switch alias { 13 | case "b64": 14 | HandleB64Util(w, data) 15 | case "d64": 16 | HandleD64Util(w, data) 17 | case "sha256": 18 | HandleSha256Util(w, data) 19 | } 20 | } 21 | 22 | func HandleSha256Util(w http.ResponseWriter, data []string) { 23 | if len(data) != 2 { 24 | w.WriteHeader(http.StatusBadRequest) 25 | templates.MessageTemplate(w, "Invalid number of arguments provided. SHA256 encode command usage: sha256 ") 26 | return 27 | } 28 | encoded := helpers.Sha256(data[1]) 29 | templates.Sha256Template(w, encoded) 30 | } 31 | 32 | func HandleD64Util(w http.ResponseWriter, data []string) { 33 | if len(data) != 2 { 34 | w.WriteHeader(http.StatusBadRequest) 35 | templates.MessageTemplate(w, "Invalid number of arguments provided. Base64 Decode command usage: d64 ") 36 | return 37 | } 38 | decoded := helpers.GetB64Decode(data[1]) 39 | templates.Base64DecodeTemplate(w, decoded) 40 | } 41 | 42 | func HandleB64Util(w http.ResponseWriter, data []string) { 43 | if len(data) != 2 { 44 | w.WriteHeader(http.StatusBadRequest) 45 | templates.MessageTemplate(w, "Invalid number of arguments provided. Base64 command usage: b64 ") 46 | return 47 | } 48 | encoded := helpers.GetB64(data[1]) 49 | templates.Base64Template(w, encoded) 50 | } 51 | -------------------------------------------------------------------------------- /internal/helpers/helpers.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/base64" 6 | "encoding/hex" 7 | ) 8 | 9 | func GetB64(data string) string { 10 | return base64.StdEncoding.EncodeToString([]byte(data)) 11 | } 12 | 13 | func GetB64Decode(data string) string { 14 | decoded, _ := base64.StdEncoding.DecodeString(data) 15 | return string(decoded) 16 | } 17 | 18 | func Sha256(data string) string { 19 | hash := sha256.New() 20 | hash.Write([]byte(data)) 21 | hashedBytes := hash.Sum(nil) 22 | hashedHex := hex.EncodeToString(hashedBytes) 23 | return hashedHex 24 | } 25 | -------------------------------------------------------------------------------- /internal/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/vigneshrajj/gofind/config" 9 | "github.com/vigneshrajj/gofind/internal/database" 10 | "github.com/vigneshrajj/gofind/internal/handlers" 11 | "github.com/vigneshrajj/gofind/internal/templates" 12 | "gorm.io/gorm" 13 | ) 14 | 15 | func HandleRoutes(db *gorm.DB) { 16 | fs := http.FileServer(http.Dir("./static")) 17 | http.Handle("/static/", http.StripPrefix("/static/", fs)) 18 | http.Handle("/files/", http.StripPrefix("/files", http.FileServer(http.Dir("/files")))) 19 | 20 | http.HandleFunc("/opensearch.xml", func(w http.ResponseWriter, r *http.Request) { 21 | templates.OpenSearchDescriptionTemplate(w) 22 | }) 23 | 24 | http.HandleFunc("/search", func(w http.ResponseWriter, r *http.Request) { 25 | query := r.URL.Query().Get("query") 26 | handlers.HandleQuery(w, r, query, db) 27 | }) 28 | 29 | 30 | http.HandleFunc("/opensearch-suggestions", func(w http.ResponseWriter, r *http.Request) { 31 | query := r.URL.Query().Get("query") 32 | handlers.HandleOpenSearchSuggestions(w, query, db) 33 | }) 34 | 35 | http.HandleFunc("/filter_commands", func(w http.ResponseWriter, r *http.Request) { 36 | handlers.HandleFilteredListCommands(w, r, db) 37 | }) 38 | 39 | http.HandleFunc("/set-default-command", func(w http.ResponseWriter, r *http.Request) { 40 | handlers.ChangeDefaultCommand(w, r, db) 41 | }) 42 | 43 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 44 | fmt.Fprintf(w, "Server running on %s", config.Port) 45 | }) 46 | } 47 | 48 | func StartServer(DbPath string, Port string) error { 49 | _, db, err := database.NewDBConnection(DbPath) 50 | if err != nil { 51 | return err 52 | } 53 | 54 | database.EnsureDefaultCommandsExist(db) 55 | if config.ItToolsUrl != "" { 56 | database.EnsureUtilCommandsExist(db) 57 | } 58 | 59 | if config.EnableAdditionalCommands { 60 | database.EnsureAdditionalCommandsExist(db) 61 | } 62 | 63 | HandleRoutes(db) 64 | 65 | log.Printf("Starting server on %s", Port) 66 | log.Fatal(http.ListenAndServe(Port, nil)) 67 | return nil 68 | } 69 | 70 | -------------------------------------------------------------------------------- /internal/templates/templates.go: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import ( 4 | "html/template" 5 | text_template "text/template" 6 | "log" 7 | "net/http" 8 | "net/url" 9 | "regexp" 10 | "strconv" 11 | "strings" 12 | 13 | "github.com/vigneshrajj/gofind/config" 14 | "github.com/vigneshrajj/gofind/models" 15 | ) 16 | 17 | type ArgType string 18 | 19 | const ( 20 | KeyVal ArgType = "keyval" 21 | Num ArgType = "num" 22 | Any ArgType = "any" 23 | None ArgType = "none" 24 | ) 25 | 26 | type CommandWithArgs struct { 27 | models.Command 28 | 29 | QueryHostname string 30 | ArgType ArgType 31 | ArgsKeyVal map[string]string 32 | ArgsNum []int 33 | } 34 | 35 | type ListCommandsPageData struct { 36 | HostUrl string 37 | Type models.CommandType 38 | EnableUtils bool 39 | } 40 | 41 | func ExtractNumArgs(query string) []int { 42 | re := regexp.MustCompile(`\{(\d+)\}`) 43 | matches := re.FindAllStringSubmatch(query, -1) 44 | 45 | var result []int 46 | 47 | for _, match := range matches { 48 | num, err := strconv.Atoi(match[1]) 49 | if err == nil { 50 | result = append(result, num) 51 | } else { 52 | log.Fatal(err.Error()) 53 | } 54 | } 55 | 56 | return result 57 | } 58 | 59 | func ExtractKeyValArgs(query string) map[string]string { 60 | re := regexp.MustCompile(`\{([^\}]+)\}`) 61 | 62 | matches := re.FindAllStringSubmatch(query, -1) 63 | 64 | result := make(map[string]string) 65 | 66 | for _, match := range matches { 67 | keyValuePairs := match[1] 68 | pairs := strings.Split(keyValuePairs, ",") 69 | for _, pair := range pairs { 70 | kv := strings.Split(pair, ":") 71 | if len(kv) == 2 { 72 | key := strings.Split(kv[0], "$(default)")[0] 73 | result[key] = kv[1] 74 | } 75 | } 76 | } 77 | 78 | return result 79 | } 80 | 81 | 82 | func extractHostnameFromQuery(query string) string { 83 | startsWithValidProtocol := strings.HasPrefix(query, "http://") || strings.HasPrefix(query, "https://") 84 | 85 | if !startsWithValidProtocol { 86 | query = "https://" + query 87 | } 88 | // regexp for getting string between second / and third / or end of line 89 | re := regexp.MustCompile(`\/([^\/]+)\/?`) 90 | matches := re.FindAllStringSubmatch(query, -1) 91 | 92 | if len(matches) < 1 || len(matches[0]) < 2 { 93 | return "" 94 | } 95 | 96 | return matches[0][1] 97 | } 98 | 99 | 100 | func addArgsAndHostToCommands(commands []models.Command) []CommandWithArgs { 101 | newCommands := make([]CommandWithArgs, 0, len(commands)) 102 | for _, command := range commands { 103 | uri, err := url.Parse(command.Query) 104 | if err != nil { 105 | newCommands = append(newCommands, CommandWithArgs{ 106 | Command: command, 107 | QueryHostname: command.Query, 108 | ArgType: None, 109 | }) 110 | continue 111 | } 112 | commandWithArgs := CommandWithArgs{ 113 | Command: command, 114 | ArgsNum: ExtractNumArgs(command.Query), 115 | ArgsKeyVal: ExtractKeyValArgs(command.Query), 116 | } 117 | 118 | isAnyArg := strings.Count(commandWithArgs.Query, "%s") == 1 119 | if len(commandWithArgs.ArgsKeyVal) > 0 { 120 | commandWithArgs.ArgType = KeyVal 121 | } else if (len(commandWithArgs.ArgsNum) > 0) { 122 | commandWithArgs.ArgType = Num 123 | } else if isAnyArg { 124 | commandWithArgs.ArgType = Any 125 | } else { 126 | commandWithArgs.ArgType = None 127 | } 128 | 129 | hostname := uri.Hostname() 130 | commandWithArgs.QueryHostname = hostname 131 | 132 | if hostname == "" { 133 | commandWithArgs.QueryHostname = extractHostnameFromQuery(command.Query) 134 | } 135 | newCommands = append(newCommands, commandWithArgs) 136 | } 137 | 138 | return newCommands 139 | } 140 | 141 | 142 | var helpers template.FuncMap = map[string]interface{}{ 143 | "isLast": func(index int, len int) bool { 144 | return index+1 == len 145 | }, 146 | } 147 | 148 | func ListCommandsTemplate(w http.ResponseWriter, command_type models.CommandType) { 149 | data := ListCommandsPageData{ 150 | HostUrl: config.HostUrl, 151 | Type: command_type, 152 | EnableUtils: config.ItToolsUrl != "", 153 | } 154 | tmpl := template.Must(template.New("list_commands.html").Funcs(helpers).ParseFiles("static/templates/list_commands.html", "static/templates/filtered_commands_list.html", "static/templates/command_tabs.html")) 155 | tmpl.Execute(w, data) 156 | } 157 | 158 | type FilteredCommandsPageData struct { 159 | Offset int 160 | Commands []CommandWithArgs 161 | } 162 | 163 | func FilteredListCommandsTemplate(w http.ResponseWriter, commands []models.Command, offset int) { 164 | data := FilteredCommandsPageData{ 165 | Offset: offset + len(commands), 166 | Commands: addArgsAndHostToCommands(commands), 167 | } 168 | 169 | tmpl := template.Must(template.New("filtered_commands_list.html").Funcs(helpers).ParseFiles("static/templates/filtered_commands_list.html")) 170 | tmpl.Execute(w, data) 171 | } 172 | 173 | type MessagePageData struct { 174 | Message string 175 | } 176 | 177 | func MessageTemplate(w http.ResponseWriter, msg string) { 178 | data := MessagePageData{ 179 | Message: msg, 180 | } 181 | tmpl := template.Must(template.ParseFiles("static/templates/message.html")) 182 | tmpl.Execute(w, data) 183 | } 184 | 185 | type B64PageType string 186 | 187 | const ( 188 | Encoded B64PageType = "encoded" 189 | Decoded B64PageType = "decoded" 190 | ) 191 | 192 | type B64PageData struct { 193 | Value string 194 | Type B64PageType 195 | } 196 | 197 | func Base64Template(w http.ResponseWriter, encoded string) { 198 | data := B64PageData{ 199 | Value: encoded, 200 | Type: Encoded, 201 | } 202 | tmpl := template.Must(template.ParseFiles("static/templates/base64.html")) 203 | tmpl.Execute(w, data) 204 | } 205 | 206 | func Base64DecodeTemplate(w http.ResponseWriter, decoded string) { 207 | data := B64PageData{ 208 | Value: decoded, 209 | Type: Decoded, 210 | } 211 | tmpl := template.Must(template.ParseFiles("static/templates/base64.html")) 212 | tmpl.Execute(w, data) 213 | } 214 | 215 | type Sha256PageData struct { 216 | Value string 217 | } 218 | 219 | func Sha256Template(w http.ResponseWriter, hashed string) { 220 | data := Sha256PageData{ 221 | Value: hashed, 222 | } 223 | tmpl := template.Must(template.ParseFiles("static/templates/sha256.html")) 224 | tmpl.Execute(w, data) 225 | } 226 | 227 | type MultiQueryPageData struct { 228 | Queries []string 229 | } 230 | 231 | func MultiQueryTemplate(w http.ResponseWriter, queries []string) { 232 | data := MultiQueryPageData{ 233 | Queries: queries, 234 | } 235 | tmpl := template.Must(template.ParseFiles("static/templates/multi_query.html")) 236 | tmpl.Execute(w, data) 237 | } 238 | 239 | type NotificationData struct { 240 | Title string 241 | } 242 | 243 | func NotificationTemplate(w http.ResponseWriter, title string) { 244 | data := NotificationData{ 245 | Title: title, 246 | } 247 | tmpl := template.Must(template.ParseFiles("static/templates/notification.html")) 248 | tmpl.Execute(w, data) 249 | } 250 | 251 | type OpenSearchPageData struct { 252 | HostUrl string 253 | } 254 | 255 | func OpenSearchDescriptionTemplate(w http.ResponseWriter) { 256 | data := OpenSearchPageData{ 257 | config.HostUrl, 258 | } 259 | tmpl := text_template.Must(text_template.ParseFiles("static/opensearch.xml")) 260 | tmpl.Execute(w, data) 261 | } 262 | -------------------------------------------------------------------------------- /models/command.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "database/sql" 5 | "time" 6 | ) 7 | 8 | type CommandType string 9 | const ( 10 | UtilCommand CommandType = "util" 11 | ApiCommand CommandType = "api" 12 | SearchCommand CommandType = "search" 13 | ) 14 | 15 | type Command struct { 16 | Alias string `gorm:"primaryKey;type:VARCHAR(50);not null" json:"alias"` 17 | CreatedAt time.Time 18 | UpdatedAt time.Time 19 | Query string 20 | Type CommandType `gorm:"type:VARCHAR(50);not null" json:"type"` 21 | Description sql.NullString 22 | IsDefault bool 23 | } 24 | -------------------------------------------------------------------------------- /static/css/input.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /static/css/styles.css: -------------------------------------------------------------------------------- 1 | *, ::before, ::after { 2 | --tw-border-spacing-x: 0; 3 | --tw-border-spacing-y: 0; 4 | --tw-translate-x: 0; 5 | --tw-translate-y: 0; 6 | --tw-rotate: 0; 7 | --tw-skew-x: 0; 8 | --tw-skew-y: 0; 9 | --tw-scale-x: 1; 10 | --tw-scale-y: 1; 11 | --tw-pan-x: ; 12 | --tw-pan-y: ; 13 | --tw-pinch-zoom: ; 14 | --tw-scroll-snap-strictness: proximity; 15 | --tw-gradient-from-position: ; 16 | --tw-gradient-via-position: ; 17 | --tw-gradient-to-position: ; 18 | --tw-ordinal: ; 19 | --tw-slashed-zero: ; 20 | --tw-numeric-figure: ; 21 | --tw-numeric-spacing: ; 22 | --tw-numeric-fraction: ; 23 | --tw-ring-inset: ; 24 | --tw-ring-offset-width: 0px; 25 | --tw-ring-offset-color: #fff; 26 | --tw-ring-color: rgb(59 130 246 / 0.5); 27 | --tw-ring-offset-shadow: 0 0 #0000; 28 | --tw-ring-shadow: 0 0 #0000; 29 | --tw-shadow: 0 0 #0000; 30 | --tw-shadow-colored: 0 0 #0000; 31 | --tw-blur: ; 32 | --tw-brightness: ; 33 | --tw-contrast: ; 34 | --tw-grayscale: ; 35 | --tw-hue-rotate: ; 36 | --tw-invert: ; 37 | --tw-saturate: ; 38 | --tw-sepia: ; 39 | --tw-drop-shadow: ; 40 | --tw-backdrop-blur: ; 41 | --tw-backdrop-brightness: ; 42 | --tw-backdrop-contrast: ; 43 | --tw-backdrop-grayscale: ; 44 | --tw-backdrop-hue-rotate: ; 45 | --tw-backdrop-invert: ; 46 | --tw-backdrop-opacity: ; 47 | --tw-backdrop-saturate: ; 48 | --tw-backdrop-sepia: ; 49 | --tw-contain-size: ; 50 | --tw-contain-layout: ; 51 | --tw-contain-paint: ; 52 | --tw-contain-style: ; 53 | } 54 | 55 | ::backdrop { 56 | --tw-border-spacing-x: 0; 57 | --tw-border-spacing-y: 0; 58 | --tw-translate-x: 0; 59 | --tw-translate-y: 0; 60 | --tw-rotate: 0; 61 | --tw-skew-x: 0; 62 | --tw-skew-y: 0; 63 | --tw-scale-x: 1; 64 | --tw-scale-y: 1; 65 | --tw-pan-x: ; 66 | --tw-pan-y: ; 67 | --tw-pinch-zoom: ; 68 | --tw-scroll-snap-strictness: proximity; 69 | --tw-gradient-from-position: ; 70 | --tw-gradient-via-position: ; 71 | --tw-gradient-to-position: ; 72 | --tw-ordinal: ; 73 | --tw-slashed-zero: ; 74 | --tw-numeric-figure: ; 75 | --tw-numeric-spacing: ; 76 | --tw-numeric-fraction: ; 77 | --tw-ring-inset: ; 78 | --tw-ring-offset-width: 0px; 79 | --tw-ring-offset-color: #fff; 80 | --tw-ring-color: rgb(59 130 246 / 0.5); 81 | --tw-ring-offset-shadow: 0 0 #0000; 82 | --tw-ring-shadow: 0 0 #0000; 83 | --tw-shadow: 0 0 #0000; 84 | --tw-shadow-colored: 0 0 #0000; 85 | --tw-blur: ; 86 | --tw-brightness: ; 87 | --tw-contrast: ; 88 | --tw-grayscale: ; 89 | --tw-hue-rotate: ; 90 | --tw-invert: ; 91 | --tw-saturate: ; 92 | --tw-sepia: ; 93 | --tw-drop-shadow: ; 94 | --tw-backdrop-blur: ; 95 | --tw-backdrop-brightness: ; 96 | --tw-backdrop-contrast: ; 97 | --tw-backdrop-grayscale: ; 98 | --tw-backdrop-hue-rotate: ; 99 | --tw-backdrop-invert: ; 100 | --tw-backdrop-opacity: ; 101 | --tw-backdrop-saturate: ; 102 | --tw-backdrop-sepia: ; 103 | --tw-contain-size: ; 104 | --tw-contain-layout: ; 105 | --tw-contain-paint: ; 106 | --tw-contain-style: ; 107 | } 108 | 109 | /* 110 | ! tailwindcss v3.4.14 | MIT License | https://tailwindcss.com 111 | */ 112 | 113 | /* 114 | 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) 115 | 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) 116 | */ 117 | 118 | *, 119 | ::before, 120 | ::after { 121 | box-sizing: border-box; 122 | /* 1 */ 123 | border-width: 0; 124 | /* 2 */ 125 | border-style: solid; 126 | /* 2 */ 127 | border-color: #e5e7eb; 128 | /* 2 */ 129 | } 130 | 131 | ::before, 132 | ::after { 133 | --tw-content: ''; 134 | } 135 | 136 | /* 137 | 1. Use a consistent sensible line-height in all browsers. 138 | 2. Prevent adjustments of font size after orientation changes in iOS. 139 | 3. Use a more readable tab size. 140 | 4. Use the user's configured `sans` font-family by default. 141 | 5. Use the user's configured `sans` font-feature-settings by default. 142 | 6. Use the user's configured `sans` font-variation-settings by default. 143 | 7. Disable tap highlights on iOS 144 | */ 145 | 146 | html, 147 | :host { 148 | line-height: 1.5; 149 | /* 1 */ 150 | -webkit-text-size-adjust: 100%; 151 | /* 2 */ 152 | -moz-tab-size: 4; 153 | /* 3 */ 154 | -o-tab-size: 4; 155 | tab-size: 4; 156 | /* 3 */ 157 | font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 158 | /* 4 */ 159 | font-feature-settings: normal; 160 | /* 5 */ 161 | font-variation-settings: normal; 162 | /* 6 */ 163 | -webkit-tap-highlight-color: transparent; 164 | /* 7 */ 165 | } 166 | 167 | /* 168 | 1. Remove the margin in all browsers. 169 | 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. 170 | */ 171 | 172 | body { 173 | margin: 0; 174 | /* 1 */ 175 | line-height: inherit; 176 | /* 2 */ 177 | } 178 | 179 | /* 180 | 1. Add the correct height in Firefox. 181 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) 182 | 3. Ensure horizontal rules are visible by default. 183 | */ 184 | 185 | hr { 186 | height: 0; 187 | /* 1 */ 188 | color: inherit; 189 | /* 2 */ 190 | border-top-width: 1px; 191 | /* 3 */ 192 | } 193 | 194 | /* 195 | Add the correct text decoration in Chrome, Edge, and Safari. 196 | */ 197 | 198 | abbr:where([title]) { 199 | -webkit-text-decoration: underline dotted; 200 | text-decoration: underline dotted; 201 | } 202 | 203 | /* 204 | Remove the default font size and weight for headings. 205 | */ 206 | 207 | h1, 208 | h2, 209 | h3, 210 | h4, 211 | h5, 212 | h6 { 213 | font-size: inherit; 214 | font-weight: inherit; 215 | } 216 | 217 | /* 218 | Reset links to optimize for opt-in styling instead of opt-out. 219 | */ 220 | 221 | a { 222 | color: inherit; 223 | text-decoration: inherit; 224 | } 225 | 226 | /* 227 | Add the correct font weight in Edge and Safari. 228 | */ 229 | 230 | b, 231 | strong { 232 | font-weight: bolder; 233 | } 234 | 235 | /* 236 | 1. Use the user's configured `mono` font-family by default. 237 | 2. Use the user's configured `mono` font-feature-settings by default. 238 | 3. Use the user's configured `mono` font-variation-settings by default. 239 | 4. Correct the odd `em` font sizing in all browsers. 240 | */ 241 | 242 | code, 243 | kbd, 244 | samp, 245 | pre { 246 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 247 | /* 1 */ 248 | font-feature-settings: normal; 249 | /* 2 */ 250 | font-variation-settings: normal; 251 | /* 3 */ 252 | font-size: 1em; 253 | /* 4 */ 254 | } 255 | 256 | /* 257 | Add the correct font size in all browsers. 258 | */ 259 | 260 | small { 261 | font-size: 80%; 262 | } 263 | 264 | /* 265 | Prevent `sub` and `sup` elements from affecting the line height in all browsers. 266 | */ 267 | 268 | sub, 269 | sup { 270 | font-size: 75%; 271 | line-height: 0; 272 | position: relative; 273 | vertical-align: baseline; 274 | } 275 | 276 | sub { 277 | bottom: -0.25em; 278 | } 279 | 280 | sup { 281 | top: -0.5em; 282 | } 283 | 284 | /* 285 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) 286 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) 287 | 3. Remove gaps between table borders by default. 288 | */ 289 | 290 | table { 291 | text-indent: 0; 292 | /* 1 */ 293 | border-color: inherit; 294 | /* 2 */ 295 | border-collapse: collapse; 296 | /* 3 */ 297 | } 298 | 299 | /* 300 | 1. Change the font styles in all browsers. 301 | 2. Remove the margin in Firefox and Safari. 302 | 3. Remove default padding in all browsers. 303 | */ 304 | 305 | button, 306 | input, 307 | optgroup, 308 | select, 309 | textarea { 310 | font-family: inherit; 311 | /* 1 */ 312 | font-feature-settings: inherit; 313 | /* 1 */ 314 | font-variation-settings: inherit; 315 | /* 1 */ 316 | font-size: 100%; 317 | /* 1 */ 318 | font-weight: inherit; 319 | /* 1 */ 320 | line-height: inherit; 321 | /* 1 */ 322 | letter-spacing: inherit; 323 | /* 1 */ 324 | color: inherit; 325 | /* 1 */ 326 | margin: 0; 327 | /* 2 */ 328 | padding: 0; 329 | /* 3 */ 330 | } 331 | 332 | /* 333 | Remove the inheritance of text transform in Edge and Firefox. 334 | */ 335 | 336 | button, 337 | select { 338 | text-transform: none; 339 | } 340 | 341 | /* 342 | 1. Correct the inability to style clickable types in iOS and Safari. 343 | 2. Remove default button styles. 344 | */ 345 | 346 | button, 347 | input:where([type='button']), 348 | input:where([type='reset']), 349 | input:where([type='submit']) { 350 | -webkit-appearance: button; 351 | /* 1 */ 352 | background-color: transparent; 353 | /* 2 */ 354 | background-image: none; 355 | /* 2 */ 356 | } 357 | 358 | /* 359 | Use the modern Firefox focus style for all focusable elements. 360 | */ 361 | 362 | :-moz-focusring { 363 | outline: auto; 364 | } 365 | 366 | /* 367 | Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) 368 | */ 369 | 370 | :-moz-ui-invalid { 371 | box-shadow: none; 372 | } 373 | 374 | /* 375 | Add the correct vertical alignment in Chrome and Firefox. 376 | */ 377 | 378 | progress { 379 | vertical-align: baseline; 380 | } 381 | 382 | /* 383 | Correct the cursor style of increment and decrement buttons in Safari. 384 | */ 385 | 386 | ::-webkit-inner-spin-button, 387 | ::-webkit-outer-spin-button { 388 | height: auto; 389 | } 390 | 391 | /* 392 | 1. Correct the odd appearance in Chrome and Safari. 393 | 2. Correct the outline style in Safari. 394 | */ 395 | 396 | [type='search'] { 397 | -webkit-appearance: textfield; 398 | /* 1 */ 399 | outline-offset: -2px; 400 | /* 2 */ 401 | } 402 | 403 | /* 404 | Remove the inner padding in Chrome and Safari on macOS. 405 | */ 406 | 407 | ::-webkit-search-decoration { 408 | -webkit-appearance: none; 409 | } 410 | 411 | /* 412 | 1. Correct the inability to style clickable types in iOS and Safari. 413 | 2. Change font properties to `inherit` in Safari. 414 | */ 415 | 416 | ::-webkit-file-upload-button { 417 | -webkit-appearance: button; 418 | /* 1 */ 419 | font: inherit; 420 | /* 2 */ 421 | } 422 | 423 | /* 424 | Add the correct display in Chrome and Safari. 425 | */ 426 | 427 | summary { 428 | display: list-item; 429 | } 430 | 431 | /* 432 | Removes the default spacing and border for appropriate elements. 433 | */ 434 | 435 | blockquote, 436 | dl, 437 | dd, 438 | h1, 439 | h2, 440 | h3, 441 | h4, 442 | h5, 443 | h6, 444 | hr, 445 | figure, 446 | p, 447 | pre { 448 | margin: 0; 449 | } 450 | 451 | fieldset { 452 | margin: 0; 453 | padding: 0; 454 | } 455 | 456 | legend { 457 | padding: 0; 458 | } 459 | 460 | ol, 461 | ul, 462 | menu { 463 | list-style: none; 464 | margin: 0; 465 | padding: 0; 466 | } 467 | 468 | /* 469 | Reset default styling for dialogs. 470 | */ 471 | 472 | dialog { 473 | padding: 0; 474 | } 475 | 476 | /* 477 | Prevent resizing textareas horizontally by default. 478 | */ 479 | 480 | textarea { 481 | resize: vertical; 482 | } 483 | 484 | /* 485 | 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) 486 | 2. Set the default placeholder color to the user's configured gray 400 color. 487 | */ 488 | 489 | input::-moz-placeholder, textarea::-moz-placeholder { 490 | opacity: 1; 491 | /* 1 */ 492 | color: #9ca3af; 493 | /* 2 */ 494 | } 495 | 496 | input::placeholder, 497 | textarea::placeholder { 498 | opacity: 1; 499 | /* 1 */ 500 | color: #9ca3af; 501 | /* 2 */ 502 | } 503 | 504 | /* 505 | Set the default cursor for buttons. 506 | */ 507 | 508 | button, 509 | [role="button"] { 510 | cursor: pointer; 511 | } 512 | 513 | /* 514 | Make sure disabled buttons don't get the pointer cursor. 515 | */ 516 | 517 | :disabled { 518 | cursor: default; 519 | } 520 | 521 | /* 522 | 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) 523 | 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) 524 | This can trigger a poorly considered lint error in some tools but is included by design. 525 | */ 526 | 527 | img, 528 | svg, 529 | video, 530 | canvas, 531 | audio, 532 | iframe, 533 | embed, 534 | object { 535 | display: block; 536 | /* 1 */ 537 | vertical-align: middle; 538 | /* 2 */ 539 | } 540 | 541 | /* 542 | Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) 543 | */ 544 | 545 | img, 546 | video { 547 | max-width: 100%; 548 | height: auto; 549 | } 550 | 551 | /* Make elements with the HTML hidden attribute stay hidden by default */ 552 | 553 | [hidden]:where(:not([hidden="until-found"])) { 554 | display: none; 555 | } 556 | 557 | [type='text'],input:where(:not([type])),[type='email'],[type='url'],[type='password'],[type='number'],[type='date'],[type='datetime-local'],[type='month'],[type='search'],[type='tel'],[type='time'],[type='week'],[multiple],textarea,select { 558 | -webkit-appearance: none; 559 | -moz-appearance: none; 560 | appearance: none; 561 | background-color: #fff; 562 | border-color: #6b7280; 563 | border-width: 1px; 564 | border-radius: 0px; 565 | padding-top: 0.5rem; 566 | padding-right: 0.75rem; 567 | padding-bottom: 0.5rem; 568 | padding-left: 0.75rem; 569 | font-size: 1rem; 570 | line-height: 1.5rem; 571 | --tw-shadow: 0 0 #0000; 572 | } 573 | 574 | [type='text']:focus, input:where(:not([type])):focus, [type='email']:focus, [type='url']:focus, [type='password']:focus, [type='number']:focus, [type='date']:focus, [type='datetime-local']:focus, [type='month']:focus, [type='search']:focus, [type='tel']:focus, [type='time']:focus, [type='week']:focus, [multiple]:focus, textarea:focus, select:focus { 575 | outline: 2px solid transparent; 576 | outline-offset: 2px; 577 | --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); 578 | --tw-ring-offset-width: 0px; 579 | --tw-ring-offset-color: #fff; 580 | --tw-ring-color: #2563eb; 581 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); 582 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); 583 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); 584 | border-color: #2563eb; 585 | } 586 | 587 | input::-moz-placeholder, textarea::-moz-placeholder { 588 | color: #6b7280; 589 | opacity: 1; 590 | } 591 | 592 | input::placeholder,textarea::placeholder { 593 | color: #6b7280; 594 | opacity: 1; 595 | } 596 | 597 | ::-webkit-datetime-edit-fields-wrapper { 598 | padding: 0; 599 | } 600 | 601 | ::-webkit-date-and-time-value { 602 | min-height: 1.5em; 603 | text-align: inherit; 604 | } 605 | 606 | ::-webkit-datetime-edit { 607 | display: inline-flex; 608 | } 609 | 610 | ::-webkit-datetime-edit,::-webkit-datetime-edit-year-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-meridiem-field { 611 | padding-top: 0; 612 | padding-bottom: 0; 613 | } 614 | 615 | select { 616 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); 617 | background-position: right 0.5rem center; 618 | background-repeat: no-repeat; 619 | background-size: 1.5em 1.5em; 620 | padding-right: 2.5rem; 621 | -webkit-print-color-adjust: exact; 622 | print-color-adjust: exact; 623 | } 624 | 625 | [multiple],[size]:where(select:not([size="1"])) { 626 | background-image: initial; 627 | background-position: initial; 628 | background-repeat: unset; 629 | background-size: initial; 630 | padding-right: 0.75rem; 631 | -webkit-print-color-adjust: unset; 632 | print-color-adjust: unset; 633 | } 634 | 635 | [type='checkbox'],[type='radio'] { 636 | -webkit-appearance: none; 637 | -moz-appearance: none; 638 | appearance: none; 639 | padding: 0; 640 | -webkit-print-color-adjust: exact; 641 | print-color-adjust: exact; 642 | display: inline-block; 643 | vertical-align: middle; 644 | background-origin: border-box; 645 | -webkit-user-select: none; 646 | -moz-user-select: none; 647 | user-select: none; 648 | flex-shrink: 0; 649 | height: 1rem; 650 | width: 1rem; 651 | color: #2563eb; 652 | background-color: #fff; 653 | border-color: #6b7280; 654 | border-width: 1px; 655 | --tw-shadow: 0 0 #0000; 656 | } 657 | 658 | [type='checkbox'] { 659 | border-radius: 0px; 660 | } 661 | 662 | [type='radio'] { 663 | border-radius: 100%; 664 | } 665 | 666 | [type='checkbox']:focus,[type='radio']:focus { 667 | outline: 2px solid transparent; 668 | outline-offset: 2px; 669 | --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); 670 | --tw-ring-offset-width: 2px; 671 | --tw-ring-offset-color: #fff; 672 | --tw-ring-color: #2563eb; 673 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); 674 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); 675 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); 676 | } 677 | 678 | [type='checkbox']:checked,[type='radio']:checked { 679 | border-color: transparent; 680 | background-color: currentColor; 681 | background-size: 100% 100%; 682 | background-position: center; 683 | background-repeat: no-repeat; 684 | } 685 | 686 | [type='checkbox']:checked { 687 | background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); 688 | } 689 | 690 | @media (forced-colors: active) { 691 | [type='checkbox']:checked { 692 | -webkit-appearance: auto; 693 | -moz-appearance: auto; 694 | appearance: auto; 695 | } 696 | } 697 | 698 | [type='radio']:checked { 699 | background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e"); 700 | } 701 | 702 | @media (forced-colors: active) { 703 | [type='radio']:checked { 704 | -webkit-appearance: auto; 705 | -moz-appearance: auto; 706 | appearance: auto; 707 | } 708 | } 709 | 710 | [type='checkbox']:checked:hover,[type='checkbox']:checked:focus,[type='radio']:checked:hover,[type='radio']:checked:focus { 711 | border-color: transparent; 712 | background-color: currentColor; 713 | } 714 | 715 | [type='checkbox']:indeterminate { 716 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e"); 717 | border-color: transparent; 718 | background-color: currentColor; 719 | background-size: 100% 100%; 720 | background-position: center; 721 | background-repeat: no-repeat; 722 | } 723 | 724 | @media (forced-colors: active) { 725 | [type='checkbox']:indeterminate { 726 | -webkit-appearance: auto; 727 | -moz-appearance: auto; 728 | appearance: auto; 729 | } 730 | } 731 | 732 | [type='checkbox']:indeterminate:hover,[type='checkbox']:indeterminate:focus { 733 | border-color: transparent; 734 | background-color: currentColor; 735 | } 736 | 737 | [type='file'] { 738 | background: unset; 739 | border-color: inherit; 740 | border-width: 0; 741 | border-radius: 0; 742 | padding: 0; 743 | font-size: unset; 744 | line-height: inherit; 745 | } 746 | 747 | [type='file']:focus { 748 | outline: 1px solid ButtonText; 749 | outline: 1px auto -webkit-focus-ring-color; 750 | } 751 | 752 | .sr-only { 753 | position: absolute; 754 | width: 1px; 755 | height: 1px; 756 | padding: 0; 757 | margin: -1px; 758 | overflow: hidden; 759 | clip: rect(0, 0, 0, 0); 760 | white-space: nowrap; 761 | border-width: 0; 762 | } 763 | 764 | .pointer-events-none { 765 | pointer-events: none; 766 | } 767 | 768 | .pointer-events-auto { 769 | pointer-events: auto; 770 | } 771 | 772 | .invisible { 773 | visibility: hidden; 774 | } 775 | 776 | .fixed { 777 | position: fixed; 778 | } 779 | 780 | .absolute { 781 | position: absolute; 782 | } 783 | 784 | .relative { 785 | position: relative; 786 | } 787 | 788 | .sticky { 789 | position: sticky; 790 | } 791 | 792 | .inset-0 { 793 | inset: 0px; 794 | } 795 | 796 | .inset-y-0 { 797 | top: 0px; 798 | bottom: 0px; 799 | } 800 | 801 | .bottom-\[calc\(80\%\)\] { 802 | bottom: calc(80%); 803 | } 804 | 805 | .left-0 { 806 | left: 0px; 807 | } 808 | 809 | .right-0 { 810 | right: 0px; 811 | } 812 | 813 | .top-0 { 814 | top: 0px; 815 | } 816 | 817 | .z-10 { 818 | z-index: 10; 819 | } 820 | 821 | .-mx-4 { 822 | margin-left: -1rem; 823 | margin-right: -1rem; 824 | } 825 | 826 | .-my-2 { 827 | margin-top: -0.5rem; 828 | margin-bottom: -0.5rem; 829 | } 830 | 831 | .mx-auto { 832 | margin-left: auto; 833 | margin-right: auto; 834 | } 835 | 836 | .my-1 { 837 | margin-top: 0.25rem; 838 | margin-bottom: 0.25rem; 839 | } 840 | 841 | .my-10 { 842 | margin-top: 2.5rem; 843 | margin-bottom: 2.5rem; 844 | } 845 | 846 | .my-4 { 847 | margin-top: 1rem; 848 | margin-bottom: 1rem; 849 | } 850 | 851 | .-mb-px { 852 | margin-bottom: -1px; 853 | } 854 | 855 | .ml-3 { 856 | margin-left: 0.75rem; 857 | } 858 | 859 | .ml-4 { 860 | margin-left: 1rem; 861 | } 862 | 863 | .mt-2 { 864 | margin-top: 0.5rem; 865 | } 866 | 867 | .block { 868 | display: block; 869 | } 870 | 871 | .inline-block { 872 | display: inline-block; 873 | } 874 | 875 | .flex { 876 | display: flex; 877 | } 878 | 879 | .inline-flex { 880 | display: inline-flex; 881 | } 882 | 883 | .table { 884 | display: table; 885 | } 886 | 887 | .table-cell { 888 | display: table-cell; 889 | } 890 | 891 | .flow-root { 892 | display: flow-root; 893 | } 894 | 895 | .hidden { 896 | display: none; 897 | } 898 | 899 | .h-4 { 900 | height: 1rem; 901 | } 902 | 903 | .h-5 { 904 | height: 1.25rem; 905 | } 906 | 907 | .h-6 { 908 | height: 1.5rem; 909 | } 910 | 911 | .w-0 { 912 | width: 0px; 913 | } 914 | 915 | .w-10 { 916 | width: 2.5rem; 917 | } 918 | 919 | .w-4 { 920 | width: 1rem; 921 | } 922 | 923 | .w-5 { 924 | width: 1.25rem; 925 | } 926 | 927 | .w-6 { 928 | width: 1.5rem; 929 | } 930 | 931 | .w-full { 932 | width: 100%; 933 | } 934 | 935 | .min-w-full { 936 | min-width: 100%; 937 | } 938 | 939 | .max-w-7xl { 940 | max-width: 80rem; 941 | } 942 | 943 | .max-w-\[20vw\] { 944 | max-width: 20vw; 945 | } 946 | 947 | .max-w-sm { 948 | max-width: 24rem; 949 | } 950 | 951 | .flex-1 { 952 | flex: 1 1 0%; 953 | } 954 | 955 | .shrink-0 { 956 | flex-shrink: 0; 957 | } 958 | 959 | .table-fixed { 960 | table-layout: fixed; 961 | } 962 | 963 | .border-separate { 964 | border-collapse: separate; 965 | } 966 | 967 | .border-spacing-0 { 968 | --tw-border-spacing-x: 0px; 969 | --tw-border-spacing-y: 0px; 970 | border-spacing: var(--tw-border-spacing-x) var(--tw-border-spacing-y); 971 | } 972 | 973 | .flex-col { 974 | flex-direction: column; 975 | } 976 | 977 | .items-start { 978 | align-items: flex-start; 979 | } 980 | 981 | .items-end { 982 | align-items: flex-end; 983 | } 984 | 985 | .items-center { 986 | align-items: center; 987 | } 988 | 989 | .space-x-8 > :not([hidden]) ~ :not([hidden]) { 990 | --tw-space-x-reverse: 0; 991 | margin-right: calc(2rem * var(--tw-space-x-reverse)); 992 | margin-left: calc(2rem * calc(1 - var(--tw-space-x-reverse))); 993 | } 994 | 995 | .space-y-4 > :not([hidden]) ~ :not([hidden]) { 996 | --tw-space-y-reverse: 0; 997 | margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse))); 998 | margin-bottom: calc(1rem * var(--tw-space-y-reverse)); 999 | } 1000 | 1001 | .overflow-hidden { 1002 | overflow: hidden; 1003 | } 1004 | 1005 | .whitespace-nowrap { 1006 | white-space: nowrap; 1007 | } 1008 | 1009 | .rounded { 1010 | border-radius: 0.25rem; 1011 | } 1012 | 1013 | .rounded-lg { 1014 | border-radius: 0.5rem; 1015 | } 1016 | 1017 | .rounded-md { 1018 | border-radius: 0.375rem; 1019 | } 1020 | 1021 | .rounded-tl-md { 1022 | border-top-left-radius: 0.375rem; 1023 | } 1024 | 1025 | .rounded-tr-md { 1026 | border-top-right-radius: 0.375rem; 1027 | } 1028 | 1029 | .border { 1030 | border-width: 1px; 1031 | } 1032 | 1033 | .border-0 { 1034 | border-width: 0px; 1035 | } 1036 | 1037 | .border-b { 1038 | border-bottom-width: 1px; 1039 | } 1040 | 1041 | .border-b-2 { 1042 | border-bottom-width: 2px; 1043 | } 1044 | 1045 | .border-gray-200 { 1046 | --tw-border-opacity: 1; 1047 | border-color: rgb(229 231 235 / var(--tw-border-opacity)); 1048 | } 1049 | 1050 | .border-gray-300 { 1051 | --tw-border-opacity: 1; 1052 | border-color: rgb(209 213 219 / var(--tw-border-opacity)); 1053 | } 1054 | 1055 | .border-green-500 { 1056 | --tw-border-opacity: 1; 1057 | border-color: rgb(34 197 94 / var(--tw-border-opacity)); 1058 | } 1059 | 1060 | .border-transparent { 1061 | border-color: transparent; 1062 | } 1063 | 1064 | .bg-green-50 { 1065 | --tw-bg-opacity: 1; 1066 | background-color: rgb(240 253 244 / var(--tw-bg-opacity)); 1067 | } 1068 | 1069 | .bg-white { 1070 | --tw-bg-opacity: 1; 1071 | background-color: rgb(255 255 255 / var(--tw-bg-opacity)); 1072 | } 1073 | 1074 | .bg-opacity-75 { 1075 | --tw-bg-opacity: 0.75; 1076 | } 1077 | 1078 | .p-4 { 1079 | padding: 1rem; 1080 | } 1081 | 1082 | .px-1 { 1083 | padding-left: 0.25rem; 1084 | padding-right: 0.25rem; 1085 | } 1086 | 1087 | .px-2 { 1088 | padding-left: 0.5rem; 1089 | padding-right: 0.5rem; 1090 | } 1091 | 1092 | .px-3 { 1093 | padding-left: 0.75rem; 1094 | padding-right: 0.75rem; 1095 | } 1096 | 1097 | .px-4 { 1098 | padding-left: 1rem; 1099 | padding-right: 1rem; 1100 | } 1101 | 1102 | .py-1 { 1103 | padding-top: 0.25rem; 1104 | padding-bottom: 0.25rem; 1105 | } 1106 | 1107 | .py-1\.5 { 1108 | padding-top: 0.375rem; 1109 | padding-bottom: 0.375rem; 1110 | } 1111 | 1112 | .py-2 { 1113 | padding-top: 0.5rem; 1114 | padding-bottom: 0.5rem; 1115 | } 1116 | 1117 | .py-3\.5 { 1118 | padding-top: 0.875rem; 1119 | padding-bottom: 0.875rem; 1120 | } 1121 | 1122 | .py-4 { 1123 | padding-top: 1rem; 1124 | padding-bottom: 1rem; 1125 | } 1126 | 1127 | .py-6 { 1128 | padding-top: 1.5rem; 1129 | padding-bottom: 1.5rem; 1130 | } 1131 | 1132 | .pl-3 { 1133 | padding-left: 0.75rem; 1134 | } 1135 | 1136 | .pl-4 { 1137 | padding-left: 1rem; 1138 | } 1139 | 1140 | .pr-1\.5 { 1141 | padding-right: 0.375rem; 1142 | } 1143 | 1144 | .pr-10 { 1145 | padding-right: 2.5rem; 1146 | } 1147 | 1148 | .pr-14 { 1149 | padding-right: 3.5rem; 1150 | } 1151 | 1152 | .pr-3 { 1153 | padding-right: 0.75rem; 1154 | } 1155 | 1156 | .pr-4 { 1157 | padding-right: 1rem; 1158 | } 1159 | 1160 | .pt-0\.5 { 1161 | padding-top: 0.125rem; 1162 | } 1163 | 1164 | .text-left { 1165 | text-align: left; 1166 | } 1167 | 1168 | .text-center { 1169 | text-align: center; 1170 | } 1171 | 1172 | .text-right { 1173 | text-align: right; 1174 | } 1175 | 1176 | .align-middle { 1177 | vertical-align: middle; 1178 | } 1179 | 1180 | .font-sans { 1181 | font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 1182 | } 1183 | 1184 | .text-4xl { 1185 | font-size: 2.25rem; 1186 | line-height: 2.5rem; 1187 | } 1188 | 1189 | .text-base { 1190 | font-size: 1rem; 1191 | line-height: 1.5rem; 1192 | } 1193 | 1194 | .text-sm { 1195 | font-size: 0.875rem; 1196 | line-height: 1.25rem; 1197 | } 1198 | 1199 | .text-xs { 1200 | font-size: 0.75rem; 1201 | line-height: 1rem; 1202 | } 1203 | 1204 | .font-bold { 1205 | font-weight: 700; 1206 | } 1207 | 1208 | .font-medium { 1209 | font-weight: 500; 1210 | } 1211 | 1212 | .font-semibold { 1213 | font-weight: 600; 1214 | } 1215 | 1216 | .text-gray-400 { 1217 | --tw-text-opacity: 1; 1218 | color: rgb(156 163 175 / var(--tw-text-opacity)); 1219 | } 1220 | 1221 | .text-gray-500 { 1222 | --tw-text-opacity: 1; 1223 | color: rgb(107 114 128 / var(--tw-text-opacity)); 1224 | } 1225 | 1226 | .text-gray-900 { 1227 | --tw-text-opacity: 1; 1228 | color: rgb(17 24 39 / var(--tw-text-opacity)); 1229 | } 1230 | 1231 | .text-green-400 { 1232 | --tw-text-opacity: 1; 1233 | color: rgb(74 222 128 / var(--tw-text-opacity)); 1234 | } 1235 | 1236 | .text-green-600 { 1237 | --tw-text-opacity: 1; 1238 | color: rgb(22 163 74 / var(--tw-text-opacity)); 1239 | } 1240 | 1241 | .text-green-700 { 1242 | --tw-text-opacity: 1; 1243 | color: rgb(21 128 61 / var(--tw-text-opacity)); 1244 | } 1245 | 1246 | .shadow-lg { 1247 | --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); 1248 | --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color); 1249 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 1250 | } 1251 | 1252 | .shadow-sm { 1253 | --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); 1254 | --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color); 1255 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 1256 | } 1257 | 1258 | .ring-1 { 1259 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); 1260 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); 1261 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); 1262 | } 1263 | 1264 | .ring-inset { 1265 | --tw-ring-inset: inset; 1266 | } 1267 | 1268 | .ring-black { 1269 | --tw-ring-opacity: 1; 1270 | --tw-ring-color: rgb(0 0 0 / var(--tw-ring-opacity)); 1271 | } 1272 | 1273 | .ring-gray-300 { 1274 | --tw-ring-opacity: 1; 1275 | --tw-ring-color: rgb(209 213 219 / var(--tw-ring-opacity)); 1276 | } 1277 | 1278 | .ring-green-600\/20 { 1279 | --tw-ring-color: rgb(22 163 74 / 0.2); 1280 | } 1281 | 1282 | .ring-opacity-5 { 1283 | --tw-ring-opacity: 0.05; 1284 | } 1285 | 1286 | .backdrop-blur { 1287 | --tw-backdrop-blur: blur(8px); 1288 | -webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); 1289 | backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); 1290 | } 1291 | 1292 | .backdrop-filter { 1293 | -webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); 1294 | backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); 1295 | } 1296 | 1297 | .placeholder\:text-gray-400::-moz-placeholder { 1298 | --tw-text-opacity: 1; 1299 | color: rgb(156 163 175 / var(--tw-text-opacity)); 1300 | } 1301 | 1302 | .placeholder\:text-gray-400::placeholder { 1303 | --tw-text-opacity: 1; 1304 | color: rgb(156 163 175 / var(--tw-text-opacity)); 1305 | } 1306 | 1307 | .hover\:border-gray-300:hover { 1308 | --tw-border-opacity: 1; 1309 | border-color: rgb(209 213 219 / var(--tw-border-opacity)); 1310 | } 1311 | 1312 | .hover\:bg-green-50:hover { 1313 | --tw-bg-opacity: 1; 1314 | background-color: rgb(240 253 244 / var(--tw-bg-opacity)); 1315 | } 1316 | 1317 | .hover\:text-gray-500:hover { 1318 | --tw-text-opacity: 1; 1319 | color: rgb(107 114 128 / var(--tw-text-opacity)); 1320 | } 1321 | 1322 | .hover\:text-gray-700:hover { 1323 | --tw-text-opacity: 1; 1324 | color: rgb(55 65 81 / var(--tw-text-opacity)); 1325 | } 1326 | 1327 | .focus\:border-green-500:focus { 1328 | --tw-border-opacity: 1; 1329 | border-color: rgb(34 197 94 / var(--tw-border-opacity)); 1330 | } 1331 | 1332 | .focus\:outline-none:focus { 1333 | outline: 2px solid transparent; 1334 | outline-offset: 2px; 1335 | } 1336 | 1337 | .focus\:ring-2:focus { 1338 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); 1339 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); 1340 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); 1341 | } 1342 | 1343 | .focus\:ring-inset:focus { 1344 | --tw-ring-inset: inset; 1345 | } 1346 | 1347 | .focus\:ring-green-500:focus { 1348 | --tw-ring-opacity: 1; 1349 | --tw-ring-color: rgb(34 197 94 / var(--tw-ring-opacity)); 1350 | } 1351 | 1352 | .focus\:ring-green-600:focus { 1353 | --tw-ring-opacity: 1; 1354 | --tw-ring-color: rgb(22 163 74 / var(--tw-ring-opacity)); 1355 | } 1356 | 1357 | .focus\:ring-offset-2:focus { 1358 | --tw-ring-offset-width: 2px; 1359 | } 1360 | 1361 | .active\:ring-2:active { 1362 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); 1363 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); 1364 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); 1365 | } 1366 | 1367 | .active\:ring-green-600:active { 1368 | --tw-ring-opacity: 1; 1369 | --tw-ring-color: rgb(22 163 74 / var(--tw-ring-opacity)); 1370 | } 1371 | 1372 | .active\:ring-offset-2:active { 1373 | --tw-ring-offset-width: 2px; 1374 | } 1375 | 1376 | .group:hover .group-hover\:visible { 1377 | visibility: visible; 1378 | } 1379 | 1380 | .peer\/search:checked ~ .peer-checked\/search\:border-green-500 { 1381 | --tw-border-opacity: 1; 1382 | border-color: rgb(34 197 94 / var(--tw-border-opacity)); 1383 | } 1384 | 1385 | .peer\/util:checked ~ .peer-checked\/util\:border-green-500 { 1386 | --tw-border-opacity: 1; 1387 | border-color: rgb(34 197 94 / var(--tw-border-opacity)); 1388 | } 1389 | 1390 | .peer\/search:checked ~ .peer-checked\/search\:text-green-600 { 1391 | --tw-text-opacity: 1; 1392 | color: rgb(22 163 74 / var(--tw-text-opacity)); 1393 | } 1394 | 1395 | .peer\/util:checked ~ .peer-checked\/util\:text-green-600 { 1396 | --tw-text-opacity: 1; 1397 | color: rgb(22 163 74 / var(--tw-text-opacity)); 1398 | } 1399 | 1400 | @media (min-width: 640px) { 1401 | .sm\:-mx-6 { 1402 | margin-left: -1.5rem; 1403 | margin-right: -1.5rem; 1404 | } 1405 | 1406 | .sm\:block { 1407 | display: block; 1408 | } 1409 | 1410 | .sm\:hidden { 1411 | display: none; 1412 | } 1413 | 1414 | .sm\:items-start { 1415 | align-items: flex-start; 1416 | } 1417 | 1418 | .sm\:items-end { 1419 | align-items: flex-end; 1420 | } 1421 | 1422 | .sm\:p-6 { 1423 | padding: 1.5rem; 1424 | } 1425 | 1426 | .sm\:px-6 { 1427 | padding-left: 1.5rem; 1428 | padding-right: 1.5rem; 1429 | } 1430 | 1431 | .sm\:pl-6 { 1432 | padding-left: 1.5rem; 1433 | } 1434 | 1435 | .sm\:pr-8 { 1436 | padding-right: 2rem; 1437 | } 1438 | 1439 | .sm\:text-sm { 1440 | font-size: 0.875rem; 1441 | line-height: 1.25rem; 1442 | } 1443 | 1444 | .sm\:text-sm\/6 { 1445 | font-size: 0.875rem; 1446 | line-height: 1.5rem; 1447 | } 1448 | } 1449 | 1450 | @media (min-width: 1024px) { 1451 | .lg\:-mx-8 { 1452 | margin-left: -2rem; 1453 | margin-right: -2rem; 1454 | } 1455 | 1456 | .lg\:px-8 { 1457 | padding-left: 2rem; 1458 | padding-right: 2rem; 1459 | } 1460 | 1461 | .lg\:pl-8 { 1462 | padding-left: 2rem; 1463 | } 1464 | 1465 | .lg\:pr-8 { 1466 | padding-right: 2rem; 1467 | } 1468 | } 1469 | -------------------------------------------------------------------------------- /static/opensearch.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | GoFind 4 | GoFind 5 | UTF-8 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /static/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --text-color: #404040; 3 | --primary-color: #dff2eb; 4 | --row-color: #edf2f0; 5 | --radio-color: #8abfa3; 6 | } 7 | 8 | body { 9 | display: flex; 10 | flex-direction: column; 11 | justify-content: center; 12 | text-align: center; 13 | color: var(--text-color); 14 | font-family:Trebuchet MS,Helvetica,sans-serif; 15 | flex-wrap: wrap; 16 | } 17 | 18 | #msg { 19 | background-color: var(--primary-color); 20 | padding: .5rem 1rem; 21 | border-radius: .5rem; 22 | display: inline-block; 23 | } 24 | 25 | #msg:empty { 26 | display: none; 27 | } 28 | 29 | #command-type { 30 | text-transform: capitalize; 31 | } 32 | 33 | table { 34 | margin: 1rem auto; 35 | min-width: 40vw; 36 | max-width: 75vw; 37 | } 38 | 39 | th { 40 | background-color: var(--primary-color); 41 | padding: .5rem 1rem; 42 | } 43 | 44 | td { 45 | padding: .3rem 1rem; 46 | text-align: start; 47 | } 48 | 49 | td.query { 50 | max-width: 30vw; 51 | position: relative; 52 | } 53 | 54 | td.query .hostname { 55 | overflow: hidden; 56 | text-overflow: ellipsis; 57 | white-space: nowrap; 58 | } 59 | 60 | td.query .query-full { 61 | position: absolute; 62 | bottom: 100%; 63 | left: 50%; 64 | transform: translateX(-50%); 65 | white-space: nowrap; 66 | background-color: white; 67 | padding: .3rem .6rem; 68 | border-radius: .3rem; 69 | border: 1px solid var(--text-color); 70 | display: none; 71 | } 72 | 73 | td.query:hover .query-full { 74 | display: block; 75 | } 76 | 77 | td.args { 78 | display: flex; 79 | flex-wrap: wrap; 80 | gap: .3rem; 81 | max-width: 12rem; 82 | } 83 | 84 | span.arg { 85 | background-color: var(--primary-color); 86 | padding: 3px; 87 | border-radius: 5px; 88 | color: #006A67; 89 | text-overflow: ellipsis; 90 | white-space: nowrap; 91 | overflow: hidden; 92 | } 93 | 94 | td.description { 95 | max-width: 15vw; 96 | overflow: hidden; 97 | text-overflow: ellipsis; 98 | white-space: nowrap; 99 | } 100 | 101 | tr:nth-child(odd) { 102 | background-color: var(--row-color); 103 | } 104 | tr td:last-child { 105 | text-align: right; 106 | } 107 | 108 | input[type="radio"] { 109 | accent-color: var(--radio-color); 110 | } 111 | 112 | textarea { 113 | min-width: 50vw; 114 | margin: 0 auto; 115 | } 116 | -------------------------------------------------------------------------------- /static/templates/base64.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | GoFind 7 | 8 | 9 | 10 |

Base64 {{.Type}} text

11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /static/templates/command_tabs.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | 9 |
10 | 34 |
35 |
36 | -------------------------------------------------------------------------------- /static/templates/filtered_commands_list.html: -------------------------------------------------------------------------------- 1 | {{ $length := len .Commands }} 2 | {{ $offset := .Offset }} 3 | {{ range $idx, $Command := .Commands }} 4 | 13 | 14 | 24 | 25 | {{$Command.Alias}} 26 | 27 | {{$Command.QueryHostname}} 28 | 32 | 33 | 34 | {{if eq $Command.ArgType "num"}} 35 | {{range $Command.ArgsNum}} 36 | {{"{"}}{{.}}{{"}"}} 37 | {{end}} 38 | {{else if eq $Command.ArgType "keyval"}} 39 | {{range $Key, $Val := $Command.ArgsKeyVal}} 40 | {{$Key}}: {{$Val}} 41 | {{end}} 42 | {{else if eq $Command.ArgType "any"}} 43 | %s 44 | {{else}} 45 | 46 | {{end}} 47 | 48 | {{$Command.Description.String}} 49 | 50 | {{end}} 51 | -------------------------------------------------------------------------------- /static/templates/list_commands.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | GoFind - List Commands 7 | 8 | 9 | 10 | 11 | 12 |

GoFind

18 | 19 |
20 |
21 | 29 |
30 | ⌘K 31 |
32 |
33 |
34 | 35 | {{template "command_tabs.html" .}} 36 | 37 |
38 |
39 |
40 |
41 |
42 |
43 | 44 | 45 | 46 | 47 | 48 | {{if eq .Type "search"}} 49 | 50 | 51 | {{end}} 52 | 53 | 54 | 55 | 56 | 58 | 59 | 60 |
DefaultAliasQueryArgumentsDescription
61 |
62 |
63 |
64 |
65 |
66 |
67 | 68 | 69 |
70 |
71 | 72 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /static/templates/message.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | GoFind 7 | 8 | 9 | 10 |

{{ .Message }}

11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /static/templates/multi_query.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | GoFind 7 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /static/templates/notification.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 | 9 |
10 |
11 |

{{.Title}}

12 |
13 |
14 | 20 |
21 |
22 |
23 |
24 |
25 | 26 | 29 | -------------------------------------------------------------------------------- /static/templates/sha256.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | GoFind - SHA256 7 | 8 | 9 | 10 |

SHA 256 encoded text

11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./static/**/*.{js,html}"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [require("@tailwindcss/forms")], 8 | }; 9 | -------------------------------------------------------------------------------- /tasks.md: -------------------------------------------------------------------------------- 1 | - [ ] add utils like 2 | - [x] base64 3 | - [x] sha256 4 | - [ ] color picker 5 | - [ ] rgb-hex-hsl 6 | - [ ] jq 7 | - [ ] calc 8 | - [ ] jwt decoder 9 | - [ ] add apis like 10 | - [ ] todoist 11 | - [ ] add more use cases like mailto, whatsapp 12 | - [ ] allow editing a query or alias 13 | - [ ] add proper usage docs 14 | 15 | 16 | - [x] env option to enable/disable additional commands 17 | - [x] use env variables from environment 18 | - [x] documentation 19 | - [x] add more tests 20 | - [x] github public 21 | - [x] make default changable 22 | - [x] show message page with 400 error 23 | - [x] allow key:value alias 24 | - [x] group commands in list view according to command type 25 | - [x] disable deleting built in util or api 26 | - [x] dockerize 27 | - [x] refactor 28 | -------------------------------------------------------------------------------- /tests/command_operations_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "database/sql" 5 | "github.com/vigneshrajj/gofind/internal/database" 6 | "testing" 7 | 8 | "github.com/vigneshrajj/gofind/models" 9 | ) 10 | 11 | func setupCommandsOperationsTest() func() { 12 | var err error 13 | _, db, err = database.NewDBConnection(":memory:") 14 | if err != nil { 15 | panic(err) 16 | } 17 | 18 | return func() {} 19 | } 20 | 21 | func TestCreateCommand(t *testing.T) { 22 | defer setupCommandsOperationsTest()() 23 | cmd := models.Command{ 24 | Alias: "help", 25 | Query: "some query", 26 | Type: models.UtilCommand, 27 | Description: sql.NullString{String: "List all available commands", Valid: true}, 28 | } 29 | if err := db.Create(&cmd).Error; err != nil { 30 | t.Fatalf("Failed to create a command: %v", err) 31 | } 32 | var count int64 33 | db.Model(&models.Command{}).Count(&count) 34 | if count != 1 { 35 | t.Fatalf("Expected 1 command, but got %v", count) 36 | } 37 | } 38 | 39 | func TestFirstOrCreateCommand(t *testing.T) { 40 | defer setupCommandsOperationsTest()() 41 | cmd := models.Command{ 42 | Alias: "help", 43 | Query: "some query", 44 | Type: models.UtilCommand, 45 | Description: sql.NullString{String: "List all available commands", Valid: true}, 46 | } 47 | database.FirstOrCreateCommand(db, cmd) 48 | var count int64 49 | db.Model(&models.Command{}).Count(&count) 50 | if count != 1 { 51 | t.Fatalf("Expected 1 command, but got %v", count) 52 | } 53 | } 54 | 55 | func TestFirstOrCreateCommandWithExistingCommand(t *testing.T) { 56 | defer setupCommandsOperationsTest()() 57 | cmd := models.Command{ 58 | Alias: "help", 59 | Query: "some query", 60 | Type: models.UtilCommand, 61 | Description: sql.NullString{String: "List all available commands", Valid: true}, 62 | } 63 | database.FirstOrCreateCommand(db, cmd) 64 | database.FirstOrCreateCommand(db, cmd) 65 | var count int64 66 | db.Model(&models.Command{}).Count(&count) 67 | if count != 1 { 68 | t.Fatalf("Expected 1 command, but got %v", count) 69 | } 70 | } 71 | 72 | 73 | func TestDeleteCommand(t *testing.T) { 74 | defer setupCommandsOperationsTest()() 75 | cmd := models.Command{ 76 | Alias: "help", 77 | Query: "https://google.com", 78 | Type: models.UtilCommand, 79 | Description: sql.NullString{String: "List all available commands", Valid: true}, 80 | } 81 | err := database.CreateCommand(db, cmd) 82 | if err != nil { 83 | t.Fatalf("Failed to create a command: %v", err) 84 | } 85 | var count int64 86 | db.Model(&models.Command{}).Count(&count) 87 | if count != 1 { 88 | t.Fatalf("Expected 1 command, but got %v", count) 89 | } 90 | err = database.DeleteCommand(db, "help") 91 | if err != nil { 92 | t.Fatalf("Failed to create a command: %v", err) 93 | } 94 | db.Model(&models.Command{}).Count(&count) 95 | if count != 0 { 96 | t.Fatalf("Expected 0 command, but got %v", count) 97 | } 98 | } 99 | 100 | func TestDeleteNonExistingCommand(t *testing.T) { 101 | defer setupCommandsOperationsTest()() 102 | err := database.DeleteCommand(db, "help") 103 | if err == nil { 104 | t.Fatal("Expected error, got nil") 105 | } 106 | } 107 | 108 | func TestListCommands(t *testing.T) { 109 | defer setupCommandsOperationsTest()() 110 | cmd := models.Command{ 111 | Alias: "help", 112 | Query: "https://google.com", 113 | Type: models.UtilCommand, 114 | Description: sql.NullString{String: "List all available commands", Valid: true}, 115 | } 116 | err := database.CreateCommand(db, cmd) 117 | if err != nil { 118 | t.Fatalf("Failed to create a command: %v", err) 119 | } 120 | var count int64 121 | commands := database.ListCommands(db) 122 | count = int64(len(commands)) 123 | if count != 1 { 124 | t.Fatalf("Expected 1 command, but got %v", count) 125 | } 126 | } 127 | 128 | func TestPartialSearchCommand(t *testing.T) { 129 | defer setupCommandsOperationsTest()() 130 | cmd := models.Command{ 131 | Alias: "goo", 132 | Query: "google.com", 133 | Type: models.ApiCommand, 134 | Description: sql.NullString{String: "Search in google", Valid: true}, 135 | } 136 | if err := database.CreateCommand(db, cmd); err != nil { 137 | t.Fatalf("Failed to create a command: %v", err) 138 | } 139 | command := database.SearchCommand(db, "g", true) 140 | if command == (models.Command{}) { 141 | t.Fatalf("Expected 1 command, got %d", 0) 142 | } 143 | } 144 | 145 | func TestGetDefaultCommand(t *testing.T) { 146 | defer setupCommandsOperationsTest()() 147 | cmd := models.Command{ 148 | Alias: "help", 149 | Query: "https://google.com", 150 | Type: models.UtilCommand, 151 | Description: sql.NullString{String: "List all available commands", Valid: true}, 152 | IsDefault: true, 153 | } 154 | err := database.CreateCommand(db, cmd) 155 | if err != nil { 156 | t.Fatalf("Failed to create a command: %v", err) 157 | } 158 | command := database.GetDefaultCommand(db) 159 | if command == (models.Command{}) { 160 | t.Fatalf("Expected 1 command, got %d", 0) 161 | } 162 | } 163 | 164 | func TestGetDefaultCommand_Error(t *testing.T) { 165 | defer setupCommandsOperationsTest()() 166 | command := database.GetDefaultCommand(db) 167 | if command != (models.Command{}) { 168 | t.Fatalf("Expected 0 command, got %d", 1) 169 | } 170 | } 171 | 172 | func TestSetDefaultCommand(t *testing.T) { 173 | defer setupCommandsOperationsTest()() 174 | cmd := models.Command{ 175 | Alias: "help", 176 | Query: "https://google.com", 177 | Type: models.SearchCommand, 178 | Description: sql.NullString{String: "List all available commands", Valid: true}, 179 | } 180 | defaultCmd := models.Command{ 181 | Alias: "g", 182 | Query: "https://google.com", 183 | Type: models.SearchCommand, 184 | Description: sql.NullString{String: "List all available commands", Valid: true}, 185 | IsDefault: true, 186 | } 187 | err := database.CreateCommand(db, cmd) 188 | if err != nil { 189 | t.Fatalf("Failed to create a command: %v", err) 190 | } 191 | err = database.CreateCommand(db, defaultCmd) 192 | if err != nil { 193 | t.Fatalf("Failed to create default command: %v", err) 194 | } 195 | err = database.SetDefaultCommand(db, "help") 196 | if err != nil { 197 | t.Fatalf("Failed to set default command: %v", err) 198 | } 199 | command := database.GetDefaultCommand(db) 200 | if command == (models.Command{}) { 201 | t.Fatalf("Expected 1 command, got %d", 0) 202 | } 203 | if command.Alias != "help" { 204 | t.Fatalf("Expected help, got %s", command.Alias) 205 | } 206 | } 207 | 208 | func TestSetDefaultCommandToNonExistingCommand(t *testing.T) { 209 | defer setupCommandsOperationsTest()() 210 | err := database.SetDefaultCommand(db, "help") 211 | if err == nil { 212 | t.Fatalf("Expected error, got nil") 213 | } 214 | } 215 | 216 | func TestSetDefaultCommandToNonExistingDefaultCommand(t *testing.T) { 217 | defer setupCommandsOperationsTest()() 218 | cmd := models.Command{ 219 | Alias: "help", 220 | Query: "https://google.com", 221 | Type: models.SearchCommand, 222 | Description: sql.NullString{String: "List all available commands", Valid: true}, 223 | } 224 | err := database.CreateCommand(db, cmd) 225 | if err != nil { 226 | t.Fatalf("Failed to create a command: %v", err) 227 | } 228 | err = database.SetDefaultCommand(db, "help") 229 | if err == nil { 230 | t.Fatalf("Expected error, got nil") 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /tests/main_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "os" 7 | "sync" 8 | "testing" 9 | "time" 10 | 11 | "github.com/vigneshrajj/gofind/internal/database" 12 | "github.com/vigneshrajj/gofind/internal/server" 13 | 14 | "gorm.io/gorm" 15 | ) 16 | 17 | var db *gorm.DB 18 | var ( 19 | wg sync.WaitGroup 20 | serverReady = false 21 | once sync.Once 22 | ) 23 | 24 | func setupServerTest() func() { 25 | os.Symlink("../static", "./static") 26 | once.Do(func() { 27 | wg.Add(1) 28 | go func() { 29 | defer wg.Done() 30 | server.StartServer(":memory:", ":3005") 31 | }() 32 | // Wait for the server to start 33 | time.Sleep(100 * time.Millisecond) 34 | serverReady = true 35 | }) 36 | 37 | for !serverReady { 38 | time.Sleep(10 * time.Millisecond) 39 | } 40 | 41 | return func() {} 42 | } 43 | 44 | func TestServerIsRunning(t *testing.T) { 45 | defer setupServerTest()() 46 | 47 | resp, err := http.Get("http://localhost:3005") 48 | if err != nil { 49 | t.Fatalf("Failed to connect to server: %v", err) 50 | } 51 | defer func(Body io.ReadCloser) { 52 | err := Body.Close() 53 | if err != nil { 54 | t.Fatalf("Failed to close response body: %v", err) 55 | } 56 | }(resp.Body) 57 | 58 | if resp.StatusCode != http.StatusOK { 59 | t.Fatalf("Expected status code 200, but got %v", resp.StatusCode) 60 | } 61 | } 62 | 63 | 64 | func TestServerFailWithInvalidDbPath(t *testing.T) { 65 | err := server.StartServer("db/db/invalid.db", ":3005") 66 | 67 | if err == nil { 68 | t.Fatalf("Expected an error, but got nil") 69 | } 70 | } 71 | 72 | func TestDBConnection(t *testing.T) { 73 | dbSql, _, err := database.NewDBConnection(":memory:") 74 | if err != nil { 75 | t.Fatalf("Failed to connect to the database: %v", err) 76 | } 77 | 78 | if err := dbSql.Ping(); err != nil { 79 | t.Fatalf("Database connection is not alive: %v", err) 80 | } 81 | } 82 | 83 | func TestDBConnectionWithInvalidFileName(t *testing.T) { 84 | _, _, err := database.NewDBConnection("db/db/invalid.db") 85 | if err == nil { 86 | t.Fatalf("Expected an error, but got nil") 87 | } 88 | } 89 | 90 | func TestSearchEndpoint(t *testing.T) { 91 | defer setupServerTest()() 92 | 93 | resp, err := http.Get("http://localhost:3005/search?query=g") 94 | if err != nil { 95 | t.Fatalf("Failed to connect to server: %v", err) 96 | } 97 | defer func(Body io.ReadCloser) { 98 | err := Body.Close() 99 | if err != nil { 100 | t.Fatalf("Failed to close response body: %v", err) 101 | } 102 | }(resp.Body) 103 | if resp.StatusCode != http.StatusOK { 104 | t.Fatalf("Expected status code 200, but got %v", resp.StatusCode) 105 | } 106 | } 107 | 108 | func TestSetDefaultCommandEndpoint(t *testing.T) { 109 | defer setupServerTest()() 110 | resp, err := http.Get("http://localhost:3005/set-default-command?default=g") 111 | if err != nil { 112 | t.Fatalf("Failed to connect to server: %v", err) 113 | } 114 | defer func(Body io.ReadCloser) { 115 | err := Body.Close() 116 | if err != nil { 117 | t.Fatalf("Failed to close response body: %v", err) 118 | } 119 | }(resp.Body) 120 | if resp.StatusCode != http.StatusOK { 121 | t.Fatalf("Expected status code 200, but got %v", resp.StatusCode) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /tests/query_handler_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "net/http/httptest" 5 | "net/url" 6 | "testing" 7 | 8 | "github.com/vigneshrajj/gofind/internal/database" 9 | "github.com/vigneshrajj/gofind/internal/handlers" 10 | 11 | "github.com/vigneshrajj/gofind/models" 12 | ) 13 | 14 | func setupQueryHandlerTest() func() { 15 | var err error 16 | _, db, err = database.NewDBConnection(":memory:") 17 | if err != nil { 18 | panic(err) 19 | } 20 | database.EnsureDefaultCommandsExist(db) 21 | 22 | if err = db.AutoMigrate(&models.Command{}); err != nil { 23 | panic(err) 24 | } 25 | 26 | return func() {} 27 | } 28 | 29 | func TestEmptyQuery(t *testing.T) { 30 | defer setupQueryHandlerTest()() 31 | query := "" 32 | w := httptest.NewRecorder() 33 | 34 | handlers.HandleQuery(w, nil, query, db) 35 | resp := w.Result() 36 | 37 | if resp.StatusCode != 400 { 38 | t.Fatalf("Expected status code 400, but got %v", resp.StatusCode) 39 | } 40 | } 41 | 42 | func TestAddCommand(t *testing.T) { 43 | defer setupQueryHandlerTest()() 44 | query := "#a alias https://url" 45 | w := httptest.NewRecorder() 46 | 47 | handlers.HandleQuery(w, nil, query, db) 48 | resp := w.Result() 49 | 50 | if resp.StatusCode != 200 { 51 | t.Fatalf("Expected status code 200, but got %v", resp.StatusCode) 52 | } 53 | } 54 | 55 | func TestAddCommandWithDescription(t *testing.T) { 56 | defer setupQueryHandlerTest()() 57 | query := "#a alias https://url description" 58 | w := httptest.NewRecorder() 59 | handlers.HandleQuery(w, nil, query, db) 60 | resp := w.Result() 61 | if resp.StatusCode != 200 { 62 | t.Fatalf("Expected status code 200, but got %v", resp.StatusCode) 63 | } 64 | } 65 | 66 | func TestAddDuplicateCommand(t *testing.T) { 67 | defer setupQueryHandlerTest()() 68 | query := "#a alias https://url" 69 | w := httptest.NewRecorder() 70 | handlers.HandleQuery(w, nil, query, db) 71 | w = httptest.NewRecorder() 72 | 73 | handlers.HandleQuery(w, nil, query, db) 74 | resp := w.Result() 75 | 76 | if resp.StatusCode != 400 { 77 | t.Fatalf("Expected status code 400, but got %v", resp.StatusCode) 78 | } 79 | } 80 | 81 | func TestInvalidAddCommand(t *testing.T) { 82 | defer setupQueryHandlerTest()() 83 | query := "#a alias" 84 | w := httptest.NewRecorder() 85 | 86 | handlers.HandleQuery(w, nil, query, db) 87 | resp := w.Result() 88 | 89 | if resp.StatusCode != 400 { 90 | t.Fatalf("Expected status code 400, but got %v", resp.StatusCode) 91 | } 92 | } 93 | 94 | func TestDeleteNonExistingCommandQuery(t *testing.T) { 95 | defer setupQueryHandlerTest()() 96 | query := "#d alias" 97 | w := httptest.NewRecorder() 98 | 99 | handlers.HandleQuery(w, nil, query, db) 100 | resp := w.Result() 101 | 102 | if resp.StatusCode != 400 { 103 | t.Fatalf("Expected status code 400, but got %v", resp.StatusCode) 104 | } 105 | } 106 | 107 | func TestDeleteCommandQuery(t *testing.T) { 108 | defer setupQueryHandlerTest()() 109 | query := "#a alias https://google.com" 110 | w := httptest.NewRecorder() 111 | handlers.HandleQuery(w, nil, query, db) 112 | query = "#d alias" 113 | 114 | handlers.HandleQuery(w, nil, query, db) 115 | resp := w.Result() 116 | 117 | if resp.StatusCode != 200 { 118 | t.Fatalf("Expected status code 200, but got %v", resp.StatusCode) 119 | } 120 | } 121 | 122 | func TestExtraArgsDeleteCommand(t *testing.T) { 123 | defer setupQueryHandlerTest()() 124 | query := "#d alias extra" 125 | w := httptest.NewRecorder() 126 | 127 | handlers.HandleQuery(w, nil, query, db) 128 | resp := w.Result() 129 | 130 | if resp.StatusCode != 400 { 131 | t.Fatalf("Expected status code 400, but got %v", resp.StatusCode) 132 | } 133 | } 134 | 135 | func TestLessArgsDeleteCommand(t *testing.T) { 136 | defer setupQueryHandlerTest()() 137 | query := "#d" 138 | w := httptest.NewRecorder() 139 | 140 | handlers.HandleQuery(w, nil, query, db) 141 | resp := w.Result() 142 | 143 | if resp.StatusCode != 400 { 144 | t.Fatalf("Expected status code 400, but got %v", resp.StatusCode) 145 | } 146 | } 147 | 148 | func TestListCommandsQuery(t *testing.T) { 149 | defer setupQueryHandlerTest()() 150 | query := "#l" 151 | w := httptest.NewRecorder() 152 | 153 | handlers.HandleQuery(w, nil, query, db) 154 | resp := w.Result() 155 | 156 | if resp.StatusCode != 200 { 157 | t.Fatalf("Expected status code 200, but got %v", resp.StatusCode) 158 | } 159 | } 160 | 161 | func TestInvalidListCommandsQuery(t *testing.T) { 162 | defer setupQueryHandlerTest()() 163 | query := "#l invalid" 164 | w := httptest.NewRecorder() 165 | handlers.HandleQuery(w, nil, query, db) 166 | resp := w.Result() 167 | if resp.StatusCode != 400 { 168 | t.Fatalf("Expected status code 400, but got %v", resp.StatusCode) 169 | } 170 | } 171 | 172 | func TestRedirectQuery(t *testing.T) { 173 | defer setupQueryHandlerTest()() 174 | query := "#a alias https://google.com" 175 | urlEncodedQuery := url.QueryEscape(query) 176 | w := httptest.NewRecorder() 177 | r := httptest.NewRequest("GET", "http://localhost:3005/search?query="+urlEncodedQuery, nil) 178 | handlers.HandleQuery(w, r, query, db) 179 | w = httptest.NewRecorder() 180 | query = "alias" 181 | 182 | handlers.HandleQuery(w, r, query, db) 183 | resp := w.Result() 184 | 185 | if resp.StatusCode != 302 { 186 | t.Fatalf("Expected status code 302, but got %v", resp.StatusCode) 187 | } 188 | if resp.Header.Get("Location") != "https://google.com" { 189 | t.Fatalf("Expected Location header to be 'https://google.com', but got %v", resp.Header.Get("Location")) 190 | } 191 | } 192 | 193 | func TestRedirectWithoutHttp(t *testing.T) { 194 | defer setupQueryHandlerTest()() 195 | query := "#a alias google.com" 196 | urlEncodedQuery := url.QueryEscape(query) 197 | w := httptest.NewRecorder() 198 | r := httptest.NewRequest("GET", "http://localhost:3005/search?query="+urlEncodedQuery, nil) 199 | handlers.HandleQuery(w, r, query, db) 200 | w = httptest.NewRecorder() 201 | query = "alias" 202 | 203 | handlers.HandleQuery(w, r, query, db) 204 | resp := w.Result() 205 | 206 | if resp.StatusCode != 302 { 207 | t.Fatalf("Expected status code 302, but got %v", resp.StatusCode) 208 | } 209 | if resp.Header.Get("Location") != "https://google.com" { 210 | t.Fatalf("Expected Location header to be 'https://google.com', but got %v", resp.Header.Get("Location")) 211 | } 212 | } 213 | 214 | func TestRedirectByPartialMatch(t *testing.T) { 215 | defer setupQueryHandlerTest()() 216 | query := "#a alias https://google.com" 217 | urlEncodedQuery := url.QueryEscape(query) 218 | w := httptest.NewRecorder() 219 | r := httptest.NewRequest("GET", "http://localhost:3005/search?query="+urlEncodedQuery, nil) 220 | handlers.HandleQuery(w, r, query, db) 221 | w = httptest.NewRecorder() 222 | query = "al" 223 | 224 | handlers.HandleQuery(w, r, query, db) 225 | resp := w.Result() 226 | 227 | if resp.StatusCode != 302 { 228 | t.Fatalf("Expected status code 302, but got %v", resp.StatusCode) 229 | } 230 | if resp.Header.Get("Location") != "https://google.com" { 231 | t.Fatalf("Expected Location header to be 'https://google.com', but got %v", resp.Header.Get("Location")) 232 | } 233 | } 234 | 235 | func TestRedirectBySupercedingPartialMatch(t *testing.T) { 236 | defer setupQueryHandlerTest()() 237 | query := "#a alias https://google.com" 238 | urlEncodedQuery := url.QueryEscape(query) 239 | w := httptest.NewRecorder() 240 | r := httptest.NewRequest("GET", "http://localhost:3005/search?query="+urlEncodedQuery, nil) 241 | handlers.HandleQuery(w, r, query, db) 242 | w = httptest.NewRecorder() 243 | query = "#a al https://youtube.com" 244 | urlEncodedQuery = url.QueryEscape(query) 245 | w = httptest.NewRecorder() 246 | r = httptest.NewRequest("GET", "http://localhost:3005/search?query="+urlEncodedQuery, nil) 247 | handlers.HandleQuery(w, r, query, db) 248 | w = httptest.NewRecorder() 249 | r = httptest.NewRequest("GET", "http://localhost:3005/search?query="+urlEncodedQuery, nil) 250 | query = "al" 251 | 252 | handlers.HandleQuery(w, r, query, db) 253 | resp := w.Result() 254 | 255 | if resp.StatusCode != 302 { 256 | t.Fatalf("Expected status code 302, but got %v", resp.StatusCode) 257 | } 258 | if resp.Header.Get("Location") != "https://youtube.com" { 259 | t.Fatalf("Expected Location header to be 'https://youtube.com', but got %v", resp.Header.Get("Location")) 260 | } 261 | } 262 | 263 | func TestRedirectNonExistingAliasToDefaultCommand(t *testing.T) { 264 | defer setupQueryHandlerTest()() 265 | w := httptest.NewRecorder() 266 | query := "invalid" 267 | r := httptest.NewRequest("GET", "http://localhost:3005/search?query="+query, nil) 268 | 269 | w = httptest.NewRecorder() 270 | handlers.HandleQuery(w, r, query, db) 271 | resp := w.Result() 272 | 273 | if resp.StatusCode != 302 { 274 | t.Fatalf("Expected status code 200, but got %v", resp.StatusCode) 275 | } 276 | if resp.Header.Get("Location") != "https://www.google.com/search?q=invalid" { 277 | t.Fatalf("Expected Location header to be 'https://www.google.com/search?q=invalid', but got %v", resp.Header.Get("Location")) 278 | } 279 | } 280 | 281 | func TestRedirectWithNArgs(t *testing.T) { 282 | defer setupQueryHandlerTest()() 283 | query := "#a alias https://google.com/search?q=%s" 284 | urlEncodedQuery := url.QueryEscape(query) 285 | w := httptest.NewRecorder() 286 | r := httptest.NewRequest("GET", "http://localhost:3005/search?query="+urlEncodedQuery, nil) 287 | handlers.HandleQuery(w, r, query, db) 288 | w = httptest.NewRecorder() 289 | query = "alias search some string" 290 | 291 | handlers.HandleQuery(w, r, query, db) 292 | resp := w.Result() 293 | 294 | if resp.StatusCode != 302 { 295 | t.Fatalf("Expected status code 302, but got %v", resp.StatusCode) 296 | } 297 | if resp.Header.Get("Location") != "https://google.com/search?q=search+some+string" { 298 | t.Fatalf("Expected Location header to be 'https://google.com/search?q=search+some+string', but got %v", resp.Header.Get("Location")) 299 | } 300 | } 301 | 302 | func TestRedirectWithMultipleArgs(t *testing.T) { 303 | defer setupQueryHandlerTest()() 304 | query := "#a alias https://google.com/search?q={1}+{2}" 305 | urlEncodedQuery := url.QueryEscape(query) 306 | w := httptest.NewRecorder() 307 | r := httptest.NewRequest("GET", "http://localhost:3005/search?query="+urlEncodedQuery, nil) 308 | handlers.HandleQuery(w, r, query, db) 309 | w = httptest.NewRecorder() 310 | query = "alias search string" 311 | handlers.HandleQuery(w, r, query, db) 312 | resp := w.Result() 313 | if resp.StatusCode != 302 { 314 | t.Fatalf("Expected status code 302, but got %v", resp.StatusCode) 315 | } 316 | if resp.Header.Get("Location") != "https://google.com/search?q=search+string" { 317 | t.Fatalf("Expected Location header to be 'https://google.com/search?q=search+string', but got %v", resp.Header.Get("Location")) 318 | } 319 | } 320 | 321 | func TestRedirectWithOneArg(t *testing.T) { 322 | defer setupQueryHandlerTest()() 323 | query := "#a alias https://google.com/search?q={1}" 324 | urlEncodedQuery := url.QueryEscape(query) 325 | w := httptest.NewRecorder() 326 | r := httptest.NewRequest("GET", "http://localhost:3005/search?query="+urlEncodedQuery, nil) 327 | handlers.HandleQuery(w, r, query, db) 328 | w = httptest.NewRecorder() 329 | query = "alias search" 330 | 331 | handlers.HandleQuery(w, r, query, db) 332 | resp := w.Result() 333 | 334 | if resp.StatusCode != 302 { 335 | t.Fatalf("Expected status code 302, but got %v", resp.StatusCode) 336 | } 337 | if resp.Header.Get("Location") != "https://google.com/search?q=search" { 338 | t.Fatalf("Expected Location header to be 'https://google.com/search?q=search', but got %v", resp.Header.Get("Location")) 339 | } 340 | } 341 | 342 | func TestRedirectWithKeyValueArg(t *testing.T) { 343 | defer setupQueryHandlerTest()() 344 | query := "#a alias https://google.com/search?q={key:val,key2:val2,key3:val3}" 345 | urlEncodedQuery := url.QueryEscape(query) 346 | w := httptest.NewRecorder() 347 | r := httptest.NewRequest("GET", "http://localhost:3005/search?query="+urlEncodedQuery, nil) 348 | handlers.HandleQuery(w, r, query, db) 349 | w = httptest.NewRecorder() 350 | query = "alias key2" 351 | 352 | handlers.HandleQuery(w, r, query, db) 353 | resp := w.Result() 354 | 355 | if resp.StatusCode != 302 { 356 | t.Fatalf("Expected status code 302, but got %v", resp.StatusCode) 357 | } 358 | if resp.Header.Get("Location") != "https://google.com/search?q=val2" { 359 | t.Fatalf("Expected Location header to be 'https://google.com/search?q=val2', but got %v", resp.Header.Get("Location")) 360 | } 361 | } 362 | 363 | func TestRedirectWithMultipleKeyValueArg(t *testing.T) { 364 | defer setupQueryHandlerTest()() 365 | query := "#a alias https://google.com/search?q={key:val,key2:val2,key3:val3}+{key:val,key2:val2,key3:val3}" 366 | urlEncodedQuery := url.QueryEscape(query) 367 | w := httptest.NewRecorder() 368 | r := httptest.NewRequest("GET", "http://localhost:3005/search?query="+urlEncodedQuery, nil) 369 | handlers.HandleQuery(w, r, query, db) 370 | w = httptest.NewRecorder() 371 | query = "alias key2 key3" 372 | handlers.HandleQuery(w, r, query, db) 373 | resp := w.Result() 374 | if resp.StatusCode != 302 { 375 | t.Fatalf("Expected status code 302, but got %v", resp.StatusCode) 376 | } 377 | if resp.Header.Get("Location") != "https://google.com/search?q=val2+val3" { 378 | t.Fatalf("Expected Location header to be 'https://google.com/search?q=val2+val3', but got %v", resp.Header.Get("Location")) 379 | } 380 | } 381 | 382 | 383 | func TestRedirectWithExtraKeyValueArg(t *testing.T) { 384 | defer setupQueryHandlerTest()() 385 | query := "#a alias https://google.com/search?q={key:val,key2:val2,key3:val3}" 386 | urlEncodedQuery := url.QueryEscape(query) 387 | w := httptest.NewRecorder() 388 | r := httptest.NewRequest("GET", "http://localhost:3005/search?query="+urlEncodedQuery, nil) 389 | handlers.HandleQuery(w, r, query, db) 390 | w = httptest.NewRecorder() 391 | query = "alias key2 key3" 392 | handlers.HandleQuery(w, r, query, db) 393 | resp := w.Result() 394 | t.Log(resp.Header.Get("Location")) 395 | if resp.StatusCode != 302 { 396 | t.Fatalf("Expected status code 302, but got %v", resp.StatusCode) 397 | } 398 | } 399 | 400 | func TestRedirectWithInvalidKeyValueArg(t *testing.T) { 401 | defer setupQueryHandlerTest()() 402 | query := "#a alias https://google.com/search?q={key:val,key2:val2,key3:val3}" 403 | urlEncodedQuery := url.QueryEscape(query) 404 | w := httptest.NewRecorder() 405 | r := httptest.NewRequest("GET", "http://localhost:3005/search?query="+urlEncodedQuery, nil) 406 | handlers.HandleQuery(w, r, query, db) 407 | w = httptest.NewRecorder() 408 | query = "alias abc" 409 | 410 | r = httptest.NewRequest("GET", "http://localhost:3005/search?query="+urlEncodedQuery, nil) 411 | handlers.HandleQuery(w, r, query, db) 412 | resp := w.Result() 413 | 414 | t.Log(resp.Header.Get("Location")) 415 | if resp.StatusCode != 400 { 416 | t.Fatalf("Expected status code 400, but got %v", resp.StatusCode) 417 | } 418 | } 419 | 420 | func TestRedirectWithInvalidArgs(t *testing.T) { 421 | defer setupQueryHandlerTest()() 422 | query := "#a alias https://google.com/search?q={1}" 423 | urlEncodedQuery := url.QueryEscape(query) 424 | w := httptest.NewRecorder() 425 | r := httptest.NewRequest("GET", "http://localhost:3005/search?query="+urlEncodedQuery, nil) 426 | handlers.HandleQuery(w, r, query, db) 427 | w = httptest.NewRecorder() 428 | query = "alias" 429 | 430 | handlers.HandleQuery(w, r, query, db) 431 | resp := w.Result() 432 | 433 | if resp.StatusCode != 400 { 434 | t.Fatalf("Expected status code 400, but got %v", resp.StatusCode) 435 | } 436 | } 437 | 438 | func TestChangeDefaultCommand(t *testing.T) { 439 | defer setupQueryHandlerTest()() 440 | query := "#a alias https://google.com" 441 | w := httptest.NewRecorder() 442 | handlers.HandleQuery(w, nil, query, db) 443 | r := httptest.NewRequest("GET", "http://localhost:3005/set-default-command?default=alias", nil) 444 | w = httptest.NewRecorder() 445 | handlers.ChangeDefaultCommand(w, r, db) 446 | command := database.GetDefaultCommand(db) 447 | if command == (models.Command{}) { 448 | t.Fatalf("Expected 1 command, got %d", 0) 449 | } 450 | if command.Alias != "alias" { 451 | t.Fatalf("Expected default command alias to be 'alias', but got %v", command.Alias) 452 | } 453 | } 454 | 455 | func TestChangeDefaultCommandToNonExistingCommand(t *testing.T) { 456 | defer setupQueryHandlerTest()() 457 | r := httptest.NewRequest("GET", "http://localhost:3005/set-default-command?default=alias", nil) 458 | w := httptest.NewRecorder() 459 | handlers.ChangeDefaultCommand(w, r, db) 460 | resp := w.Result() 461 | if resp.StatusCode != 400 { 462 | t.Fatalf("Expected status code 400, but got %v", resp.StatusCode) 463 | } 464 | } 465 | 466 | func TestDeleteUtilCommand(t *testing.T) { 467 | defer setupQueryHandlerTest()() 468 | command := models.Command{ 469 | Alias: "alias", 470 | Query: "https://google.com", 471 | Type: models.UtilCommand, 472 | IsDefault: true, 473 | } 474 | database.CreateCommand(db, command) 475 | query := "#d alias" 476 | w := httptest.NewRecorder() 477 | handlers.HandleQuery(w, nil, query, db) 478 | resp := w.Result() 479 | if resp.StatusCode != 400 { 480 | t.Fatalf("Expected status code 400, but got %v", resp.StatusCode) 481 | } 482 | } 483 | -------------------------------------------------------------------------------- /tests/static: -------------------------------------------------------------------------------- 1 | ../static -------------------------------------------------------------------------------- /tests/templates_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "io" 5 | "net/http/httptest" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/vigneshrajj/gofind/internal/templates" 10 | ) 11 | 12 | func TestBase64Template(t *testing.T) { 13 | defer setupServerTest()() 14 | data := "test" 15 | w := httptest.NewRecorder() 16 | templates.Base64Template(w, data) 17 | bodyBytes, err := io.ReadAll(w.Body) 18 | if err != nil { 19 | t.Fatalf("Failed to read response body: %v", err) 20 | } 21 | body := string(bodyBytes) 22 | if !strings.Contains(body, "test") { 23 | t.Fatalf("Expected test, but got %v", body) 24 | } 25 | } 26 | 27 | func TestBase64DecodeTemplate(t *testing.T) { 28 | defer setupServerTest()() 29 | data := "test" 30 | w := httptest.NewRecorder() 31 | templates.Base64DecodeTemplate(w, data) 32 | bodyBytes, err := io.ReadAll(w.Body) 33 | if err != nil { 34 | t.Fatalf("Failed to read response body: %v", err) 35 | } 36 | body := string(bodyBytes) 37 | if !strings.Contains(body, "test") { 38 | t.Fatalf("Expected test, but got %v", body) 39 | } 40 | } 41 | 42 | func TestSha256Template(t *testing.T) { 43 | defer setupServerTest()() 44 | data := "test" 45 | w := httptest.NewRecorder() 46 | templates.Sha256Template(w, data) 47 | bodyBytes, err := io.ReadAll(w.Body) 48 | if err != nil { 49 | t.Fatalf("Failed to read response body: %v", err) 50 | } 51 | body := string(bodyBytes) 52 | if !strings.Contains(body, "test") { 53 | t.Fatalf("Expected test, but got %v", body) 54 | } 55 | } 56 | 57 | func TestMessageTemplate(t *testing.T) { 58 | defer setupServerTest()() 59 | message := "test" 60 | w := httptest.NewRecorder() 61 | templates.MessageTemplate(w, message) 62 | bodyBytes, err := io.ReadAll(w.Body) 63 | if err != nil { 64 | t.Fatalf("Failed to read response body: %v", err) 65 | } 66 | body := string(bodyBytes) 67 | if !strings.Contains(body, "test") { 68 | t.Fatalf("Expected test, but got %v", body) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /tests/utils_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/vigneshrajj/gofind/internal/helpers" 7 | ) 8 | 9 | func TestB64EncodeHelper(t *testing.T) { 10 | data := "test" 11 | encoded := helpers.GetB64(data) 12 | if encoded != "dGVzdA==" { 13 | t.Fatalf("Expected dGVzdA==, but got %v", encoded) 14 | } 15 | } 16 | 17 | func TestB64DecodeHelper(t *testing.T) { 18 | data := "dGVzdA==" 19 | decoded := helpers.GetB64Decode(data) 20 | if decoded != "test" { 21 | t.Fatalf("Expected test, but got %v", decoded) 22 | } 23 | } 24 | 25 | func TestSha256Helper(t *testing.T) { 26 | data := "test" 27 | hashed := helpers.Sha256(data) 28 | if hashed != "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" { 29 | t.Fatalf("Expected 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08, but got %v", hashed) 30 | } 31 | } 32 | --------------------------------------------------------------------------------