├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md ├── dependabot.yml ├── scripts │ └── checknumtasks.sh └── workflows │ ├── back-compat-pr.yml │ ├── back-compat.yml │ ├── build.yml │ ├── lint.yml │ ├── release.yml │ ├── run.yml │ └── vulncheck.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yaml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets └── sample-tasks.txt ├── cmd ├── assets │ ├── CHANGELOG.md │ └── guide │ │ ├── actions-adding-context.md │ │ ├── actions-adding-tasks.md │ │ ├── actions-choosing-a-prefix.md │ │ ├── actions-deleting-a-task.md │ │ ├── actions-filtering-tasks.md │ │ ├── actions-markdown-in-context.md │ │ ├── actions-quick-filtering-via-a-list.md │ │ ├── actions-updating-task-details.md │ │ ├── cli-adding-a-task-via-the-cli.md │ │ ├── cli-importing-several-tasks-via-the-cli.md │ │ ├── config-a-sample-toml-config.md │ │ ├── config-changing-the-defaults.md │ │ ├── config-flags-env-vars-and-config-file.md │ │ ├── domain-an-archived-task.md │ │ ├── domain-task-bookmarks.md │ │ ├── domain-task-details.md │ │ ├── domain-task-priorities.md │ │ ├── domain-task-state.md │ │ ├── domain-tasks.md │ │ ├── guide-and-thats-it.md │ │ ├── guide-welcome-to-omm.md │ │ ├── visuals-list-density.md │ │ └── visuals-toggling-context-pane.md ├── db.go ├── guide.go ├── guide_test.go ├── import.go ├── root.go ├── tasks.go └── utils.go ├── docker-compose.yml ├── go.mod ├── go.sum ├── internal ├── persistence │ ├── init.go │ ├── migrations.go │ ├── migrations_test.go │ ├── queries.go │ └── queries_test.go ├── types │ ├── colors.go │ ├── types.go │ └── types_test.go ├── ui │ ├── assets │ │ └── help.md │ ├── cmds.go │ ├── config.go │ ├── initial.go │ ├── list_delegate.go │ ├── model.go │ ├── msgs.go │ ├── styles.go │ ├── ui.go │ ├── update.go │ ├── utils.go │ ├── utils_test.go │ └── view.go └── utils │ ├── assets │ └── gruvbox.json │ ├── markdown.go │ ├── markdown_test.go │ ├── urls.go │ ├── urls_test.go │ └── utils.go └── main.go /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help improve omm 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Setup** 14 | Please complete the following information along with version numbers, if applicable. 15 | - `omm` version (run `omm -v`) or git commit that you've built `omm` on 16 | - OS [e.g. Ubuntu, macOS] 17 | - Shell [e.g. zsh, fish] 18 | - Terminal Emulator [e.g. kitty, iterm] 19 | - Terminal Multiplexer [e.g. tmux] 20 | - Locale [e.g. en_US.UTF-8, zh_CN.UTF-8, etc.] 21 | 22 | **To Reproduce** 23 | Steps to reproduce the behavior: 24 | 1. ... 25 | 2. ... 26 | 3. Error occurs 27 | 28 | **Expected behavior** 29 | A clear and concise description of what you expected to happen. 30 | 31 | **Screenshots** 32 | Add screenshots to help explain your problem. 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for omm 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Is your feature request related to a problem? Please describe.** 14 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | labels: 8 | - "dependencies" 9 | commit-message: 10 | prefix: "chore" 11 | include: "scope" 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: "weekly" 16 | labels: 17 | - "dependencies" 18 | commit-message: 19 | prefix: "chore" 20 | include: "scope" 21 | -------------------------------------------------------------------------------- /.github/scripts/checknumtasks.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ "$#" -ne 2 ]; then 4 | echo "Usage: ./checknumtasks.sh " 5 | exit 1 6 | fi 7 | 8 | if [ "$1" -ne "$2" ]; then 9 | echo "Number of tasks: $1; expected: $2" 10 | exit 1 11 | fi 12 | 13 | echo "Actual and expected number of tasks match" 14 | -------------------------------------------------------------------------------- /.github/workflows/back-compat-pr.yml: -------------------------------------------------------------------------------- 1 | name: back-compat PR 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - "go.*" 7 | - "**/*.go" 8 | - ".github/workflows/back-compat-pr.yml" 9 | 10 | permissions: 11 | contents: read 12 | 13 | env: 14 | GO_VERSION: '1.24.2' 15 | 16 | jobs: 17 | check-back-compat: 18 | name: build 19 | strategy: 20 | matrix: 21 | os: [ubuntu-latest, macos-latest] 22 | runs-on: ${{ matrix.os }} 23 | steps: 24 | - uses: actions/checkout@v4 25 | with: 26 | ref: main 27 | - name: Set up Go 28 | uses: actions/setup-go@v5 29 | with: 30 | go-version: ${{ env.GO_VERSION }} 31 | - name: build main 32 | run: | 33 | go build -o omm_main 34 | cp omm_main /var/tmp 35 | rm omm_main 36 | - uses: actions/checkout@v4 37 | - name: build head 38 | run: | 39 | go build -o omm_head 40 | cp omm_head /var/tmp 41 | rm omm_head 42 | - name: Run last version 43 | run: | 44 | /var/tmp/omm_main --db-path=/var/tmp/throwaway.db 'test: a task from main' 45 | - name: Run current version 46 | run: | 47 | /var/tmp/omm_head --db-path=/var/tmp/throwaway.db 'test: a task from PR HEAD' 48 | /var/tmp/omm_head --db-path=/var/tmp/throwaway.db tasks 49 | ./.github/scripts/checknumtasks.sh "$(/var/tmp/omm_head --db-path=/var/tmp/throwaway.db tasks | wc -l | xargs)" 2 50 | -------------------------------------------------------------------------------- /.github/workflows/back-compat.yml: -------------------------------------------------------------------------------- 1 | name: back-compat 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | 7 | permissions: 8 | contents: read 9 | 10 | env: 11 | GO_VERSION: '1.24.2' 12 | 13 | jobs: 14 | check-back-compat: 15 | name: build 16 | strategy: 17 | matrix: 18 | os: [ubuntu-latest, macos-latest] 19 | runs-on: ${{ matrix.os }} 20 | steps: 21 | - uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 2 24 | - run: git checkout HEAD~1 25 | - name: Set up Go 26 | uses: actions/setup-go@v5 27 | with: 28 | go-version: ${{ env.GO_VERSION }} 29 | - name: build last commit 30 | run: | 31 | go build -o omm_prev 32 | cp omm_prev /var/tmp 33 | rm omm_prev 34 | - run: git checkout main 35 | - name: build head 36 | run: | 37 | go build -o omm_head 38 | cp omm_head /var/tmp 39 | rm omm_head 40 | - name: Run last version 41 | run: | 42 | /var/tmp/omm_prev --db-path=/var/tmp/throwaway.db 'test: a task from previous commit' 43 | - name: Run current version 44 | run: | 45 | /var/tmp/omm_head --db-path=/var/tmp/throwaway.db 'test: a task from main HEAD' 46 | /var/tmp/omm_head --db-path=/var/tmp/throwaway.db tasks 47 | ./.github/scripts/checknumtasks.sh "$(/var/tmp/omm_head --db-path=/var/tmp/throwaway.db tasks | wc -l | xargs)" 2 48 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | paths: 8 | - "go.*" 9 | - "**/*.go" 10 | - ".github/workflows/build.yml" 11 | 12 | permissions: 13 | contents: read 14 | 15 | env: 16 | GO_VERSION: '1.24.2' 17 | 18 | jobs: 19 | build: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Set up Go 24 | uses: actions/setup-go@v5 25 | with: 26 | go-version: ${{ env.GO_VERSION }} 27 | - name: go build 28 | run: go build -v ./... 29 | - name: go test 30 | run: go test -v ./... 31 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | paths: 8 | - "go.*" 9 | - "**/*.go" 10 | - ".github/workflows/lint.yml" 11 | 12 | permissions: 13 | contents: read 14 | 15 | env: 16 | GO_VERSION: '1.24.2' 17 | 18 | jobs: 19 | lint: 20 | name: lint 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Set up Go 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version: ${{ env.GO_VERSION }} 28 | - name: golangci-lint 29 | uses: golangci/golangci-lint-action@v8 30 | with: 31 | version: v2.1.5 32 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | id-token: write 10 | 11 | env: 12 | GO_VERSION: '1.24.2' 13 | 14 | jobs: 15 | release: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | - name: Set up Go 22 | uses: actions/setup-go@v5 23 | with: 24 | go-version: ${{ env.GO_VERSION }} 25 | - name: Build 26 | run: go build -v ./... 27 | - name: Test 28 | run: go test -v ./... 29 | - name: Install Cosign 30 | uses: sigstore/cosign-installer@v3 31 | with: 32 | cosign-release: 'v2.5.0' 33 | - name: Release Binaries 34 | uses: goreleaser/goreleaser-action@v6 35 | with: 36 | version: '~> v2' 37 | args: release --clean 38 | env: 39 | GITHUB_TOKEN: ${{secrets.GH_PAT}} 40 | -------------------------------------------------------------------------------- /.github/workflows/run.yml: -------------------------------------------------------------------------------- 1 | name: run 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | paths: 8 | - "go.*" 9 | - "**/*.go" 10 | - ".github/workflows/run.yml" 11 | 12 | permissions: 13 | contents: read 14 | 15 | env: 16 | GO_VERSION: '1.24.2' 17 | 18 | jobs: 19 | run: 20 | strategy: 21 | matrix: 22 | os: [ubuntu-latest, macos-latest] 23 | runs-on: ${{ matrix.os }} 24 | steps: 25 | - uses: actions/checkout@v4 26 | - name: Set up Go 27 | uses: actions/setup-go@v5 28 | with: 29 | go-version: ${{ env.GO_VERSION }} 30 | - name: build 31 | run: go build . 32 | - name: run 33 | run: | 34 | cat assets/sample-tasks.txt | ./omm import 35 | ./omm 'test: a task' 36 | ./omm tasks 37 | ./.github/scripts/checknumtasks.sh "$(./omm tasks | wc -l | xargs)" 11 38 | -------------------------------------------------------------------------------- /.github/workflows/vulncheck.yml: -------------------------------------------------------------------------------- 1 | name: vulncheck 2 | on: 3 | push: 4 | branches: ["main"] 5 | pull_request: 6 | paths: 7 | - "go.*" 8 | - "**/*.go" 9 | - ".github/workflows/vulncheck.yml" 10 | 11 | permissions: 12 | contents: read 13 | 14 | env: 15 | GO_VERSION: '1.24.2' 16 | 17 | jobs: 18 | vulncheck: 19 | name: vulncheck 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Set up Go 24 | uses: actions/setup-go@v5 25 | with: 26 | go-version: ${{ env.GO_VERSION }} 27 | - name: govulncheck 28 | shell: bash 29 | run: | 30 | go install golang.org/x/vuln/cmd/govulncheck@latest 31 | govulncheck ./... 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | omm 2 | .quickrun 3 | cosign.key 4 | justfile 5 | .notes 6 | debug.log 7 | tasks.txt 8 | .cmds 9 | dist 10 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | enable: 4 | - errname 5 | - errorlint 6 | - goconst 7 | - nilerr 8 | - prealloc 9 | - predeclared 10 | - revive 11 | - rowserrcheck 12 | - sqlclosecheck 13 | - unconvert 14 | - usestdlibvars 15 | - wastedassign 16 | exclusions: 17 | generated: lax 18 | presets: 19 | - comments 20 | - common-false-positives 21 | - legacy 22 | - std-error-handling 23 | paths: 24 | - third_party$ 25 | - builtin$ 26 | - examples$ 27 | formatters: 28 | enable: 29 | - gofumpt 30 | exclusions: 31 | generated: lax 32 | paths: 33 | - third_party$ 34 | - builtin$ 35 | - examples$ 36 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | release: 4 | draft: true 5 | 6 | before: 7 | hooks: 8 | - go mod tidy 9 | - go generate ./... 10 | 11 | builds: 12 | - env: 13 | - CGO_ENABLED=0 14 | goos: 15 | - linux 16 | - darwin 17 | 18 | signs: 19 | - cmd: cosign 20 | signature: "${artifact}.sig" 21 | certificate: "${artifact}.pem" 22 | args: 23 | - "sign-blob" 24 | - "--oidc-issuer=https://token.actions.githubusercontent.com" 25 | - "--output-certificate=${certificate}" 26 | - "--output-signature=${signature}" 27 | - "${artifact}" 28 | - "--yes" 29 | artifacts: checksum 30 | 31 | brews: 32 | - name: omm 33 | repository: 34 | owner: dhth 35 | name: homebrew-tap 36 | directory: Formula 37 | license: MIT 38 | homepage: "https://github.com/dhth/omm" 39 | description: "omm is a keyboard-driven task manager for the command line" 40 | 41 | changelog: 42 | sort: asc 43 | filters: 44 | exclude: 45 | - "^docs:" 46 | - "^test:" 47 | - "^ci:" 48 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | cmd/assets/CHANGELOG.md -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Dhruv Thakur 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # omm 2 | 3 | [![Build Workflow Status](https://img.shields.io/github/actions/workflow/status/dhth/omm/build.yml?style=flat-square)](https://github.com/dhth/omm/actions/workflows/build.yml) 4 | [![Vulncheck Workflow Status](https://img.shields.io/github/actions/workflow/status/dhth/omm/vulncheck.yml?style=flat-square&label=vulncheck)](https://github.com/dhth/omm/actions/workflows/vulncheck.yml) 5 | [![Latest Release](https://img.shields.io/github/release/dhth/omm.svg?style=flat-square)](https://github.com/dhth/omm/releases/latest) 6 | [![Commits Since Latest Release](https://img.shields.io/github/commits-since/dhth/omm/latest?style=flat-square)](https://github.com/dhth/omm/releases) 7 | 8 | `omm` (stands for "on-my-mind") is a keyboard-driven task manager for the 9 | command line. 10 | 11 | 12 | ![Usage](https://tools.dhruvs.space/images/omm/omm.gif) 13 | 14 | [source video](https://www.youtube.com/watch?v=iB_PHc92wgY) 15 | 16 | 🤔 Motivation 17 | --- 18 | 19 | The fundamental idea behind `omm` is that while we might have several tasks on 20 | our to-do list — each with its own priority — we typically focus on one task at 21 | a time. Priorities frequently change, requiring us to switch between tasks. 22 | `omm` lets you visualize this shifting priority order with a very simple list 23 | interface that can be managed entirely via the keyboard. 24 | 25 | 💾 Installation 26 | --- 27 | 28 | **homebrew**: 29 | 30 | ```sh 31 | brew install dhth/tap/omm 32 | ``` 33 | 34 | **go**: 35 | 36 | ```sh 37 | go install github.com/dhth/omm@latest 38 | ``` 39 | 40 | Or get the binary directly from a [release][2]. Read more about verifying the 41 | authenticity of released artifacts [here](#-verifying-release-artifacts). 42 | 43 | > [!NOTE] 44 | > Would you like to see `omm` on Windows? Vote 45 | > [here](https://github.com/dhth/omm/discussions/51). 46 | 47 | 💡 Guide 48 | --- 49 | 50 | omm offers a guided walkthrough of its features, intended for new users of it. 51 | Run it as follows. 52 | 53 | ```bash 54 | omm guide 55 | ``` 56 | 57 | ![Guide](https://tools.dhruvs.space/images/omm/omm-guide-1.png) 58 | 59 | 📋 Updates 60 | --- 61 | 62 | Check out the latest features added to `omm` using: 63 | 64 | ```bash 65 | omm updates 66 | ``` 67 | 68 | ⚡️ Usage 69 | --- 70 | 71 | ### TUI 72 | 73 | `omm`'s TUI is comprised of several views: 4 lists (for active and archived 74 | tasks, one for task bookmarks, and one for prefix selection), a context pane, a 75 | task details pane, and a task entry/update pane. 76 | 77 | #### Active Tasks List 78 | 79 | As the name suggests, the active tasks list is for the tasks you're actively 80 | working on right now. It allows you to do the following: 81 | 82 | - Create/update tasks at a specific position in the priority list 83 | - Add a task at the start/end of the list 84 | - Move a task to the top of the list (indicating that it takes the highest 85 | priority at the moment) 86 | - Move task up/down based on changing priorities 87 | - Archive a task 88 | - Permanently delete a task 89 | 90 | ![active-tasks](https://tools.dhruvs.space/images/omm/omm-active-tasks-1.png) 91 | 92 | #### Archived Tasks List 93 | 94 | Once you're done with a task, you can archive it, which puts it in the archived 95 | tasks list. It's more for historical reference, but you can also unarchive a 96 | task and put it back in the active list, if you need to. You can also 97 | permanently delete tasks from here. 98 | 99 | #### Context Pane 100 | 101 | For tasks that need more details that you can fit in a one line summary, there 102 | is the context pane. You add/update context for a task via a text editor which 103 | is chosen based on the following look ups: 104 | 105 | - the "--editor" flag 106 | - $OMM_EDITOR 107 | - "editor" property in omm's toml config 108 | - $EDITOR/$VISUAL 109 | - `vi` (fallback) 110 | 111 | #### Task Details Pane 112 | 113 | The Task Details pane lets you see all details for a task in a single scrollable 114 | pane. 115 | 116 | **[`^ back to top ^`](#omm)** 117 | 118 | #### Task Entry Pane 119 | 120 | This is where you enter/update a task summary. If you enter a summary in the 121 | format `prefix: task summary goes here`, `omm` will highlight the prefix for you 122 | in the task lists. 123 | 124 | ![active-tasks](https://tools.dhruvs.space/images/omm/omm-task-entry-1.png) 125 | 126 | #### Tweaking the TUI 127 | 128 | The list colors and the task list title can be changed via CLI flags. 129 | 130 | ```bash 131 | omm \ 132 | --tl-color="#b8bb26" \ 133 | --atl-color="#fb4934" \ 134 | --title="work" 135 | ``` 136 | 137 | omm offers two modes for the visual density of its lists: "compact" and 138 | "spacious", the former being the default. omm can be started with one of 139 | the two modes, which can later be switched by pressing "v". 140 | 141 | ```bash 142 | omm --list-density=spacious 143 | ``` 144 | 145 | This configuration property can also be provided via the environment variable 146 | `OMM_LIST_DENSITY`. 147 | 148 | Compact mode: 149 | 150 | ![compact](https://tools.dhruvs.space/images/omm/omm-compact-1.png) 151 | 152 | Spacious mode: 153 | 154 | ![spacious](https://tools.dhruvs.space/images/omm/omm-spacious-1.png) 155 | 156 | ### Importing tasks 157 | 158 | Multiple tasks can be imported from `stdin` using the `import` subcommand. 159 | 160 | ```bash 161 | cat << 'EOF' | omm import 162 | orders: order new ACME rocket skates 163 | traps: draw fake tunnel on the canyon wall 164 | tech: assemble ACME jet-propelled pogo stick 165 | EOF 166 | ``` 167 | 168 | Tip: Vim users can import tasks into omm by making a visual selection and 169 | running `:'<,'>!omm import`. 170 | 171 | ### Adding a single task 172 | 173 | When an argument is passed to `omm`, it saves it as a task, instead of opening 174 | up the TUI. 175 | 176 | ```bash 177 | omm "Install spring-loaded boxing glove" 178 | ``` 179 | 180 | ### Configuration 181 | 182 | `omm` allows you to change the some of its behavior via configuration, which it 183 | will consider in the order listed below: 184 | 185 | - CLI flags (run `omm -h` to see details) 186 | - Environment variables (eg. `OMM_EDITOR`) 187 | - A TOML configuration file (run `omm -h` to see where this lives; you can 188 | change this via the flag `--config-path`) 189 | 190 | Here's a sample config file: 191 | 192 | ```toml 193 | db_path = "~/.local/share/omm/omm-w.db" 194 | tl_color = "#b8bb26" 195 | atl_color = "#fabd2f" 196 | title = "work" 197 | list_density = "spacious" 198 | show_context = false 199 | editor = "vi -u NONE" 200 | ``` 201 | 202 | **[`^ back to top ^`](#omm)** 203 | 204 | Outputting tasks 205 | --- 206 | 207 | Tasks can be outputted to `stdout` using the `tasks` subcommand. 208 | 209 | ```bash 210 | omm tasks 211 | ``` 212 | 213 | 🤔 Tips 214 | --- 215 | 216 | These are some tips to improve your experience of using `omm`: 217 | 218 | - Set up discrete instances of `omm` if you need to. You can do so by 219 | referencing a different config file (which points to a unique database) for 220 | each instance, or by directly using `--db-path` flag. Eg. an omm instance for 221 | personal tasks, and another for work. Set up as many `omm` instances as you 222 | need. 223 | - Use `omm updates` to stay up to date with omm's latest features/changes. 224 | 225 | ⌨️ Keymaps 226 | --- 227 | 228 | ### General 229 | 230 | q/esc/ctrl+c go back 231 | Q quit from anywhere 232 | 233 | ### Active/Archived Tasks List 234 | 235 | j/↓ move cursor down 236 | k/↑ move cursor up 237 | h go to previous page 238 | l go to next page 239 | g go to the top 240 | G go to the end 241 | tab move between lists 242 | C toggle showing context 243 | d toggle Task Details pane 244 | b open Task Bookmarks list 245 | B open all bookmarks added to current task 246 | c update context for a task 247 | ctrl+d archive/unarchive task 248 | ctrl+x delete task 249 | ctrl+r reload task lists 250 | / filter list by task prefix 251 | ctrl+p filter by prefix via the prefix selection list 252 | y copy selected task's context to system clipboard 253 | v toggle between compact and spacious view 254 | 255 | ### Active Tasks List 256 | 257 | q/esc/ctrl+c quit 258 | o/a add task below cursor 259 | O add task above cursor 260 | I add task at the top 261 | A add task at the end 262 | u update task summary 263 | ⏎ move task to the top 264 | E move task to the end 265 | J move task one position down 266 | K move task one position up 267 | 268 | ### Task Creation/Update Pane 269 | 270 | ⏎ submit task summary 271 | ctrl+p choose/change prefix via the prefix selection list 272 | 273 | ### Task Details Pane 274 | 275 | h/←/→/l move backwards/forwards when in the task details view 276 | y copy current task's context to system clipboard 277 | B open all bookmarks added to current task 278 | 279 | ### Task Bookmarks List 280 | 281 | ⏎ open URL in browser 282 | 283 | 🔐 Verifying release artifacts 284 | --- 285 | 286 | In case you get the `omm` binary directly from a [release][2], you may want to 287 | verify its authenticity. Checksums are applied to all released artifacts, and 288 | the resulting checksum file is signed using 289 | [cosign](https://docs.sigstore.dev/cosign/installation/). 290 | 291 | Steps to verify (replace `A.B.C` in the commands listed below with the version 292 | you want): 293 | 294 | 1. Download the following files from the release: 295 | 296 | - omm_A.B.C_checksums.txt 297 | - omm_A.B.C_checksums.txt.pem 298 | - omm_A.B.C_checksums.txt.sig 299 | 300 | 2. Verify the signature: 301 | 302 | ```shell 303 | cosign verify-blob omm_A.B.C_checksums.txt \ 304 | --certificate omm_A.B.C_checksums.txt.pem \ 305 | --signature omm_A.B.C_checksums.txt.sig \ 306 | --certificate-identity-regexp 'https://github\.com/dhth/omm/\.github/workflows/.+' \ 307 | --certificate-oidc-issuer "https://token.actions.githubusercontent.com" 308 | ``` 309 | 310 | 3. Download the compressed archive you want, and validate its checksum: 311 | 312 | ```shell 313 | curl -sSLO https://github.com/dhth/omm/releases/download/vA.B.C/omm_A.B.C_linux_amd64.tar.gz 314 | sha256sum --ignore-missing -c omm_A.B.C_checksums.txt 315 | ``` 316 | 317 | 3. If checksum validation goes through, uncompress the archive: 318 | 319 | ```shell 320 | tar -xzf omm_A.B.C_linux_amd64.tar.gz 321 | ./omm 322 | # profit! 323 | ``` 324 | 325 | Acknowledgements 326 | --- 327 | 328 | `omm` stands on the shoulders of giants. 329 | 330 | - [bubbletea](https://github.com/charmbracelet/bubbletea) as the TUI framework 331 | - [sqlite](https://www.sqlite.org) as the local database 332 | - [goreleaser](https://github.com/goreleaser/goreleaser) for releasing binaries 333 | 334 | **[`^ back to top ^`](#omm)** 335 | 336 | [1]: https://github.com/dhth/binhelpers#downloading-and-validating-the-integrity-of-binaries 337 | [2]: https://github.com/dhth/omm/releases 338 | -------------------------------------------------------------------------------- /assets/sample-tasks.txt: -------------------------------------------------------------------------------- 1 | tech: Order new ACME rocket skates 2 | traps: Draw fake tunnel on the canyon wall 3 | tech: Check ACME catalog for new gadgets 4 | tech: Test the giant slingshot 5 | tech: Assemble ACME jet-propelled pogo stick 6 | plan: Plan the next trap location 7 | orders: Buy more bird seed for the bait 8 | tech: Install spring-loaded boxing glove 9 | order: Order a lifetime supply of ACME bird seed to lure Road Runner 10 | traps: Paint a fake bridge over a dry riverbed 11 | -------------------------------------------------------------------------------- /cmd/assets/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [v0.6.0] - May 02, 2025 9 | 10 | ### Changes 11 | 12 | - Use full width for context and task details 13 | - Increase upper threshold for context length to 1 MB (was 4KB before) 14 | - Preserve newlines in rendered context 15 | 16 | ## [v0.5.1] - Aug 13, 2024 17 | 18 | ### Fixed 19 | 20 | - Fixed issue where omm would panic when tasks summaries of certain lengths were 21 | entered (on certain platforms) 22 | 23 | ## [v0.5.0] - Aug 03, 2024 24 | 25 | ### Added 26 | 27 | - URIs with custom schemes are considered as task bookmarks. eg. 28 | - `spotify:track:4fVBFyglBhMf0erfF7pBJp` 29 | - `obsidian://open?vault=VAULT&file=FILE` 30 | - Circular navigation for lists 31 | 32 | ## [v0.4.3] - Jul 28, 2024 33 | 34 | ### Added 35 | 36 | - Flag for changing deletion behavior 37 | 38 | ### Changes 39 | 40 | - omm asks for confirmation before deleting a task by default 41 | 42 | ## [v0.4.2] - Jul 26, 2024 43 | 44 | ### Fixed 45 | 46 | - Fixed issue where pager didn't respond to arrow keys 47 | 48 | ## [v0.4.0] - Jul 26, 2024 49 | 50 | ### Added 51 | 52 | - Markdown in task context is rendered with syntax highlighting 53 | - Task Lists in "compact" mode highlight prefixes 54 | - Tasks Lists can be filtered 55 | - Quick filters based on task prefixes can be applied 56 | - Prefix can be chosen during task creation/update 57 | - Keymap to move an active task to the end of the list 58 | - "updates" subcommand 59 | 60 | ### Changes 61 | 62 | - Task Lists in "compact" mode show more than 9 tasks at a time, when maximised 63 | 64 | ### Removed 65 | 66 | - Keymaps ([2-9]) for the Active Tasks list to move task at a specific index to 67 | the top 68 | 69 | ## [v0.3.1] - Jul 19, 2024 70 | 71 | ### Changes 72 | 73 | - URLs in a task's summary are also considered as bookmarks 74 | 75 | ## [v0.3.0] - Jul 19, 2024 76 | 77 | ### Added 78 | 79 | - The ability to quickly open URLs present in a task's context 80 | - Support for providing configuration via a TOML file 81 | - The ability to copy a task's context 82 | 83 | ## [v0.2.2] - Jul 15, 2024 84 | 85 | ## Fixed 86 | 87 | - Fixed issue where closing the "Task Details" pane would move the active task 88 | list to the next page 89 | 90 | ## [v0.2.1] - Jul 15, 2024 91 | 92 | ## Fixed 93 | 94 | - Fixed issue where omm's database would be stored in an incorrect location on 95 | Windows 96 | 97 | ## [v0.2.0] - Jul 14, 2024 98 | 99 | ### Added 100 | 101 | - Added "task context", which can be used for additional details for a task that 102 | don't fit in the summary 103 | - A new list density mode 104 | - "Task Details" pane 105 | - An onboarding guide 106 | 107 | ### Changed 108 | 109 | - Task lists now highlight prefixes in task summaries, when provided 110 | 111 | ## [v0.1.0] - Jul 09, 2024 112 | 113 | ### Added 114 | 115 | - Initial release 116 | 117 | [unreleased]: https://github.com/dhth/omm/compare/v0.6.0...HEAD 118 | [v0.6.0]: https://github.com/dhth/omm/compare/v0.5.1...v0.6.0 119 | [v0.5.1]: https://github.com/dhth/omm/compare/v0.5.0...v0.5.1 120 | [v0.5.0]: https://github.com/dhth/omm/compare/v0.4.3...v0.5.0 121 | [v0.4.3]: https://github.com/dhth/omm/compare/v0.4.2...v0.4.3 122 | [v0.4.2]: https://github.com/dhth/omm/compare/v0.4.0...v0.4.2 123 | [v0.4.0]: https://github.com/dhth/omm/compare/v0.3.1...v0.4.0 124 | [v0.3.1]: https://github.com/dhth/omm/compare/v0.3.0...v0.3.1 125 | [v0.3.0]: https://github.com/dhth/omm/compare/v0.2.2...v0.3.0 126 | [v0.2.2]: https://github.com/dhth/omm/compare/v0.2.1...v0.2.2 127 | [v0.2.1]: https://github.com/dhth/omm/compare/v0.2.0...v0.2.1 128 | [v0.2.0]: https://github.com/dhth/omm/compare/v0.1.0...v0.2.0 129 | [v0.1.0]: https://github.com/dhth/omm/commits/v0.1.0/ 130 | -------------------------------------------------------------------------------- /cmd/assets/guide/actions-adding-context.md: -------------------------------------------------------------------------------- 1 | As mentioned before, once a task is created, you might want to add context to 2 | it. 3 | 4 | You do that by pressing `c`. Go ahead, try it out. Try changing the text, and 5 | then save the file. This context text should get updated accordingly. 6 | 7 | Once saved, you can also copy a tasks's context to your system clipboard by 8 | pressing `y`. 9 | -------------------------------------------------------------------------------- /cmd/assets/guide/actions-adding-tasks.md: -------------------------------------------------------------------------------- 1 | Let's get to the crux of omm: **adding** and **prioritizing** tasks. We'll begin 2 | with adding tasks. 3 | 4 | You can add a task below the cursor by pressing `a`. Once you get acquainted 5 | with omm, you'll want to have more control on the position of the newly added 6 | task. omm offers the following keymaps for that. 7 | 8 | ```text 9 | o/a add task below cursor 10 | O add task above cursor 11 | I add task at the top 12 | A add task at the end 13 | ``` 14 | 15 | Go ahead, create a task, then move to the next guided item. 16 | -------------------------------------------------------------------------------- /cmd/assets/guide/actions-choosing-a-prefix.md: -------------------------------------------------------------------------------- 1 | Once you've added a few tasks, you can use the **Prefix Selection List** to 2 | quickly choose a prefix for your next task. This is intended to make creation of 3 | new tasks easier. 4 | 5 | Go ahead, try it out. 6 | 7 | Press `a/o/O/I/A` to start adding a new task, and then press ``. Choose 8 | a prefix from the list and press `⏎`. 9 | -------------------------------------------------------------------------------- /cmd/assets/guide/actions-deleting-a-task.md: -------------------------------------------------------------------------------- 1 | You can permanently delete a task by pressing ``. This will put omm in 2 | an "awaiting confirmation" mode, where you can press `` again to proceed 3 | with the deletion, or press any other key to cancel it. 4 | 5 | This is omm's default behaviour. If you'd rather omm not ask for confirmation, 6 | you can set the `--confirm-before-deletion` flag to `false`. 7 | 8 | Go ahead, create a test task, and permanently delete it. 9 | -------------------------------------------------------------------------------- /cmd/assets/guide/actions-filtering-tasks.md: -------------------------------------------------------------------------------- 1 | You can filter tasks in a list by pressing `/`. Doing this will open up a search 2 | prompt, which will match your query with task prefixes. 3 | 4 | Try it out now. You get out of the filtered state by pressing `q/esc/`. 5 | 6 | Note: You cannot add tasks or move them around in a filtered state. But, you can 7 | press `⏎` to go back to the main list and have the cursor be moved to the task 8 | you had selected in the filtered state. 9 | -------------------------------------------------------------------------------- /cmd/assets/guide/actions-markdown-in-context.md: -------------------------------------------------------------------------------- 1 | Context can (optionally) be written in markdown, which will get rendered 2 | accordingly. 3 | 4 | Open this context in the task details pane (press `d`) to view it in its 5 | entirety. 6 | 7 | --- 8 | 9 | # This is an H1 heading 10 | 11 | ## This is an H2 heading 12 | 13 | ### This is an H3 heading 14 | 15 | --- 16 | 17 | `code` emphasis looks like this. 18 | 19 | --- 20 | 21 | ```go 22 | // this is a code block 23 | fmt.Print("This is a code block") 24 | ``` 25 | --- 26 | 27 | **bold text looks like this** 28 | 29 | --- 30 | 31 | *and italic like this* 32 | 33 | --- 34 | 35 | This is a URL: [omm](https://github.com/dhth/omm) 36 | 37 | --- 38 | 39 | > This is a block quote 40 | 41 | --- 42 | 43 | Tables render like this: 44 | 45 | | Syntax | Description | Test Text | 46 | | :--- | :----: | ---: | 47 | | Header | Title | Here's this | 48 | | Paragraph | Text | And more | 49 | 50 | --- 51 | 52 | You can ~~strikethrough~~ words. 53 | 54 | --- 55 | 56 | - [x] this is a task list 57 | - [x] inside a task list app 58 | - [ ] pretty meta 59 | 60 | --- 61 | 62 | - Lists 63 | - Render 64 | - Like 65 | - This 66 | -------------------------------------------------------------------------------- /cmd/assets/guide/actions-quick-filtering-via-a-list.md: -------------------------------------------------------------------------------- 1 | You can also choose the prefix you want to filter by using the **Prefix 2 | Selection List**. Press `ctrl+p` to open up this list. Press `⏎` to pre-populate 3 | the task list's search prompt with your selection. 4 | 5 | Try it out now. 6 | 7 | Note: Both the **Active Tasks List** and **Archived Tasks List** can be filtered 8 | separately, using either the manual filtering approach or via the **Quick Filter 9 | List**. 10 | -------------------------------------------------------------------------------- /cmd/assets/guide/actions-updating-task-details.md: -------------------------------------------------------------------------------- 1 | Once a task is created, its summary and context can be changed at any point. 2 | 3 | You can update a task's summary by pressing `u`. 4 | 5 | This will open up the the same prompt you saw when creating a new task, with the 6 | only difference that the task's summary will be pre-filled for you. This can 7 | come in handy when you want to quickly jot down a task for yourself (either by 8 | using the TUI, or by using the CLI (eg. `omm 'a hastily written task 9 | summary'`)), and then come back to it later to refine it more. 10 | 11 | Similarly, you can also update a task's context any time (by pressing `c`). 12 | -------------------------------------------------------------------------------- /cmd/assets/guide/cli-adding-a-task-via-the-cli.md: -------------------------------------------------------------------------------- 1 | You can also add a task to omm via its command line interface. For example: 2 | 3 | ```bash 4 | omm 'prefix: a task summary' 5 | ``` 6 | 7 | This will add an entry to the top of the active tasks list. 8 | -------------------------------------------------------------------------------- /cmd/assets/guide/cli-importing-several-tasks-via-the-cli.md: -------------------------------------------------------------------------------- 1 | You can also import more than one task at a time by using the `import` 2 | subcommand. For example: 3 | 4 | ```bash 5 | cat << 'EOF' | omm import 6 | orders: order new ACME rocket skates 7 | traps: draw fake tunnel on the canyon wall 8 | tech: assemble ACME jet-propelled pogo stick 9 | EOF 10 | ``` 11 | 12 | omm will expect each line in stdin to hold one task's summary. 13 | -------------------------------------------------------------------------------- /cmd/assets/guide/config-a-sample-toml-config.md: -------------------------------------------------------------------------------- 1 | Here's a sample TOML configuration file: 2 | 3 | ```toml 4 | db_path = "~/.local/share/omm/omm-w.db" 5 | tl_color = "#b8bb26" 6 | atl_color = "#fabd2f" 7 | title = "work" 8 | list_density = "spacious" 9 | show_context = false 10 | editor = "vi -u NONE" 11 | confirm_before_deletion = false 12 | circular_nav = true 13 | ``` 14 | -------------------------------------------------------------------------------- /cmd/assets/guide/config-changing-the-defaults.md: -------------------------------------------------------------------------------- 1 | omm allows you to change the some of its behavior via configuration, which it 2 | will consider in the order listed below: 3 | 4 | - CLI flags (run `omm -h` to see details) 5 | - Environment variables (eg. `$OMM_EDITOR`) 6 | - A TOML configuration file (run `omm -h` to see where this lives; you can 7 | change this via the flag `--config-path`) 8 | 9 | omm will consider configuration in the order laid out above, ie, CLI flags will 10 | take the highest priority. 11 | -------------------------------------------------------------------------------- /cmd/assets/guide/config-flags-env-vars-and-config-file.md: -------------------------------------------------------------------------------- 1 | Every flag listed by `omm -h` (except `--config-path`) has an environment 2 | variable counterpart, as well as a TOML config counterpart. 3 | 4 | For example: 5 | 6 | ```text 7 | --show-context -> OMM_SHOW_CONTEXT -> show_context 8 | --editor -> OMM_EDITOR -> editor 9 | ``` 10 | -------------------------------------------------------------------------------- /cmd/assets/guide/domain-an-archived-task.md: -------------------------------------------------------------------------------- 1 | This is the archived list, meaning it holds tasks that are no longer being 2 | worked on. 3 | 4 | omm provides this list both for historical reference, as well as for you to be 5 | able to move an archived task back into the active list. 6 | 7 | You can toggle the state of a task using ``. 8 | 9 | Press `tab/q/esc/` to go back to the active list. 10 | -------------------------------------------------------------------------------- /cmd/assets/guide/domain-task-bookmarks.md: -------------------------------------------------------------------------------- 1 | Sometimes you'll add URIs to a task's summary or its context. 2 | 3 | Such URIs could be placed anywhere in the summary/context. For example: 4 | 5 | - [URL](https://c.xkcd.com/random/comic) 6 | - spotify:track:4fVBFyglBhMf0erfF7pBJp 7 | - obsidian://open?vault=VAULT&file=FILE 8 | - mailto:example@example.com 9 | - slack://channel?team=TEAM_ID&id=ID 10 | 11 | omm lets you open these URIs via a single keypress. You can either press `b` to 12 | open up a list of all URIs, and then open one of them by pressing `⏎` or open 13 | all of them by pressing `B` (some of them will fail to be opened on your machine 14 | since they point to non-existent resources, but you get the point). 15 | 16 | Note: If a task has a single URI added to it, pressing `b` will skip showing the 17 | list, and open the URI directly. 18 | 19 | Try both approaches now. 20 | -------------------------------------------------------------------------------- /cmd/assets/guide/domain-task-details.md: -------------------------------------------------------------------------------- 1 | The **Task Details** pane is intended for when you want to read all the details 2 | associated with a task in a full screen view. 3 | 4 | You can view this pane by pressing `d`. This pane is useful when a task's 5 | context is too long to fit in the context pane. 6 | 7 | Whilst in this pane, you can move backwards and forwards in the task list by 8 | pressing `h/←/→/l`. You quit out of this pane by either pressing `d` again, or 9 | `q/esc/`. 10 | 11 | Try it out. Come back to this entry when you're done. 12 | -------------------------------------------------------------------------------- /cmd/assets/guide/domain-task-priorities.md: -------------------------------------------------------------------------------- 1 | At its core, omm is a dynamic list that maintains a sequence of tasks based on 2 | the priorities you assign them. 3 | 4 | And, as we all know, priorities often change. You're probably juggling multiple 5 | tasks on any given day. As such, omm allows you to move tasks around in the 6 | priority order. It has the following keymaps to achieve this: 7 | 8 | ```text 9 | ⏎ move task to the top 10 | E move task to the end 11 | J move task one position down 12 | K move task one position up 13 | ``` 14 | 15 | It's recommended that you move the task that you're currently focussing on to 16 | the top. 17 | -------------------------------------------------------------------------------- /cmd/assets/guide/domain-task-state.md: -------------------------------------------------------------------------------- 1 | A task can be in one of two states: **active** or **archived**. 2 | 3 | This list shows active tasks. 4 | 5 | To be pedantic about things, only the tasks in the active list are supposed to 6 | be "on your mind". However, there are benefits to having a list of archived 7 | tasks as well. 8 | 9 | Press `` to see the archived list. 10 | -------------------------------------------------------------------------------- /cmd/assets/guide/domain-tasks.md: -------------------------------------------------------------------------------- 1 | omm (**on-my-mind**) is a task manager. You can also think of it as a keyboard 2 | driven to-do list. 3 | 4 | As such, tasks are at the core of omm. A task can be anything that you want to 5 | keep track of, ideally something that is concrete and has a clear definition of 6 | done. 7 | 8 | Tasks in omm have a one liner summary, and optionally, some context associated 9 | with them (like this paragraph). You can choose to add context to a task when 10 | you want to save details that don't fit in a single line. 11 | -------------------------------------------------------------------------------- /cmd/assets/guide/guide-and-thats-it.md: -------------------------------------------------------------------------------- 1 | That's it for the walkthrough! 2 | 3 | I hope omm proves to be a useful tool for your task management needs. If you 4 | find any bugs in it, or have feature requests, feel free to submit them 5 | [here](https://github.com/dhth/omm/issues). 6 | 7 | Happy task managing! 👋 8 | -------------------------------------------------------------------------------- /cmd/assets/guide/guide-welcome-to-omm.md: -------------------------------------------------------------------------------- 1 | Hi there 👋 Thanks for trying out **omm**. 2 | 3 | This is a guided walkthrough to get you acquainted with omm's features. 4 | 5 | Before we begin, let's get the basics out of the way: you exit omm by pressing 6 | `q/esc/`. These keys also move you back menus/panes whilst using omm's 7 | TUI. 8 | 9 | Onwards with the walkthrough then! Simply press `j/↓`, and follow the 10 | instructions. 11 | -------------------------------------------------------------------------------- /cmd/assets/guide/visuals-list-density.md: -------------------------------------------------------------------------------- 1 | omm's task lists can be viewed in two density modes: **compact** and 2 | **spacious**. 3 | 4 | This is the compact mode. As opposed to this, the spacious mode shows tasks in a 5 | more roomier list, alongside showing creation timestamps. 6 | 7 | omm starts up with compact mode by default (you can change this, as we'll see 8 | soon). You can toggle between the two modes by pressing `v`. Choose whichever 9 | mode fits your workflow better. 10 | 11 | Try it out. Come back to this mode once you're done. 12 | -------------------------------------------------------------------------------- /cmd/assets/guide/visuals-toggling-context-pane.md: -------------------------------------------------------------------------------- 1 | The context pane can be toggled on/off by pressing `C` (capital c). 2 | 3 | You can choose to display it or not based on your preference. For convenience, 4 | the lists will always highlight tasks that have a context associated with them 5 | by having a **(c)** marker on them. 6 | 7 | omm starts up with the context pane hidden by default (you can change this, as 8 | we'll see soon). 9 | -------------------------------------------------------------------------------- /cmd/db.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "database/sql" 5 | ) 6 | 7 | func getDB(dbpath string) (*sql.DB, error) { 8 | db, err := sql.Open("sqlite", dbpath) 9 | if err != nil { 10 | return nil, err 11 | } 12 | db.SetMaxOpenConns(1) 13 | db.SetMaxIdleConns(1) 14 | return db, err 15 | } 16 | -------------------------------------------------------------------------------- /cmd/guide.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "database/sql" 5 | "embed" 6 | "fmt" 7 | "regexp" 8 | "strings" 9 | "time" 10 | 11 | pers "github.com/dhth/omm/internal/persistence" 12 | "github.com/dhth/omm/internal/types" 13 | ) 14 | 15 | const ( 16 | guideAssetsPathPrefix = "assets/guide" 17 | ) 18 | 19 | var ( 20 | //go:embed assets/guide/*.md 21 | guideFolder embed.FS 22 | 23 | guideSummaryRegex = regexp.MustCompile(`[^a-z-]`) 24 | 25 | guideEntries = []entry{ 26 | {summary: "guide: welcome to omm"}, 27 | {summary: "domain: tasks"}, 28 | {summary: "domain: task state"}, 29 | {summary: "domain: an archived task", archived: true}, 30 | {summary: "domain: task details"}, 31 | {summary: "visuals: list density"}, 32 | {summary: "visuals: toggling context pane"}, 33 | {summary: "actions: adding tasks"}, 34 | {summary: "actions: choosing a prefix"}, 35 | {summary: "cli: adding a task via the CLI"}, 36 | {summary: "cli: importing several tasks via the CLI"}, 37 | {summary: "actions: adding context"}, 38 | {summary: "actions: markdown in context"}, 39 | {summary: "actions: filtering tasks"}, 40 | {summary: "actions: quick filtering via a list"}, 41 | {summary: "actions: deleting a task"}, 42 | {summary: "domain: task bookmarks"}, 43 | {summary: "domain: task priorities"}, 44 | {summary: "actions: updating task details"}, 45 | {summary: "config: changing the defaults"}, 46 | {summary: "config: flags, env vars, and config file"}, 47 | {summary: "config: a sample TOML config"}, 48 | {summary: "guide: and that's it!"}, 49 | } 50 | ) 51 | 52 | type entry struct { 53 | summary string 54 | archived bool 55 | } 56 | 57 | func getContext(summary string) (string, error) { 58 | summary = strings.ToLower(summary) 59 | summary = strings.ReplaceAll(summary, " ", "-") 60 | fPath := guideSummaryRegex.ReplaceAllString(summary, "") 61 | 62 | ctxBytes, err := guideFolder.ReadFile(fmt.Sprintf("%s/%s.md", guideAssetsPathPrefix, fPath)) 63 | if err != nil { 64 | return "", err 65 | } 66 | 67 | return string(ctxBytes), nil 68 | } 69 | 70 | func insertGuideTasks(db *sql.DB) error { 71 | tasks := make([]types.Task, len(guideEntries)) 72 | 73 | now := time.Now() 74 | 75 | ctxs := make([]string, len(guideEntries)) 76 | 77 | var err error 78 | for i, e := range guideEntries { 79 | ctxs[i], err = getContext(guideEntries[i].summary) 80 | if err != nil { 81 | continue 82 | } 83 | 84 | tasks[i] = types.Task{ 85 | Summary: e.summary, 86 | Context: &ctxs[i], 87 | Active: !e.archived, 88 | CreatedAt: now, 89 | UpdatedAt: now, 90 | } 91 | } 92 | 93 | _, err = pers.InsertTasks(db, tasks, true) 94 | 95 | return err 96 | } 97 | -------------------------------------------------------------------------------- /cmd/guide_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestGetContext(t *testing.T) { 10 | for _, entry := range guideEntries { 11 | got, err := getContext(entry.summary) 12 | assert.NoError(t, err) 13 | assert.NotEmpty(t, got) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /cmd/import.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "fmt" 7 | "time" 8 | 9 | pers "github.com/dhth/omm/internal/persistence" 10 | "github.com/dhth/omm/internal/types" 11 | ) 12 | 13 | var errWillExceedCapacity = errors.New("import will exceed capacity") 14 | 15 | func importTask(db *sql.DB, taskSummary string) error { 16 | numTasks, err := pers.FetchNumActiveTasksShown(db) 17 | if err != nil { 18 | return err 19 | } 20 | if numTasks+1 > pers.TaskNumLimit { 21 | return fmt.Errorf("%w (current task count: %d)", errWillExceedCapacity, numTasks) 22 | } 23 | 24 | now := time.Now() 25 | task := types.Task{ 26 | Summary: taskSummary, 27 | Active: true, 28 | CreatedAt: now, 29 | UpdatedAt: now, 30 | } 31 | _, err = pers.InsertTasks(db, []types.Task{task}, true) 32 | return err 33 | } 34 | 35 | func importTasks(db *sql.DB, taskSummaries []string) error { 36 | numTasks, err := pers.FetchNumActiveTasksShown(db) 37 | if err != nil { 38 | return err 39 | } 40 | if numTasks+len(taskSummaries) > pers.TaskNumLimit { 41 | return fmt.Errorf("%w (current task count: %d)", errWillExceedCapacity, numTasks) 42 | } 43 | 44 | now := time.Now() 45 | tasks := make([]types.Task, len(taskSummaries)) 46 | for i, summ := range taskSummaries { 47 | tasks[i] = types.Task{ 48 | Summary: summ, 49 | Active: true, 50 | CreatedAt: now, 51 | UpdatedAt: now, 52 | } 53 | } 54 | _, err = pers.InsertTasks(db, tasks, true) 55 | return err 56 | } 57 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bufio" 5 | "database/sql" 6 | _ "embed" 7 | "errors" 8 | "fmt" 9 | "io/fs" 10 | "os" 11 | "path/filepath" 12 | "runtime" 13 | "strings" 14 | "time" 15 | 16 | pers "github.com/dhth/omm/internal/persistence" 17 | "github.com/dhth/omm/internal/types" 18 | "github.com/dhth/omm/internal/ui" 19 | "github.com/spf13/cobra" 20 | "github.com/spf13/pflag" 21 | "github.com/spf13/viper" 22 | ) 23 | 24 | const ( 25 | defaultConfigFilename = "omm" 26 | envPrefix = "OMM" 27 | author = "@dhth" 28 | repoIssuesURL = "https://github.com/dhth/omm/issues" 29 | defaultConfigDir = ".config" 30 | defaultDataDir = ".local/share" 31 | defaultConfigDirWindows = "AppData/Roaming" 32 | defaultDataDirWindows = "AppData/Local" 33 | configFileName = "omm/omm.toml" 34 | dbFileName = "omm/omm.db" 35 | printTasksDefault = 20 36 | taskListTitleMaxLen = 8 37 | ) 38 | 39 | var ( 40 | errCouldntGetHomeDir = errors.New("couldn't get home directory") 41 | errConfigFileExtIncorrect = errors.New("config file must be a TOML file") 42 | errConfigFileDoesntExist = errors.New("config file does not exist") 43 | errDBFileExtIncorrect = errors.New("db file needs to end with .db") 44 | errMaxImportLimitExceeded = errors.New("import limit exceeded") 45 | errNothingToImport = errors.New("nothing to import") 46 | errListDensityIncorrect = errors.New("list density is incorrect; valid values: compact/spacious") 47 | errCouldntCreateDBDirectory = errors.New("couldn't create directory for database") 48 | errCouldntCreateDB = errors.New("couldn't create database") 49 | errCouldntInitializeDB = errors.New("couldn't initialize database") 50 | errCouldntOpenDB = errors.New("couldn't open database") 51 | errCouldntSetupGuide = errors.New("couldn't set up guided walkthrough") 52 | 53 | //go:embed assets/CHANGELOG.md 54 | updateContents string 55 | 56 | reportIssueMsg = fmt.Sprintf("This isn't supposed to happen; let %s know about this error via \n%s.", author, repoIssuesURL) 57 | maxImportNumMsg = fmt.Sprintf(`A maximum of %d tasks that can be imported at a time. 58 | Archive/Delete tasks that are not active using ctrl+d/ctrl+x. 59 | 60 | `, pers.TaskNumLimit) 61 | 62 | taskCapacityMsg = fmt.Sprintf(`A maximum of %d tasks that can be active at a time. 63 | Archive/Delete tasks that are not active using ctrl+d/ctrl+x. 64 | 65 | `, pers.TaskNumLimit) 66 | ) 67 | 68 | func Execute(version string) error { 69 | rootCmd, err := NewRootCommand() 70 | if err != nil { 71 | fmt.Fprintf(os.Stderr, "Error: %s\n", err) 72 | switch { 73 | case errors.Is(err, errCouldntGetHomeDir): 74 | fmt.Printf("\n%s\n", reportIssueMsg) 75 | } 76 | return err 77 | } 78 | rootCmd.Version = version 79 | 80 | err = rootCmd.Execute() 81 | switch { 82 | case errors.Is(err, errCouldntSetupGuide): 83 | fmt.Printf("\n%s\n", reportIssueMsg) 84 | } 85 | return err 86 | } 87 | 88 | func setupDB(dbPathFull string) (*sql.DB, error) { 89 | var db *sql.DB 90 | var err error 91 | 92 | _, err = os.Stat(dbPathFull) 93 | if errors.Is(err, fs.ErrNotExist) { 94 | 95 | dir := filepath.Dir(dbPathFull) 96 | err = os.MkdirAll(dir, 0o755) 97 | if err != nil { 98 | return nil, fmt.Errorf("%w: %s", errCouldntCreateDBDirectory, err.Error()) 99 | } 100 | 101 | db, err = getDB(dbPathFull) 102 | if err != nil { 103 | return nil, fmt.Errorf("%w: %s", errCouldntCreateDB, err.Error()) 104 | } 105 | 106 | err = pers.InitDB(db) 107 | if err != nil { 108 | return nil, fmt.Errorf("%w: %s", errCouldntInitializeDB, err.Error()) 109 | } 110 | err = pers.UpgradeDB(db, 1) 111 | if err != nil { 112 | return nil, err 113 | } 114 | } else { 115 | db, err = getDB(dbPathFull) 116 | if err != nil { 117 | return nil, fmt.Errorf("%w: %s", errCouldntOpenDB, err.Error()) 118 | } 119 | err = pers.UpgradeDBIfNeeded(db) 120 | if err != nil { 121 | return nil, err 122 | } 123 | } 124 | 125 | return db, nil 126 | } 127 | 128 | func NewRootCommand() (*cobra.Command, error) { 129 | var ( 130 | configPath string 131 | configPathFull string 132 | dbPath string 133 | dbPathFull string 134 | db *sql.DB 135 | taskListColor string 136 | archivedTaskListColor string 137 | printTasksNum uint8 138 | taskListTitle string 139 | listDensityFlagInp string 140 | editorFlagInp string 141 | editorCmd string 142 | showContextFlagInp bool 143 | confirmBeforeDeletion bool 144 | circularNav bool 145 | ) 146 | 147 | rootCmd := &cobra.Command{ 148 | Use: "omm", 149 | Short: "omm (\"on my mind\") is a keyboard-driven task manager for the command line", 150 | Long: `omm ("on my mind") is a keyboard-driven task manager for the command line. 151 | 152 | It is intended to help you visualize and arrange the tasks you need to finish, 153 | based on the priority you assign them. The higher a task is in omm's list, the 154 | higher priority it takes. 155 | 156 | Tip: Quickly add a task using 'omm "task summary goes here"'. 157 | `, 158 | Args: cobra.MaximumNArgs(1), 159 | SilenceUsage: true, 160 | PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { 161 | if cmd.CalledAs() == "updates" { 162 | return nil 163 | } 164 | 165 | configPathFull = expandTilde(configPath) 166 | 167 | if filepath.Ext(configPathFull) != ".toml" { 168 | return errConfigFileExtIncorrect 169 | } 170 | _, err := os.Stat(configPathFull) 171 | 172 | fl := cmd.Flags() 173 | if fl != nil { 174 | cf := fl.Lookup("config-path") 175 | if cf != nil && cf.Changed && errors.Is(err, fs.ErrNotExist) { 176 | return errConfigFileDoesntExist 177 | } 178 | } 179 | 180 | err = initializeConfig(cmd, configPathFull) 181 | if err != nil { 182 | return err 183 | } 184 | 185 | if cmd.CalledAs() == "guide" { 186 | tempDir := os.TempDir() 187 | timestamp := time.Now().UnixNano() 188 | tempFileName := fmt.Sprintf("omm-%d.db", timestamp) 189 | tempFilePath := filepath.Join(tempDir, tempFileName) 190 | dbPath = tempFilePath 191 | } 192 | 193 | dbPathFull = expandTilde(dbPath) 194 | if filepath.Ext(dbPathFull) != ".db" { 195 | return errDBFileExtIncorrect 196 | } 197 | 198 | db, err = setupDB(dbPathFull) 199 | switch { 200 | case errors.Is(err, errCouldntCreateDB): 201 | fmt.Fprintf(os.Stderr, `Couldn't create omm's local database. This is a fatal error. 202 | %s 203 | 204 | `, reportIssueMsg) 205 | case errors.Is(err, errCouldntInitializeDB): 206 | fmt.Fprintf(os.Stderr, `Couldn't initialise omm's local database. This is a fatal error. 207 | %s 208 | 209 | `, reportIssueMsg) 210 | // cleanup 211 | cleanupErr := os.Remove(dbPathFull) 212 | if cleanupErr != nil { 213 | fmt.Fprintf(os.Stderr, `Failed to remove omm's database file as well (at %s). Remove it manually. 214 | Clean up error: %s 215 | 216 | `, dbPathFull, cleanupErr.Error()) 217 | } 218 | case errors.Is(err, errCouldntOpenDB): 219 | fmt.Fprintf(os.Stderr, `Couldn't open omm's local database. This is a fatal error. 220 | %s 221 | 222 | `, reportIssueMsg) 223 | case errors.Is(err, pers.ErrCouldntFetchDBVersion): 224 | fmt.Fprintf(os.Stderr, `Couldn't get omm's latest database version. This is a fatal error. 225 | %s 226 | 227 | `, reportIssueMsg) 228 | case errors.Is(err, pers.ErrDBDowngraded): 229 | fmt.Fprintf(os.Stderr, `Looks like you downgraded omm. You should either delete omm's database file (you 230 | will lose data by doing that), or upgrade omm to the latest version. 231 | 232 | %s 233 | 234 | `, reportIssueMsg) 235 | case errors.Is(err, pers.ErrDBMigrationFailed): 236 | fmt.Fprintf(os.Stderr, `Something went wrong migrating omm's database. This is not supposed to happen. 237 | You can try running omm by passing it a custom database file path (using 238 | --db-path; this will create a new database) to see if that fixes things. If that 239 | works, you can either delete the previous database, or keep using this new 240 | database (both are not ideal). 241 | 242 | %s 243 | Sorry for breaking the upgrade step! 244 | 245 | --- 246 | 247 | `, reportIssueMsg) 248 | } 249 | 250 | if err != nil { 251 | return err 252 | } 253 | 254 | return nil 255 | }, 256 | RunE: func(cmd *cobra.Command, args []string) error { 257 | if len(args) != 0 { 258 | summaryValid, err := types.CheckIfTaskSummaryValid(args[0]) 259 | if !summaryValid { 260 | return fmt.Errorf("%w", err) 261 | } 262 | 263 | err = importTask(db, args[0]) 264 | if errors.Is(err, errWillExceedCapacity) { 265 | fmt.Fprint(os.Stderr, taskCapacityMsg) 266 | } 267 | 268 | if err != nil { 269 | return err 270 | } 271 | return nil 272 | } 273 | 274 | // config management 275 | if cmd.Flags().Lookup("editor").Changed { 276 | editorCmd = editorFlagInp 277 | } else { 278 | editorCmd = getUserConfiguredEditor(editorFlagInp) 279 | } 280 | 281 | var ld ui.ListDensityType 282 | switch listDensityFlagInp { 283 | case ui.CompactDensityVal: 284 | ld = ui.Compact 285 | case ui.SpaciousDensityVal: 286 | ld = ui.Spacious 287 | default: 288 | return errListDensityIncorrect 289 | } 290 | 291 | if len(taskListTitle) > taskListTitleMaxLen { 292 | taskListTitle = taskListTitle[:taskListTitleMaxLen] 293 | } 294 | 295 | config := ui.Config{ 296 | DBPath: dbPathFull, 297 | ListDensity: ld, 298 | TaskListColor: taskListColor, 299 | ArchivedTaskListColor: archivedTaskListColor, 300 | TaskListTitle: taskListTitle, 301 | TextEditorCmd: strings.Fields(editorCmd), 302 | ShowContext: showContextFlagInp, 303 | ConfirmBeforeDeletion: confirmBeforeDeletion, 304 | CircularNav: circularNav, 305 | } 306 | 307 | ui.RenderUI(db, config) 308 | 309 | return nil 310 | }, 311 | } 312 | rootCmd.SetVersionTemplate(`{{with .Name}}{{printf "%s " .}}{{end}}{{printf "%s" .Version}} 313 | `) 314 | 315 | importCmd := &cobra.Command{ 316 | Use: "import", 317 | Short: "Import tasks into omm from stdin", 318 | RunE: func(_ *cobra.Command, _ []string) error { 319 | var tasks []string 320 | taskCounter := 0 321 | 322 | scanner := bufio.NewScanner(os.Stdin) 323 | for scanner.Scan() { 324 | 325 | line := scanner.Text() 326 | line = strings.TrimSpace(line) 327 | 328 | summaryValid, _ := types.CheckIfTaskSummaryValid(line) 329 | 330 | if summaryValid { 331 | tasks = append(tasks, line) 332 | } 333 | taskCounter++ 334 | if taskCounter > pers.TaskNumLimit { 335 | fmt.Fprint(os.Stderr, maxImportNumMsg) 336 | return fmt.Errorf("%w", errMaxImportLimitExceeded) 337 | } 338 | } 339 | 340 | if len(tasks) == 0 { 341 | return errNothingToImport 342 | } 343 | 344 | err := importTasks(db, tasks) 345 | if errors.Is(err, errWillExceedCapacity) { 346 | fmt.Fprint(os.Stderr, taskCapacityMsg) 347 | } 348 | if err != nil { 349 | return err 350 | } 351 | 352 | return nil 353 | }, 354 | } 355 | 356 | tasksCmd := &cobra.Command{ 357 | Use: "tasks", 358 | Short: "Output tasks tracked by omm to stdout", 359 | RunE: func(_ *cobra.Command, _ []string) error { 360 | return printTasks(db, printTasksNum, os.Stdout) 361 | }, 362 | } 363 | 364 | guideCmd := &cobra.Command{ 365 | Use: "guide", 366 | Short: "Starts a guided walkthrough of omm's features", 367 | PreRunE: func(_ *cobra.Command, _ []string) error { 368 | guideErr := insertGuideTasks(db) 369 | if guideErr != nil { 370 | return fmt.Errorf("%w: %s", errCouldntSetupGuide, guideErr.Error()) 371 | } 372 | 373 | return nil 374 | }, 375 | RunE: func(cmd *cobra.Command, _ []string) error { 376 | if cmd.Flags().Lookup("editor").Changed { 377 | editorCmd = editorFlagInp 378 | } else { 379 | editorCmd = getUserConfiguredEditor(editorFlagInp) 380 | } 381 | config := ui.Config{ 382 | DBPath: dbPathFull, 383 | ListDensity: ui.Compact, 384 | TaskListColor: taskListColor, 385 | ArchivedTaskListColor: archivedTaskListColor, 386 | TaskListTitle: "omm guide", 387 | TextEditorCmd: strings.Fields(editorCmd), 388 | ShowContext: true, 389 | Guide: true, 390 | ConfirmBeforeDeletion: true, 391 | } 392 | 393 | ui.RenderUI(db, config) 394 | 395 | return nil 396 | }, 397 | } 398 | 399 | updatesCmd := &cobra.Command{ 400 | Use: "updates", 401 | Short: "List updates recently added to omm", 402 | Run: func(_ *cobra.Command, _ []string) { 403 | fmt.Print(updateContents) 404 | }, 405 | } 406 | 407 | ros := runtime.GOOS 408 | var defaultConfigPath, defaultDBPath string 409 | var configPathAdditionalCxt, dbPathAdditionalCxt string 410 | hd, err := os.UserHomeDir() 411 | if err != nil { 412 | return nil, fmt.Errorf("%w: %s", errCouldntGetHomeDir, err.Error()) 413 | } 414 | 415 | switch ros { 416 | case "linux": 417 | xdgDataHome := os.Getenv("XDG_DATA_HOME") 418 | xdgConfigHome := os.Getenv("XDG_CONFIG_HOME") 419 | if xdgConfigHome != "" { 420 | defaultConfigPath = filepath.Join(xdgConfigHome, configFileName) 421 | } else { 422 | defaultConfigPath = filepath.Join(hd, defaultConfigDir, configFileName) 423 | } 424 | if xdgDataHome != "" { 425 | defaultDBPath = filepath.Join(xdgDataHome, dbFileName) 426 | } else { 427 | defaultDBPath = filepath.Join(hd, defaultDataDir, dbFileName) 428 | } 429 | configPathAdditionalCxt = "; will use $XDG_CONFIG_HOME by default, if set" 430 | dbPathAdditionalCxt = "; will use $XDG_DATA_HOME by default, if set" 431 | case "windows": 432 | defaultConfigPath = filepath.Join(hd, defaultConfigDirWindows, configFileName) 433 | defaultDBPath = filepath.Join(hd, defaultDataDirWindows, dbFileName) 434 | default: 435 | defaultConfigPath = filepath.Join(hd, defaultConfigDir, configFileName) 436 | defaultDBPath = filepath.Join(hd, defaultDataDir, dbFileName) 437 | } 438 | 439 | rootCmd.Flags().StringVarP(&configPath, "config-path", "c", defaultConfigPath, fmt.Sprintf("location of omm's TOML config file%s", configPathAdditionalCxt)) 440 | rootCmd.Flags().StringVarP(&dbPath, "db-path", "d", defaultDBPath, fmt.Sprintf("location of omm's database file%s", dbPathAdditionalCxt)) 441 | rootCmd.Flags().StringVar(&taskListColor, "tl-color", ui.TaskListColor, "hex color used for the task list") 442 | rootCmd.Flags().StringVar(&archivedTaskListColor, "atl-color", ui.ArchivedTLColor, "hex color used for the archived tasks list") 443 | rootCmd.Flags().StringVar(&taskListTitle, "title", ui.TaskListDefaultTitle, fmt.Sprintf("title of the task list, will trim till %d chars", taskListTitleMaxLen)) 444 | rootCmd.Flags().StringVar(&listDensityFlagInp, "list-density", ui.CompactDensityVal, fmt.Sprintf("type of density for the list; possible values: [%s, %s]", ui.CompactDensityVal, ui.SpaciousDensityVal)) 445 | rootCmd.Flags().StringVar(&editorFlagInp, "editor", "vi", "editor command to run when adding/editing context to a task") 446 | rootCmd.Flags().BoolVar(&showContextFlagInp, "show-context", false, "whether to start omm with a visible task context pane or not; this can later be toggled on/off in the TUI") 447 | rootCmd.Flags().BoolVar(&confirmBeforeDeletion, "confirm-before-deletion", true, "whether to ask for confirmation before deleting a task") 448 | rootCmd.Flags().BoolVar(&circularNav, "circular-nav", false, "whether to enable circular navigation for lists (cycle back to the first entry from the last, and vice versa)") 449 | 450 | tasksCmd.Flags().Uint8VarP(&printTasksNum, "num", "n", printTasksDefault, "number of tasks to print") 451 | tasksCmd.Flags().StringVarP(&configPath, "config-path", "c", defaultConfigPath, fmt.Sprintf("location of omm's TOML config file%s", configPathAdditionalCxt)) 452 | tasksCmd.Flags().StringVarP(&dbPath, "db-path", "d", defaultDBPath, fmt.Sprintf("location of omm's database file%s", dbPathAdditionalCxt)) 453 | 454 | importCmd.Flags().StringVarP(&configPath, "config-path", "c", defaultConfigPath, fmt.Sprintf("location of omm's TOML config file%s", configPathAdditionalCxt)) 455 | importCmd.Flags().StringVarP(&dbPath, "db-path", "d", defaultDBPath, fmt.Sprintf("location of omm's database file%s", dbPathAdditionalCxt)) 456 | 457 | guideCmd.Flags().StringVar(&editorFlagInp, "editor", "vi", "editor command to run when adding/editing context to a task") 458 | 459 | rootCmd.AddCommand(importCmd) 460 | rootCmd.AddCommand(tasksCmd) 461 | rootCmd.AddCommand(guideCmd) 462 | rootCmd.AddCommand(updatesCmd) 463 | 464 | rootCmd.CompletionOptions.DisableDefaultCmd = true 465 | 466 | return rootCmd, nil 467 | } 468 | 469 | func initializeConfig(cmd *cobra.Command, configFile string) error { 470 | v := viper.New() 471 | 472 | v.SetConfigName(filepath.Base(configFile)) 473 | v.SetConfigType("toml") 474 | v.AddConfigPath(filepath.Dir(configFile)) 475 | 476 | err := v.ReadInConfig() 477 | if err != nil && !errors.As(err, &viper.ConfigFileNotFoundError{}) { 478 | return err 479 | } 480 | 481 | v.SetEnvPrefix(envPrefix) 482 | v.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) 483 | v.AutomaticEnv() 484 | 485 | err = bindFlags(cmd, v) 486 | if err != nil { 487 | return err 488 | } 489 | 490 | return nil 491 | } 492 | 493 | func bindFlags(cmd *cobra.Command, v *viper.Viper) error { 494 | var err error 495 | cmd.Flags().VisitAll(func(f *pflag.Flag) { 496 | configName := strings.ReplaceAll(f.Name, "-", "_") 497 | 498 | if !f.Changed && v.IsSet(configName) { 499 | val := v.Get(configName) 500 | fErr := cmd.Flags().Set(f.Name, fmt.Sprintf("%v", val)) 501 | if fErr != nil { 502 | err = fErr 503 | return 504 | } 505 | } 506 | }) 507 | return err 508 | } 509 | -------------------------------------------------------------------------------- /cmd/tasks.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "io" 7 | 8 | pers "github.com/dhth/omm/internal/persistence" 9 | ) 10 | 11 | func printTasks(db *sql.DB, limit uint8, writer io.Writer) error { 12 | tasks, err := pers.FetchActiveTasks(db, int(limit)) 13 | if err != nil { 14 | return err 15 | } 16 | for _, task := range tasks { 17 | fmt.Fprintf(writer, "%s\n", task.Summary) 18 | } 19 | return nil 20 | } 21 | -------------------------------------------------------------------------------- /cmd/utils.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | "os/user" 6 | "strings" 7 | ) 8 | 9 | func expandTilde(path string) string { 10 | if strings.HasPrefix(path, "~") { 11 | usr, err := user.Current() 12 | if err != nil { 13 | os.Exit(1) 14 | } 15 | return strings.Replace(path, "~", usr.HomeDir, 1) 16 | } 17 | return path 18 | } 19 | 20 | func getUserConfiguredEditor(defaultVal string) string { 21 | editor := os.Getenv("OMM_EDITOR") 22 | if editor != "" { 23 | return editor 24 | } 25 | 26 | editor = os.Getenv("EDITOR") 27 | if editor != "" { 28 | return editor 29 | } 30 | 31 | editor = os.Getenv("VISUAL") 32 | if editor != "" { 33 | return editor 34 | } 35 | 36 | return defaultVal 37 | } 38 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # to test operations on linux 2 | services: 3 | omm-dev: 4 | image: golang:1.24.2-alpine 5 | volumes: 6 | - .:/go/src/app 7 | working_dir: /go/src/app 8 | command: sleep infinity 9 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/dhth/omm 2 | 3 | go 1.24.2 4 | 5 | require ( 6 | github.com/atotto/clipboard v0.1.4 7 | github.com/charmbracelet/bubbles v0.21.0 8 | github.com/charmbracelet/bubbletea v1.3.5 9 | github.com/charmbracelet/glamour v0.10.0 10 | github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 11 | github.com/dustin/go-humanize v1.0.1 12 | github.com/muesli/termenv v0.16.0 13 | github.com/spf13/cobra v1.9.1 14 | github.com/spf13/pflag v1.0.6 15 | github.com/spf13/viper v1.20.1 16 | github.com/stretchr/testify v1.10.0 17 | modernc.org/sqlite v1.37.1 18 | mvdan.cc/xurls/v2 v2.6.0 19 | ) 20 | 21 | require ( 22 | github.com/alecthomas/chroma/v2 v2.17.2 // indirect 23 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 24 | github.com/aymerick/douceur v0.2.0 // indirect 25 | github.com/charmbracelet/colorprofile v0.3.1 // indirect 26 | github.com/charmbracelet/x/ansi v0.9.2 // indirect 27 | github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 28 | github.com/charmbracelet/x/exp/slice v0.0.0-20250501183327-ad3bc78c6a81 // indirect 29 | github.com/charmbracelet/x/term v0.2.1 // indirect 30 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 31 | github.com/dlclark/regexp2 v1.11.5 // indirect 32 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 33 | github.com/fsnotify/fsnotify v1.9.0 // indirect 34 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 35 | github.com/google/uuid v1.6.0 // indirect 36 | github.com/gorilla/css v1.0.1 // indirect 37 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 38 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 39 | github.com/mattn/go-isatty v0.0.20 // indirect 40 | github.com/mattn/go-localereader v0.0.1 // indirect 41 | github.com/mattn/go-runewidth v0.0.16 // indirect 42 | github.com/microcosm-cc/bluemonday v1.0.27 // indirect 43 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 44 | github.com/muesli/cancelreader v0.2.2 // indirect 45 | github.com/muesli/reflow v0.3.0 // indirect 46 | github.com/ncruces/go-strftime v0.1.9 // indirect 47 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 48 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 49 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 50 | github.com/rivo/uniseg v0.4.7 // indirect 51 | github.com/sagikazarmark/locafero v0.9.0 // indirect 52 | github.com/sahilm/fuzzy v0.1.1 // indirect 53 | github.com/sourcegraph/conc v0.3.0 // indirect 54 | github.com/spf13/afero v1.14.0 // indirect 55 | github.com/spf13/cast v1.8.0 // indirect 56 | github.com/subosito/gotenv v1.6.0 // indirect 57 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 58 | github.com/yuin/goldmark v1.7.11 // indirect 59 | github.com/yuin/goldmark-emoji v1.0.6 // indirect 60 | go.uber.org/multierr v1.11.0 // indirect 61 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect 62 | golang.org/x/net v0.39.0 // indirect 63 | golang.org/x/sync v0.14.0 // indirect 64 | golang.org/x/sys v0.33.0 // indirect 65 | golang.org/x/term v0.31.0 // indirect 66 | golang.org/x/text v0.24.0 // indirect 67 | gopkg.in/yaml.v3 v3.0.1 // indirect 68 | modernc.org/libc v1.65.7 // indirect 69 | modernc.org/mathutil v1.7.1 // indirect 70 | modernc.org/memory v1.11.0 // indirect 71 | ) 72 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 2 | github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 3 | github.com/alecthomas/chroma/v2 v2.17.2 h1:Rm81SCZ2mPoH+Q8ZCc/9YvzPUN/E7HgPiPJD8SLV6GI= 4 | github.com/alecthomas/chroma/v2 v2.17.2/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk= 5 | github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= 6 | github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 7 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 8 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 9 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 10 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 11 | github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= 12 | github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 13 | github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 14 | github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 15 | github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= 16 | github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 17 | github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= 18 | github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54= 19 | github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= 20 | github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= 21 | github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= 22 | github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= 23 | github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= 24 | github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= 25 | github.com/charmbracelet/x/ansi v0.9.2 h1:92AGsQmNTRMzuzHEYfCdjQeUzTrgE1vfO5/7fEVoXdY= 26 | github.com/charmbracelet/x/ansi v0.9.2/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 27 | github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= 28 | github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 29 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= 30 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 31 | github.com/charmbracelet/x/exp/slice v0.0.0-20250501183327-ad3bc78c6a81 h1:WcoOeReajHsx60hzixuNsv+QsCnibNmaALJB7G1qfbg= 32 | github.com/charmbracelet/x/exp/slice v0.0.0-20250501183327-ad3bc78c6a81/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms= 33 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 34 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 35 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 36 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 37 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 38 | github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= 39 | github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 40 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 41 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 42 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 43 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 44 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 45 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 46 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 47 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 48 | github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= 49 | github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 50 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 51 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 52 | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= 53 | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 54 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 55 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 56 | github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 57 | github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 58 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 59 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 60 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 61 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 62 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 63 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 64 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 65 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 66 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 67 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 68 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 69 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 70 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 71 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 72 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 73 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 74 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 75 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 76 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 77 | github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= 78 | github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 79 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 80 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 81 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 82 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 83 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 84 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 85 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 86 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 87 | github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= 88 | github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 89 | github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= 90 | github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 91 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 92 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 93 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 94 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 95 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 96 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 97 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 98 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 99 | github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a h1:w3tdWGKbLGBPtR/8/oO74W6hmz0qE5q0z9aqSAewaaM= 100 | github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a/go.mod h1:S8kfXMp+yh77OxPD4fdM6YUknrZpQxLhvxzS4gDHENY= 101 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 102 | github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k= 103 | github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk= 104 | github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= 105 | github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 106 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 107 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 108 | github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= 109 | github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= 110 | github.com/spf13/cast v1.8.0 h1:gEN9K4b8Xws4EX0+a0reLmhq8moKn7ntRlQYgjPeCDk= 111 | github.com/spf13/cast v1.8.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 112 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 113 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 114 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 115 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 116 | github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= 117 | github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= 118 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 119 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 120 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 121 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 122 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 123 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 124 | github.com/yuin/goldmark v1.7.11 h1:ZCxLyDMtz0nT2HFfsYG8WZ47Trip2+JyLysKcMYE5bo= 125 | github.com/yuin/goldmark v1.7.11/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 126 | github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= 127 | github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= 128 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 129 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 130 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= 131 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= 132 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 133 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 134 | golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 135 | golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 136 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 137 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 138 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 139 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 140 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 141 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 142 | golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= 143 | golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= 144 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 145 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 146 | golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= 147 | golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= 148 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 149 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 150 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 151 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 152 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 153 | modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= 154 | modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= 155 | modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= 156 | modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= 157 | modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8= 158 | modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= 159 | modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= 160 | modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= 161 | modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00= 162 | modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= 163 | modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= 164 | modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 165 | modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= 166 | modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= 167 | modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= 168 | modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= 169 | modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= 170 | modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= 171 | modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs= 172 | modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g= 173 | modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= 174 | modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= 175 | modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= 176 | modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= 177 | mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI= 178 | mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk= 179 | -------------------------------------------------------------------------------- /internal/persistence/init.go: -------------------------------------------------------------------------------- 1 | package persistence 2 | 3 | import ( 4 | "database/sql" 5 | "time" 6 | ) 7 | 8 | func InitDB(db *sql.DB) error { 9 | // these init queries cannot be changed once omm is released; only further 10 | // migrations can be added, which are run when omm sees a difference between 11 | // the values in the db_versions table and latestDBVersion 12 | _, err := db.Exec(` 13 | CREATE TABLE IF NOT EXISTS db_versions ( 14 | id INTEGER PRIMARY KEY AUTOINCREMENT, 15 | version INTEGER NOT NULL, 16 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 17 | ); 18 | 19 | CREATE TABLE IF NOT EXISTS task ( 20 | id INTEGER PRIMARY KEY AUTOINCREMENT, 21 | summary TEXT NOT NULL, 22 | active BOOLEAN NOT NULL DEFAULT true, 23 | created_at TIMESTAMP NOT NULL, 24 | updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 25 | ); 26 | 27 | CREATE TABLE task_sequence ( 28 | id INTEGER PRIMARY KEY, 29 | sequence JSON NOT NULL 30 | ); 31 | 32 | INSERT INTO task_sequence (id, sequence) VALUES (1, '[]'); 33 | 34 | INSERT INTO db_versions (version, created_at) 35 | VALUES (1, ?); 36 | `, time.Now().UTC()) 37 | 38 | return err 39 | } 40 | -------------------------------------------------------------------------------- /internal/persistence/migrations.go: -------------------------------------------------------------------------------- 1 | package persistence 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "fmt" 7 | "time" 8 | ) 9 | 10 | const ( 11 | latestDBVersion = 2 // only upgrade this after adding a migration in getMigrations 12 | ) 13 | 14 | var ( 15 | ErrDBDowngraded = errors.New("database downgraded") 16 | ErrDBMigrationFailed = errors.New("database migration failed") 17 | ErrCouldntFetchDBVersion = errors.New("couldn't fetch version") 18 | ) 19 | 20 | type dbVersionInfo struct { 21 | id int 22 | version int 23 | createdAt time.Time 24 | } 25 | 26 | func getMigrations() map[int]string { 27 | migrations := make(map[int]string) 28 | // these migrations should not be modified once released. 29 | // that is, migrations is an append-only map. 30 | 31 | migrations[2] = ` 32 | ALTER TABLE task 33 | ADD COLUMN context TEXT; 34 | ` 35 | 36 | return migrations 37 | } 38 | 39 | func fetchLatestDBVersion(db *sql.DB) (dbVersionInfo, error) { 40 | row := db.QueryRow(` 41 | SELECT id, version, created_at 42 | FROM db_versions 43 | ORDER BY created_at DESC 44 | LIMIT 1; 45 | `) 46 | 47 | var dbVersion dbVersionInfo 48 | err := row.Scan( 49 | &dbVersion.id, 50 | &dbVersion.version, 51 | &dbVersion.createdAt, 52 | ) 53 | 54 | return dbVersion, err 55 | } 56 | 57 | func UpgradeDBIfNeeded(db *sql.DB) error { 58 | latestVersionInDB, err := fetchLatestDBVersion(db) 59 | if err != nil { 60 | return fmt.Errorf("%w: %s", ErrCouldntFetchDBVersion, err.Error()) 61 | } 62 | 63 | if latestVersionInDB.version > latestDBVersion { 64 | return ErrDBDowngraded 65 | } 66 | 67 | if latestVersionInDB.version < latestDBVersion { 68 | err = UpgradeDB(db, latestVersionInDB.version) 69 | if err != nil { 70 | return err 71 | } 72 | } 73 | 74 | return nil 75 | } 76 | 77 | func UpgradeDB(db *sql.DB, currentVersion int) error { 78 | migrations := getMigrations() 79 | for i := currentVersion + 1; i <= latestDBVersion; i++ { 80 | migrateQuery := migrations[i] 81 | migrateErr := runMigration(db, migrateQuery, i) 82 | if migrateErr != nil { 83 | return fmt.Errorf("%w (version %d): %v", ErrDBMigrationFailed, i, migrateErr.Error()) 84 | } 85 | } 86 | return nil 87 | } 88 | 89 | func runMigration(db *sql.DB, migrateQuery string, version int) error { 90 | tx, err := db.Begin() 91 | if err != nil { 92 | return err 93 | } 94 | defer func() { 95 | _ = tx.Rollback() 96 | }() 97 | 98 | stmt, err := tx.Prepare(migrateQuery) 99 | if err != nil { 100 | return err 101 | } 102 | defer stmt.Close() 103 | 104 | _, err = stmt.Exec() 105 | if err != nil { 106 | return err 107 | } 108 | 109 | tStmt, err := tx.Prepare(` 110 | INSERT INTO db_versions (version, created_at) 111 | VALUES (?, ?); 112 | `) 113 | if err != nil { 114 | return err 115 | } 116 | defer tStmt.Close() 117 | 118 | _, err = tStmt.Exec(version, time.Now().UTC()) 119 | if err != nil { 120 | return err 121 | } 122 | 123 | err = tx.Commit() 124 | if err != nil { 125 | return err 126 | } 127 | 128 | return nil 129 | } 130 | -------------------------------------------------------------------------------- /internal/persistence/migrations_test.go: -------------------------------------------------------------------------------- 1 | package persistence 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestMigrationsAreSetupCorrectly(t *testing.T) { 8 | migrations := getMigrations() 9 | for i := 2; i <= latestDBVersion; i++ { 10 | m, ok := migrations[i] 11 | if !ok { 12 | t.Errorf("couldn't get migration %d", i) 13 | } 14 | if m == "" { 15 | t.Errorf("migration %d is empty", i) 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /internal/persistence/queries.go: -------------------------------------------------------------------------------- 1 | package persistence 2 | 3 | import ( 4 | "database/sql" 5 | "encoding/json" 6 | "time" 7 | 8 | "github.com/dhth/omm/internal/types" 9 | ) 10 | 11 | const ( 12 | TaskNumLimit = 300 13 | ContextMaxBytes = 1024 * 1024 14 | ) 15 | 16 | func fetchTaskSequence(db *sql.DB) ([]uint64, error) { 17 | var seq []byte 18 | seqRow := db.QueryRow("SELECT sequence from task_sequence where id=1;") 19 | 20 | err := seqRow.Scan(&seq) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | var seqItems []uint64 26 | err = json.Unmarshal(seq, &seqItems) 27 | if err != nil { 28 | return nil, err 29 | } 30 | return seqItems, nil 31 | } 32 | 33 | func fetchNumActiveTasks(db *sql.DB) (int, error) { 34 | var rowCount int 35 | err := db.QueryRow("SELECT count(*) from task where active is true").Scan(&rowCount) 36 | return rowCount, err 37 | } 38 | 39 | func fetchNumTotalTasks(db *sql.DB) (int, error) { 40 | var rowCount int 41 | err := db.QueryRow("SELECT count(*) from task").Scan(&rowCount) 42 | return rowCount, err 43 | } 44 | 45 | func fetchTaskByID(db *sql.DB, ID int64) (types.Task, error) { 46 | var entry types.Task 47 | row := db.QueryRow(` 48 | SELECT id, summary, active, context, created_at, updated_at 49 | from task 50 | WHERE id=?; 51 | `, ID) 52 | err := row.Scan(&entry.ID, 53 | &entry.Summary, 54 | &entry.Active, 55 | &entry.Context, 56 | &entry.CreatedAt, 57 | &entry.UpdatedAt, 58 | ) 59 | return entry, err 60 | } 61 | 62 | func FetchNumActiveTasksShown(db *sql.DB) (int, error) { 63 | row := db.QueryRow(` 64 | SELECT json_array_length(sequence) AS num_tasks 65 | FROM task_sequence where id=1; 66 | `) 67 | 68 | var numTasks int 69 | err := row.Scan(&numTasks) 70 | if err != nil { 71 | return -1, err 72 | } 73 | 74 | return numTasks, nil 75 | } 76 | 77 | func UpdateTaskSequence(db *sql.DB, sequence []uint64) error { 78 | sequenceJSON, err := json.Marshal(sequence) 79 | if err != nil { 80 | return err 81 | } 82 | 83 | stmt, err := db.Prepare(` 84 | UPDATE task_sequence 85 | SET sequence = ? 86 | WHERE id = 1; 87 | `) 88 | if err != nil { 89 | return err 90 | } 91 | defer stmt.Close() 92 | 93 | _, err = stmt.Exec(sequenceJSON) 94 | if err != nil { 95 | return err 96 | } 97 | 98 | return nil 99 | } 100 | 101 | func InsertTask(db *sql.DB, summary string, createdAt, updatedAt time.Time) (uint64, error) { 102 | stmt, err := db.Prepare(` 103 | INSERT INTO task (summary, active, created_at, updated_at) 104 | VALUES (?, true, ?, ?); 105 | `) 106 | if err != nil { 107 | return 0, err 108 | } 109 | defer stmt.Close() 110 | 111 | res, err := stmt.Exec(summary, createdAt.UTC(), updatedAt.UTC()) 112 | if err != nil { 113 | return 0, err 114 | } 115 | 116 | li, err := res.LastInsertId() 117 | if err != nil { 118 | return 0, err 119 | } 120 | 121 | return uint64(li), nil 122 | } 123 | 124 | func InsertTasks(db *sql.DB, tasks []types.Task, insertAtTop bool) (int64, error) { 125 | tx, err := db.Begin() 126 | if err != nil { 127 | return -1, err 128 | } 129 | defer func() { 130 | _ = tx.Rollback() 131 | }() 132 | 133 | query := `INSERT INTO task (summary, context, active, created_at, updated_at) 134 | VALUES ` 135 | 136 | values := make([]interface{}, 0, len(tasks)*4) 137 | 138 | for i, t := range tasks { 139 | if i > 0 { 140 | query += "," 141 | } 142 | query += "(?, ?, ?, ?, ?)" 143 | values = append(values, t.Summary, t.Context, t.Active, t.CreatedAt.UTC(), t.UpdatedAt.UTC()) 144 | } 145 | 146 | query += ";" 147 | 148 | res, err := tx.Exec(query, values...) 149 | if err != nil { 150 | return -1, err 151 | } 152 | 153 | lastInsertID, err := res.LastInsertId() 154 | if err != nil { 155 | return -1, err 156 | } 157 | 158 | var seq []byte 159 | seqRow := tx.QueryRow("SELECT sequence from task_sequence where id=1;") 160 | 161 | err = seqRow.Scan(&seq) 162 | if err != nil { 163 | return -1, err 164 | } 165 | 166 | var seqItems []int 167 | err = json.Unmarshal(seq, &seqItems) 168 | if err != nil { 169 | return -1, err 170 | } 171 | 172 | var newTaskIDs []int 173 | taskID := int(lastInsertID) - len(tasks) + 1 174 | for _, t := range tasks { 175 | if t.Active { 176 | newTaskIDs = append(newTaskIDs, taskID) 177 | } 178 | taskID++ 179 | } 180 | 181 | var updatedSeqItems []int 182 | if insertAtTop { 183 | updatedSeqItems = append(newTaskIDs, seqItems...) 184 | } else { 185 | updatedSeqItems = append(seqItems, newTaskIDs...) 186 | } 187 | 188 | sequenceJSON, err := json.Marshal(updatedSeqItems) 189 | if err != nil { 190 | return -1, err 191 | } 192 | 193 | seqUpdateStmt, err := tx.Prepare(` 194 | UPDATE task_sequence 195 | SET sequence = ? 196 | WHERE id = 1; 197 | `) 198 | if err != nil { 199 | return -1, err 200 | } 201 | defer seqUpdateStmt.Close() 202 | 203 | _, err = seqUpdateStmt.Exec(sequenceJSON) 204 | if err != nil { 205 | return -1, err 206 | } 207 | 208 | err = tx.Commit() 209 | if err != nil { 210 | return -1, err 211 | } 212 | return lastInsertID, nil 213 | } 214 | 215 | func UpdateTaskSummary(db *sql.DB, id uint64, summary string, updatedAt time.Time) error { 216 | stmt, err := db.Prepare(` 217 | UPDATE task 218 | SET summary = ?, 219 | updated_at = ? 220 | WHERE id = ? 221 | `) 222 | if err != nil { 223 | return err 224 | } 225 | defer stmt.Close() 226 | 227 | _, err = stmt.Exec(summary, updatedAt.UTC(), id) 228 | if err != nil { 229 | return err 230 | } 231 | return nil 232 | } 233 | 234 | func UpdateTaskContext(db *sql.DB, id uint64, context string, updatedAt time.Time) error { 235 | stmt, err := db.Prepare(` 236 | UPDATE task 237 | SET context = ?, 238 | updated_at = ? 239 | WHERE id = ? 240 | `) 241 | if err != nil { 242 | return err 243 | } 244 | defer stmt.Close() 245 | 246 | _, err = stmt.Exec(context, updatedAt.UTC(), id) 247 | if err != nil { 248 | return err 249 | } 250 | return nil 251 | } 252 | 253 | func UnsetTaskContext(db *sql.DB, id uint64, updatedAt time.Time) error { 254 | stmt, err := db.Prepare(` 255 | UPDATE task 256 | SET context = NULL, 257 | updated_at = ? 258 | WHERE id = ? 259 | `) 260 | if err != nil { 261 | return err 262 | } 263 | defer stmt.Close() 264 | 265 | _, err = stmt.Exec(updatedAt.UTC(), id) 266 | if err != nil { 267 | return err 268 | } 269 | return nil 270 | } 271 | 272 | func ChangeTaskStatus(db *sql.DB, id uint64, active bool, updatedAt time.Time) error { 273 | stmt, err := db.Prepare(` 274 | UPDATE task 275 | SET active = ?, 276 | updated_at = ? 277 | WHERE id = ? 278 | `) 279 | if err != nil { 280 | return err 281 | } 282 | defer stmt.Close() 283 | 284 | _, err = stmt.Exec(active, updatedAt.UTC(), id) 285 | if err != nil { 286 | return err 287 | } 288 | return nil 289 | } 290 | 291 | func FetchActiveTasks(db *sql.DB, limit int) ([]types.Task, error) { 292 | var tasks []types.Task 293 | 294 | rows, err := db.Query(` 295 | SELECT t.id, t.summary, t.context, t.created_at, t.updated_at 296 | FROM task_sequence s 297 | JOIN json_each(s.sequence) j ON CAST(j.value AS INTEGER) = t.id 298 | JOIN task t ON t.id = j.value 299 | ORDER BY j.key 300 | LIMIT ?; 301 | `, limit) 302 | if err != nil { 303 | return nil, err 304 | } 305 | defer rows.Close() 306 | 307 | for rows.Next() { 308 | var entry types.Task 309 | err = rows.Scan(&entry.ID, 310 | &entry.Summary, 311 | &entry.Context, 312 | &entry.CreatedAt, 313 | &entry.UpdatedAt, 314 | ) 315 | if err != nil { 316 | return nil, err 317 | } 318 | entry.CreatedAt = entry.CreatedAt.Local() 319 | entry.UpdatedAt = entry.UpdatedAt.Local() 320 | entry.Active = true 321 | tasks = append(tasks, entry) 322 | 323 | } 324 | err = rows.Err() 325 | if err != nil { 326 | return nil, err 327 | } 328 | 329 | return tasks, nil 330 | } 331 | 332 | func FetchInActiveTasks(db *sql.DB, limit int) ([]types.Task, error) { 333 | var tasks []types.Task 334 | 335 | rows, err := db.Query(` 336 | SELECT id, summary, context, created_at, updated_at 337 | FROM task where active is false 338 | ORDER BY updated_at DESC 339 | LIMIT ?; 340 | `, limit) 341 | if err != nil { 342 | return nil, err 343 | } 344 | defer rows.Close() 345 | 346 | for rows.Next() { 347 | var entry types.Task 348 | err = rows.Scan(&entry.ID, 349 | &entry.Summary, 350 | &entry.Context, 351 | &entry.CreatedAt, 352 | &entry.UpdatedAt, 353 | ) 354 | if err != nil { 355 | return nil, err 356 | } 357 | entry.CreatedAt = entry.CreatedAt.Local() 358 | entry.UpdatedAt = entry.UpdatedAt.Local() 359 | entry.Active = false 360 | tasks = append(tasks, entry) 361 | 362 | } 363 | err = rows.Err() 364 | if err != nil { 365 | return nil, err 366 | } 367 | 368 | return tasks, nil 369 | } 370 | 371 | func DeleteTask(db *sql.DB, id uint64) error { 372 | stmt, err := db.Prepare(` 373 | DELETE from task 374 | WHERE id=?; 375 | `) 376 | if err != nil { 377 | return err 378 | } 379 | defer stmt.Close() 380 | 381 | _, err = stmt.Exec(id) 382 | if err != nil { 383 | return err 384 | } 385 | return nil 386 | } 387 | -------------------------------------------------------------------------------- /internal/persistence/queries_test.go: -------------------------------------------------------------------------------- 1 | package persistence 2 | 3 | import ( 4 | "database/sql" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | "testing" 9 | "time" 10 | 11 | "github.com/dhth/omm/internal/types" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | _ "modernc.org/sqlite" // sqlite driver 15 | ) 16 | 17 | var testDB *sql.DB 18 | 19 | func TestMain(m *testing.M) { 20 | var err error 21 | testDB, err = sql.Open("sqlite", ":memory:") 22 | if err != nil { 23 | panic(err) 24 | } 25 | 26 | err = InitDB(testDB) 27 | if err != nil { 28 | panic(err) 29 | } 30 | err = UpgradeDB(testDB, 1) 31 | if err != nil { 32 | panic(err) 33 | } 34 | code := m.Run() 35 | 36 | testDB.Close() 37 | 38 | os.Exit(code) 39 | } 40 | 41 | func cleanupDB(t *testing.T) { 42 | var err error 43 | for _, tbl := range []string{"task"} { 44 | _, err = testDB.Exec(fmt.Sprintf("DELETE FROM %s", tbl)) 45 | if err != nil { 46 | t.Fatalf("failed to clean up table %q: %v", tbl, err) 47 | } 48 | _, err := testDB.Exec("DELETE FROM sqlite_sequence WHERE name=?;", tbl) 49 | if err != nil { 50 | t.Fatalf("failed to reset auto increment for table %q: %v", tbl, err) 51 | } 52 | } 53 | _, err = testDB.Exec(`UPDATE task_sequence 54 | SET sequence = '[]' 55 | WHERE id = 1;`) 56 | if err != nil { 57 | t.Fatalf("failed to clean up table task_sequence: %v", err) 58 | } 59 | } 60 | 61 | func getSampleTasks() ([]types.Task, int, int) { 62 | numActive := 3 63 | numInactive := 2 64 | 65 | tasks := make([]types.Task, numActive+numInactive) 66 | contexts := make([]string, numActive+numInactive) 67 | now := time.Now().UTC() 68 | counter := 0 69 | for range numActive { 70 | contexts[counter] = fmt.Sprintf("context for task %d", counter) 71 | tasks[counter] = types.Task{ 72 | Summary: fmt.Sprintf("prefix: task %d", counter), 73 | Active: true, 74 | Context: &contexts[counter], 75 | CreatedAt: now, 76 | UpdatedAt: now, 77 | } 78 | counter++ 79 | } 80 | for range numInactive { 81 | contexts[counter] = fmt.Sprintf("context for task %d", counter) 82 | tasks[counter] = types.Task{ 83 | Summary: fmt.Sprintf("prefix: task %d", counter), 84 | Active: false, 85 | Context: &contexts[counter], 86 | CreatedAt: now, 87 | UpdatedAt: now, 88 | } 89 | counter++ 90 | } 91 | 92 | return tasks, numActive, numInactive 93 | } 94 | 95 | func seedDB(t *testing.T, db *sql.DB) (int, int) { 96 | t.Helper() 97 | 98 | tasks, na, ni := getSampleTasks() 99 | 100 | for _, task := range tasks { 101 | _, err := db.Exec(` 102 | INSERT INTO task (summary, active, created_at, updated_at) 103 | VALUES (?, ?, ?, ?)`, task.Summary, task.Active, task.CreatedAt, task.UpdatedAt) 104 | if err != nil { 105 | t.Fatalf("failed to insert data into table \"task\": %v", err) 106 | } 107 | } 108 | 109 | seqItems := make([]int, na) 110 | for i := range na { 111 | seqItems[i] = i + 1 112 | } 113 | sequenceJSON, err := json.Marshal(seqItems) 114 | if err != nil { 115 | t.Fatalf("failed to marshall JSON data for seeding: %v", err) 116 | } 117 | 118 | _, err = db.Exec(` 119 | UPDATE task_sequence 120 | SET sequence = ? 121 | WHERE id = 1; 122 | `, sequenceJSON) 123 | if err != nil { 124 | t.Fatalf("failed to insert data into table \"task_sequence\": %v", err) 125 | } 126 | 127 | return na, ni 128 | } 129 | 130 | func TestInsertTasksWorksWithEmptyTaskList(t *testing.T) { 131 | t.Cleanup(func() { cleanupDB(t) }) 132 | 133 | // GIVEN 134 | // WHEN 135 | now := time.Now().UTC() 136 | tasks := []types.Task{ 137 | { 138 | Summary: "prefix: new task 1", 139 | Active: true, 140 | CreatedAt: now, 141 | UpdatedAt: now, 142 | }, 143 | { 144 | Summary: "prefix: new inactive task 1", 145 | Active: false, 146 | CreatedAt: now, 147 | UpdatedAt: now, 148 | }, 149 | { 150 | Summary: "prefix: new task 3", 151 | Active: true, 152 | CreatedAt: now, 153 | UpdatedAt: now, 154 | }, 155 | } 156 | lastID, err := InsertTasks(testDB, tasks, true) 157 | assert.Equal(t, lastID, int64(3), "last ID is not correct") 158 | require.NoError(t, err) 159 | 160 | // THEN 161 | numActiveRes, err := fetchNumActiveTasks(testDB) 162 | require.NoError(t, err) 163 | assert.Equal(t, numActiveRes, 2, "number of active tasks didn't increase by the correct amount") 164 | 165 | numTotalRes, err := fetchNumTotalTasks(testDB) 166 | require.NoError(t, err) 167 | assert.Equal(t, numTotalRes, 3, "number of total tasks didn't increase by the correct amount") 168 | 169 | lastTask, err := fetchTaskByID(testDB, lastID) 170 | require.NoError(t, err) 171 | assert.Equal(t, tasks[2].Active, lastTask.Active) 172 | assert.Equal(t, tasks[2].Summary, lastTask.Summary) 173 | assert.Equal(t, tasks[2].Context, lastTask.Context) 174 | 175 | seq, err := fetchTaskSequence(testDB) 176 | require.NoError(t, err) 177 | assert.Equal(t, seq, []uint64{1, 3}, "task sequence isn't correct") 178 | } 179 | 180 | func TestInsertTasksAddsTasksAtTheTop(t *testing.T) { 181 | t.Cleanup(func() { cleanupDB(t) }) 182 | 183 | // GIVEN 184 | na, ni := seedDB(t, testDB) 185 | 186 | // WHEN 187 | now := time.Now().UTC() 188 | tasks := []types.Task{ 189 | { 190 | Summary: "prefix: new task 1", 191 | Active: true, 192 | CreatedAt: now, 193 | UpdatedAt: now, 194 | }, 195 | { 196 | Summary: "prefix: new inactive task 1", 197 | Active: false, 198 | CreatedAt: now, 199 | UpdatedAt: now, 200 | }, 201 | { 202 | Summary: "prefix: new task 3", 203 | Active: true, 204 | CreatedAt: now, 205 | UpdatedAt: now, 206 | }, 207 | } 208 | 209 | _, err := InsertTasks(testDB, tasks, true) 210 | require.NoError(t, err) 211 | 212 | // THEN 213 | numActiveRes, err := fetchNumActiveTasks(testDB) 214 | require.NoError(t, err) 215 | assert.Equal(t, numActiveRes, na+2, "number of active tasks didn't increase by the correct amount") 216 | 217 | numTotalRes, err := fetchNumTotalTasks(testDB) 218 | require.NoError(t, err) 219 | assert.Equal(t, numTotalRes, na+ni+3, "number of total tasks didn't increase by the correct amount") 220 | 221 | seq, err := fetchTaskSequence(testDB) 222 | require.NoError(t, err) 223 | assert.Equal(t, seq, []uint64{6, 8, 1, 2, 3}, "task sequence isn't correct") 224 | } 225 | 226 | func TestInsertTasksAddsTasksAtTheEnd(t *testing.T) { 227 | t.Cleanup(func() { cleanupDB(t) }) 228 | 229 | // GIVEN 230 | na, _ := seedDB(t, testDB) 231 | 232 | // WHEN 233 | now := time.Now().UTC() 234 | tasks := []types.Task{ 235 | { 236 | Summary: "prefix: new task 1", 237 | Active: true, 238 | CreatedAt: now, 239 | UpdatedAt: now, 240 | }, 241 | { 242 | Summary: "prefix: new task 2", 243 | Active: true, 244 | CreatedAt: now, 245 | UpdatedAt: now, 246 | }, 247 | } 248 | 249 | _, err := InsertTasks(testDB, tasks, false) 250 | require.NoError(t, err) 251 | 252 | // THEN 253 | numActiveRes, err := fetchNumActiveTasks(testDB) 254 | require.NoError(t, err) 255 | assert.Equal(t, numActiveRes, na+2, "number of active tasks didn't increase by the correct amount") 256 | 257 | seq, err := fetchTaskSequence(testDB) 258 | require.NoError(t, err) 259 | assert.Equal(t, seq, []uint64{1, 2, 3, 6, 7}, "task sequence isn't correct") 260 | } 261 | -------------------------------------------------------------------------------- /internal/types/colors.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | var colors = []string{ 4 | "#fe77a4", 5 | "#d3869a", 6 | "#ff4c8b", 7 | "#ffb0c2", 8 | "#df748b", 9 | "#ff6682", 10 | "#f19597", 11 | "#d89e9d", 12 | "#fc5260", 13 | "#e96462", 14 | "#ffb5a2", 15 | "#febcac", 16 | "#f0947b", 17 | "#ff6334", 18 | "#af9084", 19 | "#ff5405", 20 | "#e98658", 21 | "#be876e", 22 | "#ff803b", 23 | "#fd780b", 24 | "#ff9743", 25 | "#e2ac85", 26 | "#d67717", 27 | "#d4925c", 28 | "#ffb472", 29 | "#fe9103", 30 | "#de9644", 31 | "#dc8b00", 32 | "#ffb13c", 33 | "#c9b094", 34 | "#faca7d", 35 | "#c7921f", 36 | "#c6a267", 37 | "#d3cdc5", 38 | "#fabd2f", 39 | "#dcad50", 40 | "#daa402", 41 | "#ffc20c", 42 | "#fbcf56", 43 | "#b29807", 44 | "#e7c727", 45 | "#c7b648", 46 | "#9c9360", 47 | "#cec48b", 48 | "#bbb206", 49 | "#ddd601", 50 | "#d1cc74", 51 | "#b8bb26", 52 | "#acaa5e", 53 | "#b4c800", 54 | "#a6b92b", 55 | "#a8b64c", 56 | "#aab08a", 57 | "#849843", 58 | "#a8d906", 59 | "#a8a9a3", 60 | "#88b500", 61 | "#add562", 62 | "#a0d845", 63 | "#8de107", 64 | "#829b60", 65 | "#7db839", 66 | "#94bc63", 67 | "#71c200", 68 | "#b5d092", 69 | "#6e9f3a", 70 | "#51a100", 71 | "#b5e48c", 72 | "#8ce852", 73 | "#59d412", 74 | "#89d967", 75 | "#59c435", 76 | "#4ba539", 77 | "#00b700", 78 | "#00db04", 79 | "#9ae089", 80 | "#6fbd63", 81 | "#83b87a", 82 | "#5ddb63", 83 | "#04eb4d", 84 | "#7a9879", 85 | "#00ce48", 86 | "#05b64c", 87 | "#9cdea5", 88 | "#64d97f", 89 | "#8fbc96", 90 | "#4daa67", 91 | "#00d977", 92 | "#12b667", 93 | "#6ed999", 94 | "#63bd8f", 95 | "#00d990", 96 | "#a7e0c2", 97 | "#0abe88", 98 | "#90b4a6", 99 | "#83a598", 100 | "#5cab95", 101 | "#b9d9cf", 102 | "#03d7b3", 103 | "#00b499", 104 | "#6fd0bd", 105 | "#1edacd", 106 | "#19b7b2", 107 | "#89cbce", 108 | "#4dcfdb", 109 | "#62a6ae", 110 | "#90e1ef", 111 | "#01aac0", 112 | "#48cae4", 113 | "#00ddff", 114 | "#6ac1db", 115 | "#00c3f9", 116 | "#99bbcd", 117 | "#149ccd", 118 | "#6da2c6", 119 | "#7bcaff", 120 | "#07b1fa", 121 | "#b4d4fb", 122 | "#629fdb", 123 | "#5aaaff", 124 | "#0798ff", 125 | "#4896ef", 126 | "#bbd1ff", 127 | "#9fb9f0", 128 | "#949aab", 129 | "#7b8ad5", 130 | "#8498fb", 131 | "#aaaffc", 132 | "#8187dc", 133 | "#ada7ff", 134 | "#aba3ca", 135 | "#d2c8f2", 136 | "#a681fb", 137 | "#b798f0", 138 | "#c3a4e1", 139 | "#ce8cf7", 140 | "#c97df9", 141 | "#e6a5f4", 142 | "#e47cfb", 143 | "#ffc6ff", 144 | "#f344ff", 145 | "#a882a7", 146 | "#c57fbf", 147 | "#ff4ded", 148 | "#f081de", 149 | "#fc69e6", 150 | "#dfa5ca", 151 | "#f646c1", 152 | "#ceb4c3", 153 | "#f27abe", 154 | "#ae8c99", 155 | "#ee91b6", 156 | } 157 | -------------------------------------------------------------------------------- /internal/types/types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "hash/fnv" 7 | "strings" 8 | "time" 9 | 10 | "github.com/charmbracelet/lipgloss" 11 | "github.com/dhth/omm/internal/utils" 12 | "github.com/dustin/go-humanize" 13 | ) 14 | 15 | const ( 16 | timeFormat = "2006/01/02 15:04" 17 | PrefixDelimiter = ":" 18 | compactPrefixPadding = 24 19 | spaciousPrefixPadding = 80 20 | createdAtPadding = 40 21 | GOOSDarwin = "darwin" 22 | taskSummaryWidth = 120 23 | TaskSummaryMaxLen = 300 24 | ) 25 | 26 | var ( 27 | createdAtColor = "#928374" 28 | hasContextColor = "#928374" 29 | createdAtStyle = lipgloss.NewStyle(). 30 | Foreground(lipgloss.Color(createdAtColor)) 31 | 32 | hasContextStyle = lipgloss.NewStyle(). 33 | Foreground(lipgloss.Color(hasContextColor)) 34 | 35 | ErrTaskSummaryEmpty = errors.New("task summary is empty") 36 | ErrTaskPrefixEmpty = errors.New("task prefix is empty") 37 | ErrTaskSummaryBodyEmpty = errors.New("task summary body is empty") 38 | ErrTaskSummaryTooLong = errors.New("task summary is too long") 39 | ) 40 | 41 | type Task struct { 42 | ID uint64 43 | Summary string 44 | Context *string 45 | Active bool 46 | CreatedAt time.Time 47 | UpdatedAt time.Time 48 | } 49 | 50 | type ContextBookmark string 51 | 52 | type TaskPrefix string 53 | 54 | func (t Task) Prefix() (TaskPrefix, bool) { 55 | summEls := strings.Split(t.Summary, PrefixDelimiter) 56 | if len(summEls) > 1 { 57 | // This shouldn't happen, but it's still good to check this to ensure 58 | // the quick filter list doesn't misbehave 59 | if strings.TrimSpace(summEls[0]) == "" { 60 | return "", false 61 | } 62 | return TaskPrefix(strings.TrimSpace(summEls[0])), true 63 | } 64 | return "", false 65 | } 66 | 67 | func CheckIfTaskSummaryValid(summary string) (bool, error) { 68 | if strings.TrimSpace(summary) == "" { 69 | return false, ErrTaskSummaryEmpty 70 | } 71 | 72 | if len(summary) > TaskSummaryMaxLen { 73 | return false, ErrTaskSummaryTooLong 74 | } 75 | 76 | summEls := strings.Split(summary, PrefixDelimiter) 77 | if len(summEls) > 1 { 78 | if strings.TrimSpace(summEls[0]) == "" { 79 | return false, ErrTaskPrefixEmpty 80 | } 81 | 82 | if strings.TrimSpace(strings.Join(summEls[1:], PrefixDelimiter)) == "" { 83 | return false, ErrTaskSummaryBodyEmpty 84 | } 85 | } 86 | 87 | return true, nil 88 | } 89 | 90 | func (t Task) GetPrefixAndSummaryContent() (string, string, bool) { 91 | summEls := strings.Split(t.Summary, PrefixDelimiter) 92 | 93 | if len(summEls) == 1 { 94 | return "", t.Summary, false 95 | } 96 | 97 | return strings.TrimSpace(summEls[0]), strings.TrimSpace(strings.Join(summEls[1:], PrefixDelimiter)), true 98 | } 99 | 100 | func (t Task) Title() string { 101 | _, sc, _ := t.GetPrefixAndSummaryContent() 102 | return sc 103 | } 104 | 105 | func (t Task) Description() string { 106 | var prefix string 107 | var createdAt string 108 | var hasContext string 109 | 110 | summEls := strings.Split(t.Summary, PrefixDelimiter) 111 | if len(summEls) > 1 { 112 | prefix = GetDynamicStyle(summEls[0]).Render(utils.RightPadTrim(summEls[0], spaciousPrefixPadding, true)) 113 | } else { 114 | prefix = strings.Repeat(" ", spaciousPrefixPadding) 115 | } 116 | now := time.Now() 117 | 118 | var createdAtTs string 119 | if now.Sub(t.CreatedAt).Seconds() < 60 { 120 | createdAtTs = "just now" 121 | } else { 122 | createdAtTs = humanize.Time(t.CreatedAt) 123 | } 124 | createdAt = createdAtStyle.Render(utils.RightPadTrim(fmt.Sprintf("created %s", createdAtTs), createdAtPadding, true)) 125 | 126 | if t.Context != nil { 127 | hasContext = hasContextStyle.Render("(c)") 128 | } 129 | 130 | return fmt.Sprintf("%s%s%s", prefix, createdAt, hasContext) 131 | } 132 | 133 | func (t Task) FilterValue() string { 134 | p, ok := t.Prefix() 135 | if ok { 136 | return string(p) 137 | } 138 | return "" 139 | } 140 | 141 | func GetDynamicStyle(str string) lipgloss.Style { 142 | h := fnv.New32() 143 | h.Write([]byte(str)) 144 | hash := h.Sum32() 145 | 146 | color := colors[hash%uint32(len(colors))] 147 | return lipgloss.NewStyle(). 148 | Foreground(lipgloss.Color(color)) 149 | } 150 | 151 | func (c ContextBookmark) Title() string { 152 | return string(c) 153 | } 154 | 155 | func (c ContextBookmark) Description() string { 156 | return "" 157 | } 158 | 159 | func (c ContextBookmark) FilterValue() string { 160 | return string(c) 161 | } 162 | 163 | func (p TaskPrefix) Title() string { 164 | return string(p) 165 | } 166 | 167 | func (p TaskPrefix) Description() string { 168 | return "" 169 | } 170 | 171 | func (p TaskPrefix) FilterValue() string { 172 | return string(p) 173 | } 174 | -------------------------------------------------------------------------------- /internal/types/types_test.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestGetDynamicStyle(t *testing.T) { 10 | input := "abcdefghi" 11 | gota := GetDynamicStyle(input) 12 | gotb := GetDynamicStyle(input) 13 | // assert same style returned for the same string 14 | assert.Equal(t, gota.GetBackground(), gotb.GetBackground()) 15 | } 16 | -------------------------------------------------------------------------------- /internal/ui/assets/help.md: -------------------------------------------------------------------------------- 1 | ## Overview 2 | 3 | omm ("on-my-mind") is a keyboard-driven task manager for the command line. 4 | 5 | Tip: Run `omm guide` for a guided walkthrough of omm's features. 6 | 7 | omm has 6 components: 8 | 9 | - Active Tasks List 10 | - Archived Tasks List 11 | - Task Creation/Update Pane 12 | - Task Details Pane 13 | - Task Bookmarks List 14 | - Prefix Selection List 15 | 16 | ## Keymaps 17 | 18 | ### General 19 | 20 | ```text 21 | q/esc/ctrl+c go back 22 | Q quit from anywhere 23 | ``` 24 | 25 | ### Active/Archived Tasks List 26 | 27 | ```text 28 | j/↓ move cursor down 29 | k/↑ move cursor up 30 | h go to previous page 31 | l go to next page 32 | g go to the top 33 | G go to the end 34 | tab move between lists 35 | C toggle showing context 36 | d toggle Task Details pane 37 | b open Task Bookmarks list 38 | B open all bookmarks added to current task 39 | c update context for a task 40 | ctrl+d archive/unarchive task 41 | ctrl+x delete task 42 | ctrl+r reload task lists 43 | / filter list by task prefix 44 | ctrl+p filter by prefix via the prefix selection list 45 | y copy selected task's context to system clipboard 46 | v toggle between compact and spacious view 47 | ``` 48 | 49 | ### Active Tasks List 50 | 51 | ```text 52 | q/esc/ctrl+c quit 53 | o/a add task below cursor 54 | O add task above cursor 55 | I add task at the top 56 | A add task at the end 57 | u update task summary 58 | ⏎ move task to the top 59 | E move task to the end 60 | J move task one position down 61 | K move task one position up 62 | ``` 63 | 64 | **Note**: Most actions on tasks are not allowed when the tasks list is in a 65 | filtered state. You can press `⏎` to go back to the main list and have the 66 | cursor be moved to the task you had selected in the filtered state, and run the 67 | action from there. 68 | 69 | ### Task Creation/Update Pane 70 | 71 | ```text 72 | ⏎ submit task summary 73 | ctrl+p choose/change prefix via the prefix selection list 74 | ``` 75 | 76 | ### Task Details Pane 77 | 78 | ```text 79 | h/←/→/l move backwards/forwards when in the task details view 80 | y copy current task's context to system clipboard 81 | B open all bookmarks added to current task 82 | ``` 83 | 84 | ### Task Bookmarks List 85 | 86 | ```text 87 | ⏎ open URI in browser 88 | ``` 89 | -------------------------------------------------------------------------------- /internal/ui/cmds.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "database/sql" 5 | "os/exec" 6 | "runtime" 7 | "time" 8 | 9 | "github.com/atotto/clipboard" 10 | tea "github.com/charmbracelet/bubbletea" 11 | pers "github.com/dhth/omm/internal/persistence" 12 | "github.com/dhth/omm/internal/types" 13 | _ "modernc.org/sqlite" // sqlite driver 14 | ) 15 | 16 | func hideHelp(interval time.Duration) tea.Cmd { 17 | return tea.Tick(interval, func(time.Time) tea.Msg { 18 | return HideHelpMsg{} 19 | }) 20 | } 21 | 22 | func updateTaskSequence(db *sql.DB, sequence []uint64) tea.Cmd { 23 | return func() tea.Msg { 24 | err := pers.UpdateTaskSequence(db, sequence) 25 | return taskSequenceUpdatedMsg{err} 26 | } 27 | } 28 | 29 | func createTask(db *sql.DB, summary string, createdAt, updatedAt time.Time) tea.Cmd { 30 | return func() tea.Msg { 31 | id, err := pers.InsertTask(db, summary, createdAt, updatedAt) 32 | return taskCreatedMsg{id, summary, createdAt, updatedAt, err} 33 | } 34 | } 35 | 36 | func deleteTask(db *sql.DB, id uint64, index int, active bool) tea.Cmd { 37 | return func() tea.Msg { 38 | err := pers.DeleteTask(db, id) 39 | return taskDeletedMsg{id, index, active, err} 40 | } 41 | } 42 | 43 | func updateTaskSummary(db *sql.DB, listIndex int, id uint64, summary string) tea.Cmd { 44 | return func() tea.Msg { 45 | now := time.Now() 46 | err := pers.UpdateTaskSummary(db, id, summary, now) 47 | return taskSummaryUpdatedMsg{listIndex, id, summary, now, err} 48 | } 49 | } 50 | 51 | func updateTaskContext(db *sql.DB, listIndex int, id uint64, context string, list taskListType) tea.Cmd { 52 | return func() tea.Msg { 53 | var err error 54 | now := time.Now() 55 | if context == "" { 56 | err = pers.UnsetTaskContext(db, id, now) 57 | } else { 58 | err = pers.UpdateTaskContext(db, id, context, now) 59 | } 60 | return taskContextUpdatedMsg{listIndex, list, id, context, now, err} 61 | } 62 | } 63 | 64 | func changeTaskStatus(db *sql.DB, listIndex int, id uint64, active bool, updatedAt time.Time) tea.Cmd { 65 | return func() tea.Msg { 66 | err := pers.ChangeTaskStatus(db, id, active, updatedAt) 67 | return taskStatusChangedMsg{listIndex, id, active, updatedAt, err} 68 | } 69 | } 70 | 71 | func fetchTasks(db *sql.DB, active bool, limit int) tea.Cmd { 72 | return func() tea.Msg { 73 | var tasks []types.Task 74 | var err error 75 | switch active { 76 | case true: 77 | tasks, err = pers.FetchActiveTasks(db, limit) 78 | case false: 79 | tasks, err = pers.FetchInActiveTasks(db, limit) 80 | } 81 | return tasksFetched{tasks, active, err} 82 | } 83 | } 84 | 85 | func openTextEditor(fPath string, editorCmd []string, taskIndex int, taskID uint64, oldContext *string) tea.Cmd { 86 | c := exec.Command(editorCmd[0], append(editorCmd[1:], fPath)...) 87 | 88 | return tea.ExecProcess(c, func(err error) tea.Msg { 89 | return tea.Msg(textEditorClosed{fPath, taskIndex, taskID, oldContext, err}) 90 | }) 91 | } 92 | 93 | func openURI(uri string) tea.Cmd { 94 | var cmd string 95 | var args []string 96 | switch runtime.GOOS { 97 | case "windows": 98 | cmd = "cmd" 99 | args = []string{"/c", "start"} 100 | case "darwin": 101 | cmd = "open" 102 | default: 103 | cmd = "xdg-open" 104 | } 105 | c := exec.Command(cmd, append(args, uri)...) 106 | err := c.Run() 107 | return func() tea.Msg { 108 | return uriOpenedMsg{uri, err} 109 | } 110 | } 111 | 112 | func openURIsDarwin(uris []string) tea.Cmd { 113 | c := exec.Command("open", uris...) 114 | err := c.Run() 115 | return func() tea.Msg { 116 | return urisOpenedDarwinMsg{uris, err} 117 | } 118 | } 119 | 120 | func copyContextToClipboard(context string) tea.Cmd { 121 | return func() tea.Msg { 122 | err := clipboard.WriteAll(context) 123 | return contextWrittenToCBMsg{err} 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /internal/ui/config.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | type ListDensityType uint8 4 | 5 | const ( 6 | Compact ListDensityType = iota 7 | Spacious 8 | ) 9 | 10 | const ( 11 | CompactDensityVal = "compact" 12 | SpaciousDensityVal = "spacious" 13 | ) 14 | 15 | type Config struct { 16 | ListDensity ListDensityType 17 | TaskListColor string 18 | ArchivedTaskListColor string 19 | ContextPaneColor string 20 | TaskListTitle string 21 | TextEditorCmd []string 22 | Guide bool 23 | DBPath string 24 | ShowContext bool 25 | ConfirmBeforeDeletion bool 26 | CircularNav bool 27 | } 28 | -------------------------------------------------------------------------------- /internal/ui/initial.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "database/sql" 5 | "runtime" 6 | 7 | "github.com/charmbracelet/bubbles/list" 8 | "github.com/charmbracelet/bubbles/textinput" 9 | "github.com/charmbracelet/lipgloss" 10 | "github.com/dhth/omm/internal/types" 11 | "github.com/dhth/omm/internal/utils" 12 | ) 13 | 14 | func InitialModel(db *sql.DB, config Config) Model { 15 | taskItems := make([]list.Item, 0) 16 | tlSelItemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(config.TaskListColor)) 17 | 18 | var taskList list.Model 19 | 20 | switch config.ListDensity { 21 | case Compact: 22 | taskList = list.New(taskItems, 23 | compactItemDelegate{tlSelItemStyle}, 24 | taskSummaryWidth, 25 | defaultListHeight, 26 | ) 27 | case Spacious: 28 | taskList = list.New(taskItems, 29 | newSpaciousListDelegate(lipgloss.Color(config.TaskListColor), true, 1), 30 | taskSummaryWidth, 31 | defaultListHeight, 32 | ) 33 | } 34 | taskList.Title = config.TaskListTitle 35 | taskList.SetFilteringEnabled(true) 36 | taskList.SetStatusBarItemName("task", "tasks") 37 | taskList.SetShowStatusBar(true) 38 | taskList.SetShowHelp(false) 39 | taskList.DisableQuitKeybindings() 40 | taskList.KeyMap.PrevPage.SetKeys("left", "h", "pgup") 41 | taskList.KeyMap.NextPage.SetKeys("right", "l", "pgdown") 42 | taskList.SetStatusBarItemName("task", "tasks") 43 | 44 | taskList.Styles.Title = taskList.Styles.Title. 45 | Foreground(lipgloss.Color(defaultBackgroundColor)). 46 | Background(lipgloss.Color(config.TaskListColor)). 47 | Bold(true) 48 | taskListTitleStyle := titleStyle.Background(lipgloss.Color(config.TaskListColor)) 49 | 50 | atlSelItemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(config.ArchivedTaskListColor)) 51 | archivedTaskItems := make([]list.Item, 0) 52 | 53 | var archivedTaskList list.Model 54 | switch config.ListDensity { 55 | case Compact: 56 | archivedTaskList = list.New(archivedTaskItems, 57 | compactItemDelegate{atlSelItemStyle}, 58 | taskSummaryWidth, 59 | defaultListHeight, 60 | ) 61 | case Spacious: 62 | archivedTaskList = list.New(archivedTaskItems, 63 | newSpaciousListDelegate(lipgloss.Color(config.ArchivedTaskListColor), true, 1), 64 | taskSummaryWidth, 65 | defaultListHeight, 66 | ) 67 | } 68 | archivedTaskList.Title = archivedTitle 69 | archivedTaskList.SetShowStatusBar(true) 70 | archivedTaskList.SetStatusBarItemName("task", "tasks") 71 | archivedTaskList.SetFilteringEnabled(true) 72 | archivedTaskList.SetShowHelp(false) 73 | archivedTaskList.DisableQuitKeybindings() 74 | archivedTaskList.KeyMap.PrevPage.SetKeys("left", "h", "pgup") 75 | archivedTaskList.KeyMap.NextPage.SetKeys("right", "l", "pgdown") 76 | archivedTaskList.SetStatusBarItemName("task", "tasks") 77 | 78 | archivedTaskList.Styles.Title = archivedTaskList.Styles.Title. 79 | Foreground(lipgloss.Color(defaultBackgroundColor)). 80 | Background(lipgloss.Color(config.ArchivedTaskListColor)). 81 | Bold(true) 82 | archivedTaskListTitleStyle := titleStyle.Background(lipgloss.Color(config.ArchivedTaskListColor)) 83 | 84 | taskInput := textinput.New() 85 | taskInput.Placeholder = "prefix: task summary goes here" 86 | taskInput.CharLimit = types.TaskSummaryMaxLen 87 | taskInput.Width = taskSummaryWidth 88 | 89 | contextBMList := list.New(nil, newSpaciousListDelegate(lipgloss.Color(contextBMColor), false, 1), taskSummaryWidth, defaultListHeight) 90 | 91 | contextBMList.Title = "task bookmarks" 92 | contextBMList.SetShowHelp(false) 93 | contextBMList.SetStatusBarItemName("bookmark", "bookmarks") 94 | contextBMList.SetFilteringEnabled(false) 95 | contextBMList.DisableQuitKeybindings() 96 | contextBMList.KeyMap.PrevPage.SetKeys("left", "h", "pgup") 97 | contextBMList.KeyMap.NextPage.SetKeys("right", "l", "pgdown") 98 | 99 | contextBMList.Styles.Title = contextBMList.Styles.Title. 100 | Foreground(lipgloss.Color(defaultBackgroundColor)). 101 | Background(lipgloss.Color(contextBMColor)). 102 | Bold(true) 103 | 104 | prefixSearchList := list.New(nil, newSpaciousListDelegate(lipgloss.Color(prefixSearchColor), false, 0), taskSummaryWidth, defaultListHeight) 105 | 106 | prefixSearchList.Title = "filter by prefix" 107 | prefixSearchList.SetShowHelp(false) 108 | prefixSearchList.SetStatusBarItemName("prefix", "prefixes") 109 | prefixSearchList.SetFilteringEnabled(false) 110 | prefixSearchList.DisableQuitKeybindings() 111 | prefixSearchList.KeyMap.PrevPage.SetKeys("left", "h", "pgup") 112 | prefixSearchList.KeyMap.NextPage.SetKeys("right", "l", "pgdown") 113 | 114 | prefixSearchList.Styles.Title = prefixSearchList.Styles.Title. 115 | Foreground(lipgloss.Color(defaultBackgroundColor)). 116 | Background(lipgloss.Color(prefixSearchColor)). 117 | Bold(true) 118 | 119 | m := Model{ 120 | db: db, 121 | cfg: config, 122 | taskList: taskList, 123 | archivedTaskList: archivedTaskList, 124 | taskBMList: contextBMList, 125 | prefixSearchList: prefixSearchList, 126 | taskInput: taskInput, 127 | showHelpIndicator: true, 128 | tlTitleStyle: taskListTitleStyle, 129 | atlTitleStyle: archivedTaskListTitleStyle, 130 | tlSelStyle: tlSelItemStyle, 131 | atlSelStyle: atlSelItemStyle, 132 | contextVPTaskID: 0, 133 | rtos: runtime.GOOS, 134 | uriRegex: utils.GetURIRegex(), 135 | } 136 | 137 | return m 138 | } 139 | -------------------------------------------------------------------------------- /internal/ui/list_delegate.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/charmbracelet/bubbles/list" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/lipgloss" 10 | "github.com/dhth/omm/internal/types" 11 | "github.com/dhth/omm/internal/utils" 12 | ) 13 | 14 | type compactItemDelegate struct { 15 | selStyle lipgloss.Style 16 | } 17 | 18 | func (d compactItemDelegate) Height() int { return 1 } 19 | 20 | func (d compactItemDelegate) Spacing() int { return 1 } 21 | 22 | func (d compactItemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } 23 | 24 | func (d compactItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { 25 | t, ok := listItem.(types.Task) 26 | if !ok { 27 | return 28 | } 29 | 30 | prefix, sc, hp := t.GetPrefixAndSummaryContent() 31 | 32 | if hp { 33 | prefix = types.GetDynamicStyle(prefix).Render(utils.RightPadTrim(prefix, prefixPadding, true)) 34 | } 35 | var hasContext string 36 | if t.Context != nil { 37 | hasContext = "(c)" 38 | } 39 | 40 | sr := d.selStyle.Render 41 | var str string 42 | if index == m.Index() { 43 | str = fmt.Sprintf("%s%s%s%s", sr("│ "), prefix, sr(utils.RightPadTrim(sc, taskSummaryWidth-prefixPadding, true)), sr(hasContext)) 44 | } else { 45 | str = fmt.Sprintf("%s%s%s%s", " ", prefix, utils.RightPadTrim(sc, taskSummaryWidth-prefixPadding, true), hasContext) 46 | } 47 | 48 | fmt.Fprint(w, str) 49 | } 50 | 51 | func newSpaciousListDelegate(color lipgloss.Color, showDesc bool, spacing int) list.DefaultDelegate { 52 | d := list.NewDefaultDelegate() 53 | 54 | d.ShowDescription = showDesc 55 | d.SetSpacing(spacing) 56 | 57 | d.Styles.NormalTitle = d.Styles. 58 | NormalTitle. 59 | Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#fbf1c7"}) 60 | 61 | d.Styles.SelectedTitle = d.Styles. 62 | SelectedTitle. 63 | Foreground(color). 64 | BorderLeftForeground(color) 65 | 66 | d.Styles.SelectedDesc = d.Styles. 67 | SelectedTitle 68 | 69 | d.Styles.FilterMatch = lipgloss.NewStyle() 70 | 71 | return d 72 | } 73 | -------------------------------------------------------------------------------- /internal/ui/model.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "database/sql" 5 | "regexp" 6 | "time" 7 | 8 | "github.com/charmbracelet/bubbles/list" 9 | "github.com/charmbracelet/bubbles/textinput" 10 | "github.com/charmbracelet/bubbles/viewport" 11 | tea "github.com/charmbracelet/bubbletea" 12 | "github.com/charmbracelet/glamour" 13 | "github.com/charmbracelet/lipgloss" 14 | pers "github.com/dhth/omm/internal/persistence" 15 | ) 16 | 17 | const ( 18 | defaultListHeight = 10 19 | prefixPadding = 24 20 | timeFormat = "2006/01/02 15:04" 21 | taskSummaryWidth = 120 22 | archivedTitle = "archived" 23 | ) 24 | 25 | type taskChangeType uint 26 | 27 | const ( 28 | taskInsert taskChangeType = iota 29 | taskUpdateSummary 30 | taskChangePriority 31 | ) 32 | 33 | type activeView uint 34 | 35 | const ( 36 | taskListView activeView = iota 37 | archivedTaskListView 38 | taskEntryView 39 | taskDetailsView 40 | contextBookmarksView 41 | prefixSelectionView 42 | helpView 43 | ) 44 | 45 | type taskListType uint 46 | 47 | const ( 48 | activeTasks taskListType = iota 49 | archivedTasks 50 | ) 51 | 52 | type prefixUse uint 53 | 54 | const ( 55 | prefixFilter prefixUse = iota 56 | prefixChoose 57 | ) 58 | 59 | type Model struct { 60 | db *sql.DB 61 | cfg Config 62 | taskList list.Model 63 | archivedTaskList list.Model 64 | taskBMList list.Model 65 | prefixSearchList list.Model 66 | tlIndexMap map[uint64]int 67 | atlIndexMap map[uint64]int 68 | taskIndex int 69 | taskID uint64 70 | taskChange taskChangeType 71 | contextVP viewport.Model 72 | contextVPReady bool 73 | taskDetailsVP viewport.Model 74 | taskDetailsVPReady bool 75 | helpVP viewport.Model 76 | helpVPReady bool 77 | quitting bool 78 | showHelpIndicator bool 79 | successMsg string 80 | errorMsg string 81 | taskInput textinput.Model 82 | activeView activeView 83 | lastActiveView activeView 84 | activeTaskList taskListType 85 | tlTitleStyle lipgloss.Style 86 | atlTitleStyle lipgloss.Style 87 | tlSelStyle lipgloss.Style 88 | atlSelStyle lipgloss.Style 89 | terminalWidth int 90 | terminalHeight int 91 | contextVPTaskID uint64 92 | rtos string 93 | uriRegex *regexp.Regexp 94 | shortenedListHt int 95 | contextMdRenderer *glamour.TermRenderer 96 | taskDetailsMdRenderer *glamour.TermRenderer 97 | prefixSearchUse prefixUse 98 | showDeletePrompt bool 99 | } 100 | 101 | func (m Model) Init() tea.Cmd { 102 | return tea.Batch( 103 | fetchTasks(m.db, true, pers.TaskNumLimit), 104 | fetchTasks(m.db, false, pers.TaskNumLimit), 105 | hideHelp(time.Minute*1), 106 | ) 107 | } 108 | -------------------------------------------------------------------------------- /internal/ui/msgs.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/dhth/omm/internal/types" 7 | ) 8 | 9 | type HideHelpMsg struct{} 10 | 11 | type taskSequenceUpdatedMsg struct { 12 | err error 13 | } 14 | 15 | type taskCreatedMsg struct { 16 | id uint64 17 | taskSummary string 18 | createdAt time.Time 19 | updatedAt time.Time 20 | err error 21 | } 22 | 23 | type taskDeletedMsg struct { 24 | id uint64 25 | listIndex int 26 | active bool 27 | err error 28 | } 29 | 30 | type taskSummaryUpdatedMsg struct { 31 | listIndex int 32 | id uint64 33 | taskSummary string 34 | updatedAt time.Time 35 | err error 36 | } 37 | 38 | type taskContextUpdatedMsg struct { 39 | listIndex int 40 | list taskListType 41 | id uint64 42 | context string 43 | updatedAt time.Time 44 | err error 45 | } 46 | 47 | type taskStatusChangedMsg struct { 48 | listIndex int 49 | id uint64 50 | active bool 51 | updatedAt time.Time 52 | err error 53 | } 54 | 55 | type tasksFetched struct { 56 | tasks []types.Task 57 | active bool 58 | err error 59 | } 60 | 61 | type textEditorClosed struct { 62 | fPath string 63 | taskIndex int 64 | taskID uint64 65 | oldContext *string 66 | err error 67 | } 68 | 69 | type uriOpenedMsg struct { 70 | url string 71 | err error 72 | } 73 | 74 | type urisOpenedDarwinMsg struct { 75 | urls []string 76 | err error 77 | } 78 | 79 | type contextWrittenToCBMsg struct { 80 | err error 81 | } 82 | -------------------------------------------------------------------------------- /internal/ui/styles.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | ) 6 | 7 | const ( 8 | defaultBackgroundColor = "#282828" 9 | TaskListColor = "#fe8019" 10 | ArchivedTLColor = "#fabd2f" 11 | contextBMColor = "#83a598" 12 | prefixSearchColor = "#d3896b" 13 | contextTitleColor = "#8ec07c" 14 | taskEntryTitleColor = "#b8bb26" 15 | taskDetailsTitleColor = "#d3869b" 16 | taskListHeaderColor = "#928374" 17 | formHelpColor = "#928374" 18 | formColor = "#928374" 19 | helpMsgColor = "#928374" 20 | promptColor = "#fb4934" 21 | helpViewTitleColor = "#83a598" 22 | helpTitleColor = "#83a598" 23 | sBSuccessMsgColor = "#d3869b" 24 | sBErrMsgColor = "#fb4934" 25 | footerColor = "#928374" 26 | ) 27 | 28 | var ( 29 | titleStyle = lipgloss.NewStyle(). 30 | PaddingLeft(1). 31 | PaddingRight(1). 32 | Bold(true). 33 | Background(lipgloss.Color(TaskListColor)). 34 | Foreground(lipgloss.Color(defaultBackgroundColor)) 35 | 36 | listStyle = lipgloss.NewStyle().PaddingBottom(1).PaddingTop(1) 37 | 38 | taskEntryTitleStyle = titleStyle. 39 | Background(lipgloss.Color(taskEntryTitleColor)) 40 | 41 | helpTitleStyle = titleStyle. 42 | Background(lipgloss.Color(helpTitleColor)) 43 | 44 | contextTitleStyle = titleStyle. 45 | Background(lipgloss.Color(contextTitleColor)) 46 | 47 | taskDetailsTitleStyle = titleStyle. 48 | Background(lipgloss.Color(taskDetailsTitleColor)) 49 | 50 | headerStyle = lipgloss.NewStyle(). 51 | PaddingTop(1). 52 | PaddingBottom(1). 53 | PaddingLeft(2) 54 | 55 | statusBarMsgStyle = lipgloss.NewStyle(). 56 | PaddingLeft(2) 57 | 58 | sBErrMsgStyle = statusBarMsgStyle. 59 | Foreground(lipgloss.Color(sBErrMsgColor)) 60 | 61 | sBSuccessMsgStyle = statusBarMsgStyle. 62 | Foreground(lipgloss.Color(sBSuccessMsgColor)) 63 | 64 | helpMsgStyle = statusBarMsgStyle. 65 | Foreground(lipgloss.Color(helpMsgColor)) 66 | 67 | promptStyle = statusBarMsgStyle. 68 | Foreground(lipgloss.Color(promptColor)) 69 | 70 | formStyle = lipgloss.NewStyle(). 71 | Foreground(lipgloss.Color(formColor)) 72 | 73 | formHelpStyle = lipgloss.NewStyle(). 74 | Foreground(lipgloss.Color(formHelpColor)) 75 | ) 76 | -------------------------------------------------------------------------------- /internal/ui/ui.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log" 7 | "os" 8 | 9 | tea "github.com/charmbracelet/bubbletea" 10 | ) 11 | 12 | func RenderUI(db *sql.DB, config Config) { 13 | if len(os.Getenv("DEBUG")) > 0 { 14 | f, err := tea.LogToFile("debug.log", "debug") 15 | if err != nil { 16 | fmt.Fprintf(os.Stderr, "fatal error: %s", err.Error()) 17 | os.Exit(1) 18 | } 19 | defer f.Close() 20 | } 21 | 22 | p := tea.NewProgram(InitialModel(db, config), tea.WithAltScreen()) 23 | if _, err := p.Run(); err != nil { 24 | log.Fatalf("Something went wrong %s", err) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /internal/ui/update.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "os" 7 | "sort" 8 | "strings" 9 | "time" 10 | 11 | "github.com/charmbracelet/bubbles/list" 12 | "github.com/charmbracelet/bubbles/viewport" 13 | tea "github.com/charmbracelet/bubbletea" 14 | "github.com/charmbracelet/lipgloss" 15 | pers "github.com/dhth/omm/internal/persistence" 16 | "github.com/dhth/omm/internal/types" 17 | "github.com/dhth/omm/internal/utils" 18 | ) 19 | 20 | const ( 21 | noSpaceAvailableMsg = "Task list is at capacity. Archive/delete tasks using ctrl+d/ctrl+x." 22 | noContextMsg = " ∅" 23 | viewPortMoveLineCount = 3 24 | cannotMoveWhenFilteredMsg = "Can't move items when the task list is filtered" 25 | cannotAddWhenFilteredMsg = "Can't add items when the task list is filtered" 26 | cannotDeleteWhenFilteredMsg = "Can't delete items when the task list is filtered" 27 | somethingWentWrongMsg = "Something went wrong" 28 | ) 29 | 30 | //go:embed assets/help.md 31 | var helpStr string 32 | 33 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 34 | var cmd tea.Cmd 35 | var cmds []tea.Cmd 36 | m.successMsg = "" 37 | m.errorMsg = "" 38 | 39 | if m.activeView == taskListView || m.activeView == archivedTaskListView { 40 | switch msg := msg.(type) { 41 | case tea.KeyMsg: 42 | if m.taskList.FilterState() == list.Filtering { 43 | m.taskList, cmd = m.taskList.Update(msg) 44 | cmds = append(cmds, cmd) 45 | return m, tea.Batch(cmds...) 46 | } 47 | if m.archivedTaskList.FilterState() == list.Filtering { 48 | m.archivedTaskList, cmd = m.archivedTaskList.Update(msg) 49 | cmds = append(cmds, cmd) 50 | return m, tea.Batch(cmds...) 51 | } 52 | } 53 | } 54 | 55 | if m.activeView == taskEntryView { 56 | switch msg := msg.(type) { 57 | case tea.KeyMsg: 58 | switch keypress := msg.String(); keypress { 59 | case "esc", "ctrl+c": 60 | m.activeView = taskListView 61 | m.activeTaskList = activeTasks 62 | case "enter": 63 | taskSummary := m.taskInput.Value() 64 | taskSummary = strings.TrimSpace(taskSummary) 65 | 66 | if taskSummary == "" { 67 | m.activeView = taskListView 68 | m.activeTaskList = activeTasks 69 | break 70 | } 71 | 72 | summEls := strings.Split(taskSummary, types.PrefixDelimiter) 73 | if len(summEls) > 1 { 74 | if summEls[0] == "" { 75 | m.errorMsg = "prefix cannot be empty" 76 | break 77 | } 78 | } 79 | 80 | switch m.taskChange { 81 | case taskInsert: 82 | now := time.Now() 83 | cmd = createTask(m.db, taskSummary, now, now) 84 | cmds = append(cmds, cmd) 85 | m.taskInput.Reset() 86 | m.activeView = taskListView 87 | m.activeTaskList = activeTasks 88 | case taskUpdateSummary: 89 | cmd = updateTaskSummary(m.db, m.taskIndex, m.taskID, taskSummary) 90 | cmds = append(cmds, cmd) 91 | m.taskInput.Reset() 92 | m.activeView = taskListView 93 | m.activeTaskList = activeTasks 94 | } 95 | 96 | case "ctrl+p": 97 | if len(m.taskList.Items()) == 0 { 98 | m.errorMsg = "No items in task list" 99 | break 100 | } 101 | 102 | tasksPrefixes := make(map[types.TaskPrefix]struct{}) 103 | 104 | summary := m.taskInput.Value() 105 | 106 | currentPrefix, prefixPresent := getPrefix(summary) 107 | 108 | for _, li := range m.taskList.Items() { 109 | t, ok := li.(types.Task) 110 | if !ok { 111 | continue 112 | } 113 | 114 | prefix, pOk := t.Prefix() 115 | if !pOk { 116 | continue 117 | } 118 | 119 | if prefixPresent && prefix.FilterValue() == currentPrefix { 120 | continue 121 | } 122 | 123 | tasksPrefixes[prefix] = struct{}{} 124 | } 125 | 126 | var prefixes []types.TaskPrefix 127 | for k := range tasksPrefixes { 128 | prefixes = append(prefixes, k) 129 | } 130 | 131 | if len(prefixes) == 0 { 132 | m.errorMsg = "No prefixes in task list" 133 | break 134 | } 135 | 136 | if len(prefixes) == 1 { 137 | m.errorMsg = "Only 1 unique prefix in task list" 138 | break 139 | } 140 | 141 | sort.Slice(prefixes, func(i, j int) bool { 142 | return prefixes[i] < prefixes[j] 143 | }) 144 | 145 | pi := make([]list.Item, len(prefixes)) 146 | for i, p := range prefixes { 147 | pi[i] = list.Item(p) 148 | } 149 | 150 | m.prefixSearchList.SetItems(pi) 151 | m.lastActiveView = m.activeView 152 | m.activeView = prefixSelectionView 153 | switch prefixPresent { 154 | case true: 155 | m.prefixSearchList.Title = "change prefix" 156 | case false: 157 | m.prefixSearchList.Title = "choose prefix" 158 | } 159 | m.prefixSearchUse = prefixChoose 160 | 161 | return m, tea.Batch(cmds...) 162 | } 163 | } 164 | 165 | m.taskInput, cmd = m.taskInput.Update(msg) 166 | cmds = append(cmds, cmd) 167 | return m, tea.Batch(cmds...) 168 | } 169 | 170 | skipListUpdate := false 171 | 172 | switch msg := msg.(type) { 173 | case tea.WindowSizeMsg: 174 | w, h := listStyle.GetFrameSize() 175 | _, h3 := statusBarMsgStyle.GetFrameSize() 176 | m.terminalWidth = msg.Width 177 | m.terminalHeight = msg.Height 178 | m.taskList.SetWidth(msg.Width - w) 179 | m.archivedTaskList.SetWidth(msg.Width - 2) 180 | m.taskBMList.SetWidth(msg.Width - 2) 181 | m.taskBMList.SetHeight(msg.Height - h - h3 - 1) 182 | m.prefixSearchList.SetWidth(msg.Width - 2) 183 | m.prefixSearchList.SetHeight(msg.Height - h - h3 - 1) 184 | 185 | var listHeight int 186 | contextHeight := (msg.Height - h - h3 - 5) / 2 187 | 188 | m.shortenedListHt = msg.Height - contextHeight - 5 189 | 190 | if m.cfg.ShowContext { 191 | listHeight = m.shortenedListHt 192 | } else { 193 | listHeight = msg.Height - h - h3 - 1 194 | } 195 | 196 | m.taskList.SetHeight(listHeight) 197 | m.archivedTaskList.SetHeight(listHeight) 198 | vpWidth := msg.Width - 4 199 | 200 | if !m.contextVPReady { 201 | m.contextVP = viewport.New(vpWidth, contextHeight) 202 | m.contextVPReady = true 203 | } else { 204 | m.contextVP.Width = vpWidth 205 | m.contextVP.Height = contextHeight 206 | } 207 | 208 | if !m.taskDetailsVPReady { 209 | m.taskDetailsVP = viewport.New(vpWidth, m.terminalHeight-4) 210 | m.taskDetailsVP.KeyMap.HalfPageDown.SetKeys("ctrl+d") 211 | m.taskDetailsVPReady = true 212 | m.taskDetailsVP.KeyMap.Up.SetEnabled(false) 213 | m.taskDetailsVP.KeyMap.Down.SetEnabled(false) 214 | } else { 215 | m.taskDetailsVP.Width = vpWidth 216 | m.taskDetailsVP.Height = m.terminalHeight - 4 217 | } 218 | 219 | contextMdRenderer, err := utils.GetMarkDownRenderer(vpWidth) 220 | if err == nil { 221 | m.contextMdRenderer = contextMdRenderer 222 | } 223 | taskDetailsMdRenderer, err := utils.GetMarkDownRenderer(vpWidth) 224 | if err == nil { 225 | m.taskDetailsMdRenderer = taskDetailsMdRenderer 226 | } 227 | 228 | helpToRender := helpStr 229 | switch m.contextMdRenderer { 230 | case nil: 231 | break 232 | default: 233 | helpStrGl, err := m.contextMdRenderer.Render(helpStr) 234 | if err != nil { 235 | break 236 | } 237 | helpToRender = helpStrGl 238 | } 239 | 240 | if !m.helpVPReady { 241 | m.helpVP = viewport.New(msg.Width-3, m.terminalHeight-4) 242 | m.helpVP.SetContent(helpToRender) 243 | m.helpVP.KeyMap.Up.SetEnabled(false) 244 | m.helpVP.KeyMap.Down.SetEnabled(false) 245 | m.helpVPReady = true 246 | } else { 247 | m.helpVP.Width = msg.Width - 3 248 | m.helpVP.Height = m.terminalHeight - 4 249 | } 250 | 251 | case tea.KeyMsg: 252 | if m.cfg.ConfirmBeforeDeletion && m.showDeletePrompt && msg.String() != "ctrl+x" { 253 | m.showDeletePrompt = false 254 | 255 | switch m.activeView { 256 | case taskListView: 257 | m.taskList.Title = m.cfg.TaskListTitle 258 | m.taskList.Styles.Title = m.taskList.Styles.Title.Background(lipgloss.Color(m.cfg.TaskListColor)) 259 | case archivedTaskListView: 260 | m.archivedTaskList.Title = archivedTitle 261 | m.archivedTaskList.Styles.Title = m.archivedTaskList.Styles.Title.Background(lipgloss.Color(m.cfg.ArchivedTaskListColor)) 262 | } 263 | return m, tea.Batch(cmds...) 264 | } 265 | 266 | switch keypress := msg.String(); keypress { 267 | 268 | case "Q": 269 | m.quitting = true 270 | if m.cfg.Guide { 271 | _ = os.Remove(m.cfg.DBPath) 272 | } 273 | return m, tea.Quit 274 | 275 | case "esc", "q", "ctrl+c": 276 | av := m.activeView 277 | 278 | if m.activeView == taskListView && m.taskList.IsFiltered() { 279 | m.taskList.ResetFilter() 280 | break 281 | } 282 | 283 | if m.activeView == archivedTaskListView && m.archivedTaskList.IsFiltered() { 284 | m.archivedTaskList.ResetFilter() 285 | break 286 | } 287 | 288 | if m.activeView == archivedTaskListView { 289 | m.activeView = taskListView 290 | m.activeTaskList = activeTasks 291 | m.lastActiveView = av 292 | break 293 | } 294 | 295 | if m.activeView == taskDetailsView || m.activeView == contextBookmarksView || m.activeView == helpView { 296 | m.activeView = m.lastActiveView 297 | switch m.activeView { 298 | case taskListView: 299 | m.activeTaskList = activeTasks 300 | case archivedTaskListView: 301 | m.activeTaskList = archivedTasks 302 | } 303 | break 304 | } 305 | 306 | if m.activeView == prefixSelectionView { 307 | m.activeView = m.lastActiveView 308 | if m.prefixSearchUse == prefixChoose { 309 | return m, tea.Batch(cmds...) 310 | } 311 | break 312 | } 313 | 314 | m.quitting = true 315 | if m.cfg.Guide { 316 | _ = os.Remove(m.cfg.DBPath) 317 | } 318 | return m, tea.Quit 319 | 320 | case "?": 321 | if m.activeView == taskDetailsView || m.activeView == contextBookmarksView || m.activeView == prefixSelectionView { 322 | break 323 | } 324 | 325 | if m.activeView == helpView { 326 | m.activeView = m.lastActiveView 327 | break 328 | } 329 | m.lastActiveView = m.activeView 330 | m.activeView = helpView 331 | 332 | case "tab", "shift+tab": 333 | switch m.activeView { 334 | case taskListView: 335 | m.activeView = archivedTaskListView 336 | m.activeTaskList = archivedTasks 337 | m.lastActiveView = m.activeView 338 | case archivedTaskListView: 339 | m.activeView = taskListView 340 | m.activeTaskList = activeTasks 341 | m.lastActiveView = m.activeView 342 | } 343 | 344 | case "I": 345 | if m.activeView != taskListView { 346 | break 347 | } 348 | 349 | if !m.isSpaceAvailable() { 350 | m.errorMsg = noSpaceAvailableMsg 351 | break 352 | } 353 | 354 | if m.taskList.IsFiltered() { 355 | m.errorMsg = cannotAddWhenFilteredMsg 356 | break 357 | } 358 | 359 | m.taskIndex = 0 360 | m.taskInput.Reset() 361 | m.taskInput.Focus() 362 | m.taskChange = taskInsert 363 | m.activeView = taskEntryView 364 | return m, tea.Batch(cmds...) 365 | 366 | case "O": 367 | if m.activeView != taskListView { 368 | break 369 | } 370 | 371 | if !m.isSpaceAvailable() { 372 | m.errorMsg = noSpaceAvailableMsg 373 | break 374 | } 375 | 376 | if m.taskList.IsFiltered() { 377 | m.errorMsg = cannotAddWhenFilteredMsg 378 | break 379 | } 380 | 381 | m.taskIndex = m.taskList.Index() 382 | m.taskInput.Reset() 383 | m.taskInput.Focus() 384 | m.taskChange = taskInsert 385 | m.activeView = taskEntryView 386 | return m, tea.Batch(cmds...) 387 | 388 | case "a", "o": 389 | if m.activeView != taskListView { 390 | break 391 | } 392 | 393 | if !m.isSpaceAvailable() { 394 | m.errorMsg = noSpaceAvailableMsg 395 | break 396 | } 397 | 398 | if m.taskList.IsFiltered() { 399 | m.errorMsg = cannotAddWhenFilteredMsg 400 | break 401 | } 402 | 403 | if len(m.taskList.Items()) == 0 { 404 | m.taskIndex = 0 405 | } else { 406 | m.taskIndex = m.taskList.Index() + 1 407 | } 408 | m.taskInput.Reset() 409 | m.taskInput.Focus() 410 | m.taskChange = taskInsert 411 | m.activeView = taskEntryView 412 | return m, tea.Batch(cmds...) 413 | 414 | case "A": 415 | if m.activeView != taskListView { 416 | break 417 | } 418 | 419 | if !m.isSpaceAvailable() { 420 | m.errorMsg = noSpaceAvailableMsg 421 | break 422 | } 423 | 424 | if m.taskList.IsFiltered() { 425 | m.errorMsg = cannotAddWhenFilteredMsg 426 | break 427 | } 428 | 429 | m.taskIndex = len(m.taskList.Items()) 430 | m.taskInput.Reset() 431 | m.taskInput.Focus() 432 | m.taskChange = taskInsert 433 | m.activeView = taskEntryView 434 | return m, tea.Batch(cmds...) 435 | 436 | case "down", "j": 437 | switch m.activeView { 438 | case taskListView, archivedTaskListView, contextBookmarksView, prefixSelectionView: 439 | if !m.cfg.CircularNav { 440 | break 441 | } 442 | 443 | // cycle back to top 444 | var list *list.Model 445 | switch m.activeView { 446 | case taskListView: 447 | list = &m.taskList 448 | case archivedTaskListView: 449 | list = &m.archivedTaskList 450 | case contextBookmarksView: 451 | list = &m.taskBMList 452 | case prefixSelectionView: 453 | list = &m.prefixSearchList 454 | default: 455 | break 456 | } 457 | 458 | if list.IsFiltered() { 459 | break 460 | } 461 | 462 | numItems := len(list.Items()) 463 | if numItems <= 1 { 464 | break 465 | } 466 | 467 | if list.Index() == numItems-1 { 468 | list.Select(0) 469 | skipListUpdate = true 470 | } 471 | 472 | case taskDetailsView: 473 | if m.taskDetailsVP.AtBottom() { 474 | break 475 | } 476 | m.taskDetailsVP.ScrollDown(viewPortMoveLineCount) 477 | 478 | case helpView: 479 | if m.helpVP.AtBottom() { 480 | break 481 | } 482 | m.helpVP.ScrollDown(viewPortMoveLineCount) 483 | } 484 | 485 | case "up", "k": 486 | switch m.activeView { 487 | case taskListView, archivedTaskListView, contextBookmarksView, prefixSelectionView: 488 | if !m.cfg.CircularNav { 489 | break 490 | } 491 | 492 | // cycle to the end 493 | var list *list.Model 494 | switch m.activeView { 495 | case taskListView: 496 | list = &m.taskList 497 | case archivedTaskListView: 498 | list = &m.archivedTaskList 499 | case contextBookmarksView: 500 | list = &m.taskBMList 501 | case prefixSelectionView: 502 | list = &m.prefixSearchList 503 | default: 504 | break 505 | } 506 | 507 | if list.IsFiltered() { 508 | break 509 | } 510 | 511 | numItems := len(list.Items()) 512 | if numItems <= 1 { 513 | break 514 | } 515 | 516 | if list.Index() == 0 { 517 | list.Select(numItems - 1) 518 | skipListUpdate = true 519 | } 520 | 521 | case taskDetailsView: 522 | if m.taskDetailsVP.AtTop() { 523 | break 524 | } 525 | m.taskDetailsVP.ScrollUp(viewPortMoveLineCount) 526 | 527 | case helpView: 528 | if m.helpVP.AtTop() { 529 | break 530 | } 531 | m.helpVP.ScrollUp(viewPortMoveLineCount) 532 | } 533 | 534 | case "J": 535 | if m.activeView != taskListView { 536 | break 537 | } 538 | 539 | if m.taskList.IsFiltered() { 540 | m.errorMsg = cannotMoveWhenFilteredMsg 541 | break 542 | } 543 | 544 | if len(m.taskList.Items()) == 0 { 545 | break 546 | } 547 | 548 | ci := m.taskList.Index() 549 | if ci == len(m.taskList.Items())-1 { 550 | break 551 | } 552 | 553 | itemAbove := m.taskList.Items()[ci+1] 554 | currentItem := m.taskList.Items()[ci] 555 | m.taskList.SetItem(ci, itemAbove) 556 | m.taskList.SetItem(ci+1, currentItem) 557 | m.taskList.Select(ci + 1) 558 | 559 | cmd = m.updateActiveTasksSequence() 560 | cmds = append(cmds, cmd) 561 | 562 | case "K": 563 | if m.activeView != taskListView { 564 | break 565 | } 566 | 567 | if m.taskList.IsFiltered() { 568 | m.errorMsg = cannotMoveWhenFilteredMsg 569 | break 570 | } 571 | 572 | ci := m.taskList.Index() 573 | if ci == 0 { 574 | break 575 | } 576 | 577 | itemAbove := m.taskList.Items()[ci-1] 578 | currentItem := m.taskList.Items()[ci] 579 | m.taskList.SetItem(ci, itemAbove) 580 | m.taskList.SetItem(ci-1, currentItem) 581 | m.taskList.Select(ci - 1) 582 | 583 | cmd = m.updateActiveTasksSequence() 584 | cmds = append(cmds, cmd) 585 | 586 | case "u": 587 | if m.activeView != taskListView { 588 | break 589 | } 590 | 591 | if len(m.taskList.Items()) == 0 { 592 | break 593 | } 594 | 595 | listItem := m.taskList.SelectedItem() 596 | index := m.taskList.Index() 597 | t, ok := listItem.(types.Task) 598 | if !ok { 599 | m.errorMsg = somethingWentWrongMsg 600 | break 601 | } 602 | 603 | m.taskInput.SetValue(t.Summary) 604 | m.taskInput.Focus() 605 | m.taskIndex = index 606 | m.taskID = t.ID 607 | m.taskChange = taskUpdateSummary 608 | m.activeView = taskEntryView 609 | return m, tea.Batch(cmds...) 610 | 611 | case "ctrl+r": 612 | if m.activeView != taskListView && m.activeView != archivedTaskListView { 613 | break 614 | } 615 | 616 | br := false 617 | switch m.activeView { 618 | case taskListView: 619 | if m.taskList.IsFiltered() { 620 | br = true 621 | } 622 | 623 | case archivedTaskListView: 624 | if m.archivedTaskList.IsFiltered() { 625 | br = true 626 | } 627 | } 628 | 629 | if br { 630 | break 631 | } 632 | 633 | cmds = append(cmds, fetchTasks(m.db, true, pers.TaskNumLimit)) 634 | cmds = append(cmds, fetchTasks(m.db, false, pers.TaskNumLimit)) 635 | 636 | case "ctrl+d": 637 | switch m.activeView { 638 | case taskListView: 639 | if len(m.taskList.Items()) == 0 { 640 | break 641 | } 642 | 643 | if m.taskList.IsFiltered() { 644 | m.errorMsg = "Cannot archive items when the task list is filtered" 645 | break 646 | } 647 | 648 | listItem := m.taskList.SelectedItem() 649 | index := m.taskList.Index() 650 | t, ok := listItem.(types.Task) 651 | if !ok { 652 | m.errorMsg = "Something went wrong; cannot archive item" 653 | break 654 | } 655 | 656 | cmd = changeTaskStatus(m.db, index, t.ID, false, time.Now()) 657 | cmds = append(cmds, cmd) 658 | 659 | case archivedTaskListView: 660 | if len(m.archivedTaskList.Items()) == 0 { 661 | break 662 | } 663 | 664 | if m.archivedTaskList.IsFiltered() { 665 | m.errorMsg = "Cannot unarchive items when the task list is filtered" 666 | break 667 | } 668 | 669 | listItem := m.archivedTaskList.SelectedItem() 670 | index := m.archivedTaskList.Index() 671 | t, ok := listItem.(types.Task) 672 | if !ok { 673 | m.errorMsg = somethingWentWrongMsg 674 | break 675 | } 676 | 677 | cmd = changeTaskStatus(m.db, index, t.ID, true, time.Now()) 678 | cmds = append(cmds, cmd) 679 | } 680 | 681 | case "ctrl+x": 682 | if m.activeView != taskListView && m.activeView != archivedTaskListView { 683 | break 684 | } 685 | 686 | quit := false 687 | switch m.activeView { 688 | case taskListView: 689 | if len(m.taskList.Items()) == 0 { 690 | quit = true 691 | break 692 | } 693 | 694 | if m.taskList.IsFiltered() { 695 | m.errorMsg = cannotDeleteWhenFilteredMsg 696 | quit = true 697 | break 698 | } 699 | case archivedTaskListView: 700 | if len(m.archivedTaskList.Items()) == 0 { 701 | quit = true 702 | break 703 | } 704 | 705 | if m.archivedTaskList.IsFiltered() { 706 | m.errorMsg = cannotDeleteWhenFilteredMsg 707 | quit = true 708 | break 709 | } 710 | } 711 | 712 | if quit { 713 | break 714 | } 715 | 716 | if m.cfg.ConfirmBeforeDeletion && !m.showDeletePrompt { 717 | m.showDeletePrompt = true 718 | 719 | switch m.activeView { 720 | case taskListView: 721 | m.taskList.Title = "delete ?" 722 | m.taskList.Styles.Title = m.taskList.Styles.Title.Background(lipgloss.Color(promptColor)) 723 | case archivedTaskListView: 724 | m.archivedTaskList.Title = "delete ?" 725 | m.archivedTaskList.Styles.Title = m.archivedTaskList.Styles.Title.Background(lipgloss.Color(promptColor)) 726 | } 727 | 728 | break 729 | } 730 | 731 | switch m.activeView { 732 | case taskListView: 733 | index := m.taskList.Index() 734 | t, ok := m.taskList.SelectedItem().(types.Task) 735 | if !ok { 736 | m.errorMsg = somethingWentWrongMsg 737 | break 738 | } 739 | cmd = deleteTask(m.db, t.ID, index, true) 740 | cmds = append(cmds, cmd) 741 | if m.cfg.ConfirmBeforeDeletion { 742 | m.showDeletePrompt = false 743 | m.taskList.Title = m.cfg.TaskListTitle 744 | m.taskList.Styles.Title = m.taskList.Styles.Title.Background(lipgloss.Color(m.cfg.TaskListColor)) 745 | } 746 | 747 | case archivedTaskListView: 748 | index := m.archivedTaskList.Index() 749 | task, ok := m.archivedTaskList.SelectedItem().(types.Task) 750 | if !ok { 751 | m.errorMsg = somethingWentWrongMsg 752 | break 753 | } 754 | 755 | cmd = deleteTask(m.db, task.ID, index, false) 756 | cmds = append(cmds, cmd) 757 | if m.cfg.ConfirmBeforeDeletion { 758 | m.showDeletePrompt = false 759 | m.archivedTaskList.Title = archivedTitle 760 | m.archivedTaskList.Styles.Title = m.archivedTaskList.Styles.Title.Background(lipgloss.Color(m.cfg.ArchivedTaskListColor)) 761 | } 762 | } 763 | 764 | case "ctrl+p": 765 | if m.activeView != taskListView && m.activeView != archivedTaskListView { 766 | break 767 | } 768 | 769 | var taskList list.Model 770 | taskPrefixes := make(map[types.TaskPrefix]struct{}) 771 | 772 | switch m.activeView { 773 | case taskListView: 774 | taskList = m.taskList 775 | case archivedTaskListView: 776 | taskList = m.archivedTaskList 777 | } 778 | 779 | if len(taskList.Items()) == 0 { 780 | m.errorMsg = "No items in task list" 781 | break 782 | } 783 | 784 | for _, li := range taskList.Items() { 785 | t, ok := li.(types.Task) 786 | if ok { 787 | prefix, pOk := t.Prefix() 788 | if pOk { 789 | taskPrefixes[prefix] = struct{}{} 790 | } 791 | } 792 | } 793 | var prefixes []types.TaskPrefix 794 | for k := range taskPrefixes { 795 | prefixes = append(prefixes, k) 796 | } 797 | 798 | if len(prefixes) == 0 { 799 | m.errorMsg = "No prefixes in task list" 800 | break 801 | } 802 | 803 | if len(prefixes) == 1 { 804 | m.errorMsg = "Only 1 unique prefix in task list" 805 | break 806 | } 807 | 808 | sort.Slice(prefixes, func(i, j int) bool { 809 | return prefixes[i] < prefixes[j] 810 | }) 811 | 812 | pi := make([]list.Item, len(prefixes)) 813 | for i, p := range prefixes { 814 | pi[i] = list.Item(p) 815 | } 816 | 817 | m.prefixSearchList.SetItems(pi) 818 | m.lastActiveView = m.activeView 819 | m.activeView = prefixSelectionView 820 | m.prefixSearchList.Title = "filter by prefix" 821 | m.prefixSearchUse = prefixFilter 822 | 823 | case "enter": 824 | if m.activeView != taskListView && m.activeView != archivedTaskListView && m.activeView != contextBookmarksView && m.activeView != prefixSelectionView { 825 | break 826 | } 827 | switch m.activeView { 828 | case taskListView: 829 | if len(m.taskList.Items()) == 0 { 830 | break 831 | } 832 | 833 | if m.taskList.IsFiltered() { 834 | selected, ok := m.taskList.SelectedItem().(types.Task) 835 | if !ok { 836 | m.errorMsg = somethingWentWrongMsg 837 | break 838 | } 839 | 840 | listIndex, ok := m.tlIndexMap[selected.ID] 841 | if !ok { 842 | m.errorMsg = somethingWentWrongMsg 843 | break 844 | } 845 | 846 | m.taskList.ResetFilter() 847 | m.taskList.Select(listIndex) 848 | break 849 | } 850 | 851 | index := m.taskList.Index() 852 | 853 | if index == 0 { 854 | m.errorMsg = "This item is already at the top of the list" 855 | break 856 | } 857 | 858 | listItem := m.taskList.SelectedItem() 859 | m.taskList.RemoveItem(index) 860 | cmd = m.taskList.InsertItem(0, listItem) 861 | cmds = append(cmds, cmd) 862 | m.taskList.Select(0) 863 | 864 | cmd = m.updateActiveTasksSequence() 865 | cmds = append(cmds, cmd) 866 | 867 | case archivedTaskListView: 868 | if len(m.archivedTaskList.Items()) == 0 { 869 | break 870 | } 871 | 872 | if !m.archivedTaskList.IsFiltered() { 873 | break 874 | } 875 | 876 | selected, ok := m.archivedTaskList.SelectedItem().(types.Task) 877 | if !ok { 878 | m.errorMsg = somethingWentWrongMsg 879 | break 880 | } 881 | 882 | listIndex, ok := m.atlIndexMap[selected.ID] 883 | if !ok { 884 | m.errorMsg = somethingWentWrongMsg 885 | break 886 | } 887 | 888 | m.archivedTaskList.ResetFilter() 889 | m.archivedTaskList.Select(listIndex) 890 | 891 | case contextBookmarksView: 892 | uri := m.taskBMList.SelectedItem().FilterValue() 893 | cmds = append(cmds, openURI(uri)) 894 | case prefixSelectionView: 895 | prefix := m.prefixSearchList.SelectedItem().FilterValue() 896 | 897 | switch m.prefixSearchUse { 898 | case prefixFilter: 899 | var taskList list.Model 900 | 901 | switch m.activeTaskList { 902 | case activeTasks: 903 | taskList = m.taskList 904 | case archivedTasks: 905 | taskList = m.archivedTaskList 906 | } 907 | 908 | taskList.ResetFilter() 909 | var tlCmd tea.Cmd 910 | 911 | runes := []rune(prefix) 912 | 913 | if len(runes) > 1 { 914 | taskList.FilterInput.SetValue(string(runes[:len(runes)-1])) 915 | } 916 | 917 | taskList, tlCmd = taskList.Update(tea.KeyMsg{Type: -1, Runes: []int32{47}, Alt: false, Paste: false}) 918 | cmds = append(cmds, tlCmd) 919 | 920 | taskList, tlCmd = taskList.Update(tea.KeyMsg{Type: -1, Runes: []rune{runes[len(runes)-1]}, Alt: false, Paste: false}) 921 | cmds = append(cmds, tlCmd) 922 | 923 | // TODO: Try sending ENTER programmatically too 924 | // taskList, tlCmd = taskList.Update(tea.KeyMsg{Type: 13, Runes: []int32(nil), Alt: false, Paste: false}) 925 | // or 926 | // taskList, tlCmd = taskList.Update(tea.KeyEnter) 927 | // this results in the list's paginator being broken, so requires another manual ENTER keypress 928 | 929 | switch m.activeTaskList { 930 | case activeTasks: 931 | m.taskList = taskList 932 | m.activeView = taskListView 933 | case archivedTasks: 934 | m.archivedTaskList = taskList 935 | m.activeView = archivedTaskListView 936 | } 937 | 938 | return m, tea.Sequence(cmds...) 939 | 940 | case prefixChoose: 941 | m.taskInput.SetValue(getSummaryWithNewPrefix(m.taskInput.Value(), prefix)) 942 | m.activeView = taskEntryView 943 | } 944 | 945 | } 946 | 947 | case "E", "$": 948 | if m.activeView != taskListView { 949 | break 950 | } 951 | 952 | if len(m.taskList.Items()) == 0 { 953 | break 954 | } 955 | 956 | if m.taskList.IsFiltered() { 957 | m.errorMsg = cannotMoveWhenFilteredMsg 958 | break 959 | } 960 | 961 | index := m.taskList.Index() 962 | 963 | lastIndex := len(m.taskList.Items()) - 1 964 | 965 | if index == lastIndex { 966 | m.errorMsg = "This item is already at the end of the list" 967 | break 968 | } 969 | 970 | if m.taskList.IsFiltered() { 971 | m.taskList.ResetFilter() 972 | m.taskList.Select(index) 973 | } 974 | 975 | listItem := m.taskList.SelectedItem() 976 | m.taskList.RemoveItem(index) 977 | cmd = m.taskList.InsertItem(lastIndex, listItem) 978 | cmds = append(cmds, cmd) 979 | m.taskList.Select(lastIndex) 980 | 981 | cmd = m.updateActiveTasksSequence() 982 | cmds = append(cmds, cmd) 983 | 984 | case "c": 985 | if m.activeView != taskListView && m.activeView != archivedTaskListView && m.activeView != taskDetailsView { 986 | break 987 | } 988 | 989 | var t types.Task 990 | var ok bool 991 | var index int 992 | 993 | switch m.activeTaskList { 994 | case activeTasks: 995 | t, ok = m.taskList.SelectedItem().(types.Task) 996 | if !ok { 997 | m.errorMsg = somethingWentWrongMsg 998 | break 999 | } 1000 | index = m.taskList.Index() 1001 | case archivedTasks: 1002 | t, ok = m.archivedTaskList.SelectedItem().(types.Task) 1003 | if !ok { 1004 | m.errorMsg = somethingWentWrongMsg 1005 | break 1006 | } 1007 | index = m.archivedTaskList.Index() 1008 | } 1009 | 1010 | if len(m.cfg.TextEditorCmd) == 0 { 1011 | m.errorMsg = "No editor has been set via --editor, or $EDITOR or $VISUAL" 1012 | break 1013 | } 1014 | 1015 | tempFile, err := os.CreateTemp("", "omm-*.md") 1016 | if err != nil { 1017 | m.errorMsg = fmt.Sprintf("Error creating temporary file: %s", err) 1018 | break 1019 | } 1020 | if t.Context != nil { 1021 | _, err = tempFile.Write([]byte(*t.Context)) 1022 | if err != nil { 1023 | _ = tempFile.Close() 1024 | break 1025 | } 1026 | } 1027 | _ = tempFile.Close() 1028 | 1029 | cmds = append(cmds, openTextEditor(tempFile.Name(), m.cfg.TextEditorCmd, index, t.ID, t.Context)) 1030 | 1031 | case "v": 1032 | if m.activeView != taskListView && m.activeView != archivedTaskListView { 1033 | break 1034 | } 1035 | 1036 | var tlDel list.ItemDelegate 1037 | var atlDel list.ItemDelegate 1038 | 1039 | switch m.cfg.ListDensity { 1040 | case Compact: 1041 | tlDel = newSpaciousListDelegate(lipgloss.Color(m.cfg.TaskListColor), true, 1) 1042 | atlDel = newSpaciousListDelegate(lipgloss.Color(m.cfg.ArchivedTaskListColor), true, 1) 1043 | 1044 | m.cfg.ListDensity = Spacious 1045 | 1046 | case Spacious: 1047 | tlDel = compactItemDelegate{m.tlSelStyle} 1048 | atlDel = compactItemDelegate{m.atlSelStyle} 1049 | m.cfg.ListDensity = Compact 1050 | } 1051 | 1052 | m.taskList.SetDelegate(tlDel) 1053 | m.archivedTaskList.SetDelegate(atlDel) 1054 | 1055 | if m.cfg.ShowContext { 1056 | m.taskList.SetHeight(m.shortenedListHt) 1057 | m.archivedTaskList.SetHeight(m.shortenedListHt) 1058 | } 1059 | 1060 | case "C": 1061 | if m.activeView != taskListView && m.activeView != archivedTaskListView { 1062 | break 1063 | } 1064 | 1065 | m.cfg.ShowContext = !m.cfg.ShowContext 1066 | 1067 | _, h := listStyle.GetFrameSize() 1068 | _, h3 := statusBarMsgStyle.GetFrameSize() 1069 | var listHeight int 1070 | 1071 | if m.cfg.ShowContext { 1072 | listHeight = m.shortenedListHt 1073 | } else { 1074 | listHeight = m.terminalHeight - h - h3 - 1 1075 | } 1076 | 1077 | if m.cfg.ListDensity == Compact { 1078 | tlDel := compactItemDelegate{m.tlSelStyle} 1079 | atlDel := compactItemDelegate{m.atlSelStyle} 1080 | m.taskList.SetDelegate(tlDel) 1081 | m.archivedTaskList.SetDelegate(atlDel) 1082 | } 1083 | 1084 | m.taskList.SetHeight(listHeight) 1085 | m.archivedTaskList.SetHeight(listHeight) 1086 | 1087 | case "d": 1088 | if m.activeView == taskDetailsView { 1089 | m.activeView = m.lastActiveView 1090 | break 1091 | } 1092 | 1093 | if m.activeView != taskListView && m.activeView != archivedTaskListView { 1094 | break 1095 | } 1096 | 1097 | var t types.Task 1098 | var ok bool 1099 | 1100 | switch m.activeView { 1101 | case taskListView: 1102 | t, ok = m.taskList.SelectedItem().(types.Task) 1103 | case archivedTaskListView: 1104 | t, ok = m.archivedTaskList.SelectedItem().(types.Task) 1105 | } 1106 | 1107 | if !ok { 1108 | break 1109 | } 1110 | 1111 | m.taskDetailsVP.GotoTop() 1112 | m.setContextFSContent(t) 1113 | 1114 | switch m.activeView { 1115 | case taskListView: 1116 | m.activeTaskList = activeTasks 1117 | default: 1118 | m.activeTaskList = archivedTasks 1119 | } 1120 | m.lastActiveView = m.activeView 1121 | m.activeView = taskDetailsView 1122 | 1123 | case "h": 1124 | if m.activeView != taskDetailsView { 1125 | break 1126 | } 1127 | var t types.Task 1128 | var ok bool 1129 | 1130 | switch m.activeTaskList { 1131 | case activeTasks: 1132 | m.taskList.CursorUp() 1133 | t, ok = m.taskList.SelectedItem().(types.Task) 1134 | case archivedTasks: 1135 | m.archivedTaskList.CursorUp() 1136 | t, ok = m.archivedTaskList.SelectedItem().(types.Task) 1137 | } 1138 | 1139 | if !ok { 1140 | break 1141 | } 1142 | 1143 | m.taskDetailsVP.GotoTop() 1144 | m.setContextFSContent(t) 1145 | 1146 | case "l": 1147 | if m.activeView != taskDetailsView { 1148 | break 1149 | } 1150 | var t types.Task 1151 | var ok bool 1152 | 1153 | switch m.activeTaskList { 1154 | case activeTasks: 1155 | m.taskList.CursorDown() 1156 | t, ok = m.taskList.SelectedItem().(types.Task) 1157 | case archivedTasks: 1158 | m.archivedTaskList.CursorDown() 1159 | t, ok = m.archivedTaskList.SelectedItem().(types.Task) 1160 | } 1161 | 1162 | if !ok { 1163 | break 1164 | } 1165 | 1166 | m.taskDetailsVP.GotoTop() 1167 | m.setContextFSContent(t) 1168 | 1169 | case "b": 1170 | if m.activeView != taskListView && m.activeView != archivedTaskListView { 1171 | break 1172 | } 1173 | 1174 | uris, ok := m.getTaskURIs() 1175 | if !ok { 1176 | break 1177 | } 1178 | 1179 | if len(uris) == 0 { 1180 | m.errorMsg = "No bookmarks for this task" 1181 | break 1182 | } 1183 | 1184 | if len(uris) == 1 { 1185 | cmds = append(cmds, openURI(uris[0])) 1186 | break 1187 | } 1188 | 1189 | bmItems := make([]list.Item, len(uris)) 1190 | for i, uri := range uris { 1191 | bmItems[i] = list.Item(types.ContextBookmark(uri)) 1192 | } 1193 | m.taskBMList.SetItems(bmItems) 1194 | switch m.activeView { 1195 | case taskListView: 1196 | m.activeTaskList = activeTasks 1197 | case archivedTaskListView: 1198 | m.activeTaskList = archivedTasks 1199 | } 1200 | m.lastActiveView = m.activeView 1201 | m.activeView = contextBookmarksView 1202 | 1203 | case "B": 1204 | if m.activeView != taskListView && m.activeView != archivedTaskListView && m.activeView != taskDetailsView { 1205 | break 1206 | } 1207 | 1208 | uris, ok := m.getTaskURIs() 1209 | if !ok { 1210 | break 1211 | } 1212 | 1213 | if len(uris) == 0 { 1214 | m.errorMsg = "No bookmarks for this task" 1215 | break 1216 | } 1217 | 1218 | if len(uris) == 1 { 1219 | cmds = append(cmds, openURI(uris[0])) 1220 | break 1221 | } 1222 | 1223 | if m.rtos == types.GOOSDarwin { 1224 | cmds = append(cmds, openURIsDarwin(uris)) 1225 | break 1226 | } 1227 | 1228 | for _, uri := range uris { 1229 | cmds = append(cmds, openURI(uri)) 1230 | } 1231 | 1232 | case "y": 1233 | if m.activeView != taskListView && m.activeView != archivedTaskListView && m.activeView != taskDetailsView { 1234 | break 1235 | } 1236 | 1237 | var t types.Task 1238 | var ok bool 1239 | 1240 | switch m.activeView { 1241 | case taskListView: 1242 | t, ok = m.taskList.SelectedItem().(types.Task) 1243 | case archivedTaskListView: 1244 | t, ok = m.archivedTaskList.SelectedItem().(types.Task) 1245 | case taskDetailsView: 1246 | switch m.activeTaskList { 1247 | case activeTasks: 1248 | t, ok = m.taskList.SelectedItem().(types.Task) 1249 | case archivedTasks: 1250 | t, ok = m.archivedTaskList.SelectedItem().(types.Task) 1251 | } 1252 | } 1253 | 1254 | if !ok { 1255 | break 1256 | } 1257 | 1258 | if t.Context == nil { 1259 | m.errorMsg = "There's no context to copy" 1260 | break 1261 | } 1262 | 1263 | cmds = append(cmds, copyContextToClipboard(*t.Context)) 1264 | } 1265 | 1266 | case HideHelpMsg: 1267 | m.showHelpIndicator = false 1268 | 1269 | case taskCreatedMsg: 1270 | if msg.err != nil { 1271 | m.errorMsg = fmt.Sprintf("Error creating task: %s", msg.err) 1272 | break 1273 | } 1274 | 1275 | t := types.Task{ 1276 | ID: msg.id, 1277 | Summary: msg.taskSummary, 1278 | Active: true, 1279 | CreatedAt: msg.createdAt, 1280 | UpdatedAt: msg.updatedAt, 1281 | } 1282 | entry := list.Item(t) 1283 | cmd = m.taskList.InsertItem(m.taskIndex, entry) 1284 | cmds = append(cmds, cmd) 1285 | m.taskList.Select(m.taskIndex) 1286 | 1287 | cmd = m.updateActiveTasksSequence() 1288 | cmds = append(cmds, cmd) 1289 | 1290 | case taskDeletedMsg: 1291 | if msg.err != nil { 1292 | m.errorMsg = fmt.Sprintf("Error deleting task: %s", msg.err) 1293 | break 1294 | } 1295 | 1296 | switch msg.active { 1297 | case true: 1298 | m.taskList.RemoveItem(msg.listIndex) 1299 | cmd = m.updateActiveTasksSequence() 1300 | cmds = append(cmds, cmd) 1301 | case false: 1302 | m.archivedTaskList.RemoveItem(msg.listIndex) 1303 | m.updateArchivedTasksIndex() 1304 | } 1305 | 1306 | case taskSequenceUpdatedMsg: 1307 | if msg.err != nil { 1308 | m.errorMsg = fmt.Sprintf("Error updating task sequence: %s", msg.err) 1309 | } 1310 | 1311 | case taskSummaryUpdatedMsg: 1312 | if msg.err != nil { 1313 | m.errorMsg = fmt.Sprintf("Error updating task: %s", msg.err) 1314 | } else { 1315 | listItem := m.taskList.Items()[msg.listIndex] 1316 | t, ok := listItem.(types.Task) 1317 | if !ok { 1318 | break 1319 | } 1320 | 1321 | t.Summary = msg.taskSummary 1322 | t.UpdatedAt = msg.updatedAt 1323 | cmd = m.taskList.SetItem(msg.listIndex, list.Item(t)) 1324 | cmds = append(cmds, cmd) 1325 | } 1326 | 1327 | case taskContextUpdatedMsg: 1328 | if msg.err != nil { 1329 | m.errorMsg = fmt.Sprintf("Error updating task: %s", msg.err) 1330 | } else { 1331 | var t types.Task 1332 | var ok bool 1333 | 1334 | switch msg.list { 1335 | case activeTasks: 1336 | listItem := m.taskList.Items()[msg.listIndex] 1337 | t, ok = listItem.(types.Task) 1338 | if !ok { 1339 | break 1340 | } 1341 | 1342 | if msg.context == "" { 1343 | t.Context = nil 1344 | } else { 1345 | t.Context = &msg.context 1346 | } 1347 | t.UpdatedAt = msg.updatedAt 1348 | cmd = m.taskList.SetItem(msg.listIndex, list.Item(t)) 1349 | cmds = append(cmds, cmd) 1350 | case archivedTasks: 1351 | listItem := m.archivedTaskList.Items()[msg.listIndex] 1352 | t, ok = listItem.(types.Task) 1353 | if !ok { 1354 | break 1355 | } 1356 | 1357 | if msg.context == "" { 1358 | t.Context = nil 1359 | } else { 1360 | t.Context = &msg.context 1361 | } 1362 | t.UpdatedAt = msg.updatedAt 1363 | cmd = m.archivedTaskList.SetItem(msg.listIndex, list.Item(t)) 1364 | cmds = append(cmds, cmd) 1365 | } 1366 | 1367 | if m.activeView == taskDetailsView { 1368 | m.taskDetailsVP.GotoTop() 1369 | m.setContextFSContent(t) 1370 | } 1371 | // to force refresh 1372 | m.contextVPTaskID = 0 1373 | } 1374 | 1375 | case taskStatusChangedMsg: 1376 | if msg.err != nil { 1377 | m.errorMsg = fmt.Sprintf("Error deleting task: %s", msg.err) 1378 | } else { 1379 | switch msg.active { 1380 | case true: 1381 | item := m.archivedTaskList.Items()[msg.listIndex] 1382 | oldIndex := m.taskList.Index() 1383 | 1384 | t, ok := item.(types.Task) 1385 | if !ok { 1386 | break 1387 | } 1388 | t.UpdatedAt = msg.updatedAt 1389 | m.taskList.InsertItem(0, list.Item(t)) 1390 | m.taskList.Select(oldIndex + 1) 1391 | m.archivedTaskList.RemoveItem(msg.listIndex) 1392 | case false: 1393 | item := m.taskList.Items()[msg.listIndex] 1394 | 1395 | t, ok := item.(types.Task) 1396 | if !ok { 1397 | break 1398 | } 1399 | 1400 | t.UpdatedAt = msg.updatedAt 1401 | m.archivedTaskList.InsertItem(0, list.Item(t)) 1402 | m.taskList.RemoveItem(msg.listIndex) 1403 | } 1404 | cmd = m.updateActiveTasksSequence() 1405 | m.updateArchivedTasksIndex() 1406 | cmds = append(cmds, cmd) 1407 | } 1408 | 1409 | case tasksFetched: 1410 | if msg.err != nil { 1411 | message := "error fetching tasks : " + msg.err.Error() 1412 | m.errorMsg = message 1413 | } else { 1414 | switch msg.active { 1415 | case true: 1416 | taskItems := make([]list.Item, len(msg.tasks)) 1417 | for i, t := range msg.tasks { 1418 | taskItems[i] = t 1419 | } 1420 | m.taskList.SetItems(taskItems) 1421 | m.taskList.Select(0) 1422 | 1423 | tlIndexMap := make(map[uint64]int) 1424 | for i, ti := range m.taskList.Items() { 1425 | t, ok := ti.(types.Task) 1426 | if ok { 1427 | tlIndexMap[t.ID] = i 1428 | } 1429 | } 1430 | m.tlIndexMap = tlIndexMap 1431 | 1432 | case false: 1433 | archivedTaskItems := make([]list.Item, len(msg.tasks)) 1434 | for i, t := range msg.tasks { 1435 | archivedTaskItems[i] = t 1436 | } 1437 | m.archivedTaskList.SetItems(archivedTaskItems) 1438 | m.archivedTaskList.Select(0) 1439 | m.updateArchivedTasksIndex() 1440 | } 1441 | } 1442 | case textEditorClosed: 1443 | if msg.err != nil { 1444 | m.errorMsg = fmt.Sprintf("%s: %s", somethingWentWrongMsg, msg.err) 1445 | _ = os.Remove(msg.fPath) 1446 | break 1447 | } 1448 | 1449 | context, err := os.ReadFile(msg.fPath) 1450 | if err != nil { 1451 | break 1452 | } 1453 | 1454 | err = os.Remove(msg.fPath) 1455 | if err != nil { 1456 | m.errorMsg = fmt.Sprintf("warning: omm failed to remove temporary file: %s", err) 1457 | } 1458 | 1459 | if len(context) > pers.ContextMaxBytes { 1460 | m.errorMsg = "The content you entered is too large, maybe shorten it" 1461 | // TODO: allow reopening the text editor with the same content again 1462 | break 1463 | } 1464 | 1465 | if len(context) == 0 && msg.oldContext == nil { 1466 | break 1467 | } 1468 | 1469 | cmds = append(cmds, updateTaskContext(m.db, msg.taskIndex, msg.taskID, string(context), m.activeTaskList)) 1470 | case uriOpenedMsg: 1471 | if msg.err != nil { 1472 | m.errorMsg = fmt.Sprintf("Error opening uri: %s", msg.err) 1473 | } 1474 | case urisOpenedDarwinMsg: 1475 | if msg.err != nil { 1476 | m.errorMsg = fmt.Sprintf("Error opening uris: %s", msg.err) 1477 | } 1478 | 1479 | case contextWrittenToCBMsg: 1480 | if msg.err != nil { 1481 | m.errorMsg = fmt.Sprintf("Couldn't copy context to clipboard: %s", msg.err) 1482 | } else { 1483 | m.successMsg = "Context copied to clipboard!" 1484 | } 1485 | } 1486 | 1487 | var viewUpdateCmd tea.Cmd 1488 | switch m.activeView { 1489 | case taskListView: 1490 | if !skipListUpdate { 1491 | m.taskList, viewUpdateCmd = m.taskList.Update(msg) 1492 | } 1493 | 1494 | if !m.cfg.ShowContext { 1495 | break 1496 | } 1497 | 1498 | t, ok := m.taskList.SelectedItem().(types.Task) 1499 | if !ok { 1500 | break 1501 | } 1502 | 1503 | if m.contextVPTaskID == t.ID { 1504 | break 1505 | } 1506 | 1507 | var detailsToRender string 1508 | switch t.Context { 1509 | case nil: 1510 | detailsToRender = noContextMsg 1511 | default: 1512 | detailsToRender = *t.Context 1513 | switch m.contextMdRenderer { 1514 | case nil: 1515 | break 1516 | default: 1517 | contextGl, err := m.contextMdRenderer.Render(*t.Context) 1518 | if err != nil { 1519 | break 1520 | } 1521 | detailsToRender = contextGl 1522 | } 1523 | } 1524 | 1525 | m.contextVP.SetContent(detailsToRender) 1526 | m.contextVPTaskID = t.ID 1527 | 1528 | case archivedTaskListView: 1529 | if !skipListUpdate { 1530 | m.archivedTaskList, viewUpdateCmd = m.archivedTaskList.Update(msg) 1531 | } 1532 | 1533 | if !m.cfg.ShowContext { 1534 | break 1535 | } 1536 | 1537 | t, ok := m.archivedTaskList.SelectedItem().(types.Task) 1538 | if !ok { 1539 | break 1540 | } 1541 | 1542 | if m.contextVPTaskID == t.ID { 1543 | break 1544 | } 1545 | 1546 | if t.Context != nil { 1547 | if m.contextMdRenderer != nil { 1548 | contextGl, err := m.contextMdRenderer.Render(*t.Context) 1549 | if err != nil { 1550 | m.contextVP.SetContent(*t.Context) 1551 | } else { 1552 | m.contextVP.SetContent(contextGl) 1553 | } 1554 | } else { 1555 | m.contextVP.SetContent(*t.Context) 1556 | } 1557 | } else { 1558 | m.contextVP.SetContent(noContextMsg) 1559 | } 1560 | m.contextVPTaskID = t.ID 1561 | 1562 | case taskEntryView: 1563 | m.taskInput, viewUpdateCmd = m.taskInput.Update(msg) 1564 | 1565 | case taskDetailsView: 1566 | m.taskDetailsVP, viewUpdateCmd = m.taskDetailsVP.Update(msg) 1567 | 1568 | case contextBookmarksView: 1569 | if !skipListUpdate { 1570 | m.taskBMList, viewUpdateCmd = m.taskBMList.Update(msg) 1571 | } 1572 | 1573 | case prefixSelectionView: 1574 | if !skipListUpdate { 1575 | m.prefixSearchList, viewUpdateCmd = m.prefixSearchList.Update(msg) 1576 | } 1577 | 1578 | case helpView: 1579 | m.helpVP, viewUpdateCmd = m.helpVP.Update(msg) 1580 | } 1581 | 1582 | cmds = append(cmds, viewUpdateCmd) 1583 | 1584 | return m, tea.Batch(cmds...) 1585 | } 1586 | 1587 | func (m *Model) updateActiveTasksSequence() tea.Cmd { 1588 | sequence := make([]uint64, len(m.taskList.Items())) 1589 | tlIndexMap := make(map[uint64]int) 1590 | 1591 | for i, ti := range m.taskList.Items() { 1592 | t, ok := ti.(types.Task) 1593 | if ok { 1594 | sequence[i] = t.ID 1595 | tlIndexMap[t.ID] = i 1596 | } 1597 | } 1598 | 1599 | m.tlIndexMap = tlIndexMap 1600 | 1601 | return updateTaskSequence(m.db, sequence) 1602 | } 1603 | 1604 | func (m *Model) updateArchivedTasksIndex() { 1605 | sequence := make([]uint64, len(m.archivedTaskList.Items())) 1606 | tlIndexMap := make(map[uint64]int) 1607 | 1608 | for i, ti := range m.archivedTaskList.Items() { 1609 | t, ok := ti.(types.Task) 1610 | if ok { 1611 | sequence[i] = t.ID 1612 | tlIndexMap[t.ID] = i 1613 | } 1614 | } 1615 | 1616 | m.atlIndexMap = tlIndexMap 1617 | } 1618 | 1619 | func (m Model) isSpaceAvailable() bool { 1620 | return len(m.taskList.Items()) < pers.TaskNumLimit 1621 | } 1622 | 1623 | func (m *Model) setContextFSContent(task types.Task) { 1624 | var ctx string 1625 | if task.Context != nil { 1626 | ctx = fmt.Sprintf("---\n%s", *task.Context) 1627 | } 1628 | 1629 | details := fmt.Sprintf(`- summary : %s 1630 | - created at : %s 1631 | - last updated at : %s 1632 | 1633 | %s 1634 | `, task.Summary, task.CreatedAt.Format(timeFormat), task.UpdatedAt.Format(timeFormat), ctx) 1635 | 1636 | if m.taskDetailsMdRenderer != nil { 1637 | detailsGl, err := m.taskDetailsMdRenderer.Render(details) 1638 | if err == nil { 1639 | m.taskDetailsVP.SetContent(detailsGl) 1640 | return 1641 | } 1642 | } 1643 | m.taskDetailsVP.SetContent(details) 1644 | } 1645 | 1646 | func (m Model) getTaskURIs() ([]string, bool) { 1647 | var t types.Task 1648 | var ok bool 1649 | 1650 | switch m.activeView { 1651 | case taskListView: 1652 | t, ok = m.taskList.SelectedItem().(types.Task) 1653 | case archivedTaskListView: 1654 | t, ok = m.archivedTaskList.SelectedItem().(types.Task) 1655 | case taskDetailsView: 1656 | switch m.activeTaskList { 1657 | case activeTasks: 1658 | t, ok = m.taskList.SelectedItem().(types.Task) 1659 | case archivedTasks: 1660 | t, ok = m.archivedTaskList.SelectedItem().(types.Task) 1661 | } 1662 | } 1663 | if !ok { 1664 | return nil, false 1665 | } 1666 | 1667 | var uris []string 1668 | uris = append(uris, utils.ExtractURIs(m.uriRegex, t.Summary)...) 1669 | if t.Context != nil { 1670 | uris = append(uris, utils.ExtractURIs(m.uriRegex, *t.Context)...) 1671 | } 1672 | 1673 | return uris, true 1674 | } 1675 | -------------------------------------------------------------------------------- /internal/ui/utils.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/dhth/omm/internal/types" 8 | ) 9 | 10 | func getSummaryWithNewPrefix(summary, newPrefix string) string { 11 | if strings.TrimSpace(summary) == "" { 12 | return fmt.Sprintf("%s: ", newPrefix) 13 | } 14 | 15 | summEls := strings.Split(summary, types.PrefixDelimiter) 16 | 17 | if len(summEls) == 1 { 18 | return fmt.Sprintf("%s: %s", newPrefix, summary) 19 | } 20 | 21 | if summEls[1] == "" { 22 | return fmt.Sprintf("%s: ", newPrefix) 23 | } 24 | 25 | return fmt.Sprintf("%s:%s", newPrefix, strings.Join(summEls[1:], types.PrefixDelimiter)) 26 | } 27 | 28 | func getPrefix(summary string) (string, bool) { 29 | if strings.TrimSpace(summary) == "" { 30 | return "", false 31 | } 32 | 33 | summEls := strings.Split(summary, types.PrefixDelimiter) 34 | 35 | if len(summEls) == 1 { 36 | return "", false 37 | } 38 | 39 | return strings.TrimSpace(summEls[0]), true 40 | } 41 | -------------------------------------------------------------------------------- /internal/ui/utils_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestGetSummaryWithNewPrefix(t *testing.T) { 10 | testCases := []struct { 11 | name string 12 | summary string 13 | newPrefix string 14 | expected string 15 | }{ 16 | { 17 | name: "empty summary", 18 | summary: "", 19 | newPrefix: "new", 20 | expected: "new: ", 21 | }, 22 | { 23 | name: "just whitespace", 24 | summary: " ", 25 | newPrefix: "new", 26 | expected: "new: ", 27 | }, 28 | { 29 | name: "just a colon", 30 | summary: ":", 31 | newPrefix: "new", 32 | expected: "new: ", 33 | }, 34 | { 35 | name: "just summary content", 36 | summary: "this is a task", 37 | newPrefix: "new", 38 | expected: "new: this is a task", 39 | }, 40 | { 41 | name: "just a prefix", 42 | summary: "old:", 43 | newPrefix: "new", 44 | expected: "new: ", 45 | }, 46 | { 47 | name: "prefix and summary content", 48 | summary: "old: this is a task", 49 | newPrefix: "new", 50 | expected: "new: this is a task", 51 | }, 52 | { 53 | name: "prefix and summary content without space", 54 | summary: "old:this is a task", 55 | newPrefix: "new", 56 | expected: "new:this is a task", 57 | }, 58 | { 59 | name: "summary with two colons", 60 | summary: "old: this: is a task", 61 | newPrefix: "new", 62 | expected: "new: this: is a task", 63 | }, 64 | } 65 | 66 | for _, tt := range testCases { 67 | t.Run(tt.name, func(t *testing.T) { 68 | got := getSummaryWithNewPrefix(tt.summary, tt.newPrefix) 69 | 70 | assert.Equal(t, tt.expected, got) 71 | }) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /internal/ui/view.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/lipgloss" 7 | "github.com/dhth/omm/internal/utils" 8 | ) 9 | 10 | var TaskListDefaultTitle = "omm" 11 | 12 | func (m Model) View() string { 13 | if m.quitting { 14 | return "" 15 | } 16 | 17 | var content string 18 | var context string 19 | var statusBar string 20 | var listEmpty bool 21 | 22 | if m.showHelpIndicator && (m.activeView != helpView) { 23 | statusBar += helpMsgStyle.Render("Press ? for help") 24 | } 25 | 26 | if m.showDeletePrompt { 27 | statusBar += promptStyle.Render("press ctrl+x again to delete, any other key to cancel") 28 | } 29 | 30 | if m.errorMsg != "" && m.successMsg != "" { 31 | statusBar += fmt.Sprintf("%s%s", 32 | sBErrMsgStyle.Render(utils.Trim(m.errorMsg, (m.terminalWidth/2)-3)), 33 | sBSuccessMsgStyle.Render(utils.Trim(m.successMsg, (m.terminalWidth/2)-3)), 34 | ) 35 | } else if m.errorMsg != "" { 36 | statusBar += sBErrMsgStyle.Render(m.errorMsg) 37 | } else if m.successMsg != "" { 38 | statusBar += sBSuccessMsgStyle.Render(m.successMsg) 39 | } 40 | 41 | switch m.activeView { 42 | case taskListView: 43 | 44 | if len(m.taskList.Items()) > 0 { 45 | content = listStyle.Render(m.taskList.View()) 46 | } else { 47 | content = fmt.Sprintf(` 48 | %s 49 | 50 | %s`, m.tlTitleStyle.Render(m.cfg.TaskListTitle), formStyle.Render("No items. Press a/o to add one.\n")) 51 | listEmpty = true 52 | } 53 | 54 | case archivedTaskListView: 55 | if len(m.archivedTaskList.Items()) > 0 { 56 | content = listStyle.Render(m.archivedTaskList.View()) 57 | } else { 58 | content = fmt.Sprintf(` 59 | %s 60 | 61 | %s`, m.atlTitleStyle.Render(archivedTitle), formStyle.Render("No items. You archive items by pressing ctrl+d.\n")) 62 | listEmpty = true 63 | } 64 | 65 | case taskEntryView: 66 | switch m.taskChange { 67 | case taskInsert: 68 | header := taskEntryTitleStyle.Render("enter your task") 69 | 70 | var newTaskPosition string 71 | if m.taskIndex == 0 { 72 | newTaskPosition = "at the top" 73 | } else if m.taskIndex == len(m.taskList.Items()) { 74 | newTaskPosition = "at the end" 75 | } else { 76 | newTaskPosition = fmt.Sprintf("at position %d", m.taskIndex+1) 77 | } 78 | content = fmt.Sprintf(` 79 | %s 80 | 81 | %s 82 | 83 | %s 84 | 85 | %s 86 | 87 | %s`, 88 | header, 89 | formHelpStyle.Render(fmt.Sprintf("task will be added %s", newTaskPosition)), 90 | formHelpStyle.Render("omm picks up the prefix in a task summary like 'prefix: do something'\n and highlights it for you in the task list"), 91 | m.taskInput.View(), 92 | formHelpStyle.Render("press to go back, ⏎ to submit"), 93 | ) 94 | 95 | for range m.terminalHeight - 12 { 96 | content += "\n" 97 | } 98 | case taskUpdateSummary: 99 | header := taskEntryTitleStyle.Render("update task") 100 | content = fmt.Sprintf(` 101 | %s 102 | 103 | %s 104 | 105 | %s 106 | 107 | %s`, 108 | header, 109 | formHelpStyle.Render("omm picks up the prefix in a task summary like 'prefix: do something'\n and highlights it for you in the task list"), 110 | m.taskInput.View(), 111 | formHelpStyle.Render("press to go back, ⏎ to submit"), 112 | ) 113 | for range m.terminalHeight - 10 { 114 | content += "\n" 115 | } 116 | 117 | } 118 | 119 | case taskDetailsView: 120 | var spVal string 121 | sp := int(m.taskDetailsVP.ScrollPercent() * 100) 122 | if sp < 100 { 123 | spVal = helpMsgStyle.Render(fmt.Sprintf(" %d%% ↓", sp)) 124 | } 125 | header := fmt.Sprintf("%s%s", taskDetailsTitleStyle.Render("task details"), spVal) 126 | if !m.taskDetailsVPReady { 127 | content = headerStyle.Render(header) + "\n" + "Initializing..." 128 | } else { 129 | content = headerStyle.Render(header) + "\n" + m.taskDetailsVP.View() 130 | } 131 | 132 | case contextBookmarksView: 133 | content = listStyle.Render(m.taskBMList.View()) 134 | 135 | case prefixSelectionView: 136 | content = listStyle.Render(m.prefixSearchList.View()) 137 | 138 | case helpView: 139 | header := fmt.Sprintf(` 140 | %s %s 141 | 142 | `, helpTitleStyle.Render("help"), helpMsgStyle.Render("(scroll with j/k/↓/↑)")) 143 | if !m.helpVPReady { 144 | content = "Initializing..." 145 | } else { 146 | content = header + m.helpVP.View() 147 | } 148 | } 149 | 150 | var components []string 151 | components = append(components, content) 152 | 153 | if !listEmpty && m.cfg.ShowContext && (m.activeView == taskListView || m.activeView == archivedTaskListView) { 154 | 155 | if !m.contextVPReady { 156 | context = "Initializing..." 157 | } else { 158 | context = fmt.Sprintf(" %s\n\n%s", 159 | contextTitleStyle.Render("context"), 160 | m.contextVP.View(), 161 | ) 162 | } 163 | components = append(components, context) 164 | } 165 | 166 | components = append(components, statusBar) 167 | 168 | return lipgloss.JoinVertical(lipgloss.Left, components...) 169 | } 170 | -------------------------------------------------------------------------------- /internal/utils/assets/gruvbox.json: -------------------------------------------------------------------------------- 1 | { 2 | "document": { 3 | "block_prefix": "", 4 | "block_suffix": "", 5 | "color": "#fbf1c7", 6 | "margin": 2 7 | }, 8 | "block_quote": { 9 | "indent": 1, 10 | "indent_token": "┃ " 11 | }, 12 | "paragraph": {}, 13 | "list": { 14 | "level_indent": 2 15 | }, 16 | "heading": { 17 | "block_suffix": "\n", 18 | "color": "#fe8019", 19 | "bold": true 20 | }, 21 | "h1": { 22 | "prefix": "# ", 23 | "suffix": "", 24 | "color": "#fe8019", 25 | "bold": true 26 | }, 27 | "h2": { 28 | "prefix": "## ", 29 | "color": "#83a598" 30 | }, 31 | "h3": { 32 | "prefix": "### ", 33 | "color": "#83a598" 34 | }, 35 | "h4": { 36 | "prefix": "#### ", 37 | "color": "#83a598" 38 | }, 39 | "h5": { 40 | "prefix": "", 41 | "color": "#fabd2f" 42 | }, 43 | "h6": { 44 | "prefix": "", 45 | "color": "#8ec07c", 46 | "bold": false 47 | }, 48 | "text": {}, 49 | "strikethrough": { 50 | "crossed_out": true 51 | }, 52 | "emph": { 53 | "color": "#83a598", 54 | "italic": true 55 | }, 56 | "strong": { 57 | "color": "#fe8019", 58 | "bold": true 59 | }, 60 | "hr": { 61 | "color": "#928374", 62 | "format": "\n--------\n" 63 | }, 64 | "item": { 65 | "block_prefix": "• " 66 | }, 67 | "enumeration": { 68 | "block_prefix": ". " 69 | }, 70 | "task": { 71 | "ticked": "[✔] ", 72 | "unticked": "[ ] " 73 | }, 74 | "link": { 75 | "color": "#83a598", 76 | "underline": true 77 | }, 78 | "link_text": { 79 | "color": "#fabd2f", 80 | "bold": true 81 | }, 82 | "image": { 83 | "color": "132", 84 | "underline": true 85 | }, 86 | "image_text": { 87 | "color": "245", 88 | "format": "Image: {{.text}} →" 89 | }, 90 | "code": { 91 | "prefix": " ", 92 | "suffix": " ", 93 | "color": "#b8bb26", 94 | "background_color": "#3c3836" 95 | }, 96 | "code_block": { 97 | "color": "244", 98 | "chroma": { 99 | "text": { 100 | "color": "#fbf1c7" 101 | }, 102 | "error": { 103 | "color": "#282828", 104 | "background_color": "#fb4934" 105 | }, 106 | "comment": { 107 | "color": "#928374" 108 | }, 109 | "comment_preproc": { 110 | "color": "#8ec07c" 111 | }, 112 | "keyword": { 113 | "color": "#fe8019" 114 | }, 115 | "keyword_reserved": { 116 | "color": "#8ec07c" 117 | }, 118 | "keyword_namespace": { 119 | "color": "#d3869b" 120 | }, 121 | "keyword_type": { 122 | "color": "#fabd2f" 123 | }, 124 | "operator": { 125 | "color": "#fe8019" 126 | }, 127 | "punctuation": { 128 | "color": "#928374" 129 | }, 130 | "name": { 131 | "color": "#ebdbb2" 132 | }, 133 | "name_builtin": { 134 | "color": "#fabd2f" 135 | }, 136 | "name_tag": { 137 | "color": "#fb4934" 138 | }, 139 | "name_attribute": { 140 | "color": "#b8bb26" 141 | }, 142 | "name_class": { 143 | "color": "#fe8019" 144 | }, 145 | "name_constant": { 146 | "color": "#d3869b" 147 | }, 148 | "name_decorator": { 149 | "color": "#d3869b" 150 | }, 151 | "name_exception": { 152 | "color": "#fb4934" 153 | }, 154 | "name_function": { 155 | "color": "#fabd2f" 156 | }, 157 | "name_other": {}, 158 | "literal": {}, 159 | "literal_number": { 160 | "color": "#fabd2f" 161 | }, 162 | "literal_date": {}, 163 | "literal_string": { 164 | "color": "#b8bb26" 165 | }, 166 | "literal_string_escape": { 167 | "color": "#83a598" 168 | }, 169 | "generic_deleted": { 170 | "color": "#fb4934" 171 | }, 172 | "generic_emph": { 173 | "color": "#83a598", 174 | "italic": true 175 | }, 176 | "generic_inserted": { 177 | "color": "#b8bb26" 178 | }, 179 | "generic_strong": { 180 | "color": "#ebdbb2", 181 | "bold": true 182 | }, 183 | "generic_subheading": { 184 | "color": "#b8bb26" 185 | }, 186 | "background": { 187 | "background_color": "#282828" 188 | } 189 | } 190 | }, 191 | "table": { 192 | "center_separator": "┼", 193 | "column_separator": "│", 194 | "row_separator": "─" 195 | }, 196 | "definition_list": {}, 197 | "definition_term": {}, 198 | "definition_description": { 199 | "block_prefix": "\n🠶 " 200 | }, 201 | "html_block": {}, 202 | "html_span": {} 203 | } 204 | -------------------------------------------------------------------------------- /internal/utils/markdown.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | _ "embed" 5 | 6 | "github.com/charmbracelet/glamour" 7 | "github.com/muesli/termenv" 8 | ) 9 | 10 | //go:embed assets/gruvbox.json 11 | var glamourJSONBytes []byte 12 | 13 | func GetMarkDownRenderer(wrap int) (*glamour.TermRenderer, error) { 14 | return glamour.NewTermRenderer( 15 | glamour.WithStylesFromJSONBytes(glamourJSONBytes), 16 | glamour.WithColorProfile(termenv.TrueColor), 17 | glamour.WithPreservedNewLines(), 18 | glamour.WithWordWrap(wrap), 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /internal/utils/markdown_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/charmbracelet/glamour" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestGetGlamourStyleFromFile(t *testing.T) { 12 | gotOption := glamour.WithStylesFromJSONBytes(glamourJSONBytes) 13 | renderer, err := glamour.NewTermRenderer(gotOption) 14 | assert.NoError(t, err) 15 | assert.NotNil(t, renderer) 16 | 17 | _, err = renderer.Render("a") 18 | assert.NoError(t, err) 19 | } 20 | 21 | func TestGlamourStylesFileIsValid(t *testing.T) { 22 | got := json.Valid(glamourJSONBytes) 23 | assert.True(t, got) 24 | } 25 | -------------------------------------------------------------------------------- /internal/utils/urls.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | 7 | "mvdan.cc/xurls/v2" 8 | ) 9 | 10 | // duplicated from https://github.com/mvdan/xurls/blob/master/xurls.go#L83-L88 as xurls doesn't let the user extend or 11 | // override SchemesNoAuthority 12 | var ( 13 | knownSchemes = []string{ 14 | `cid`, 15 | `file`, 16 | `magnet`, 17 | `mailto`, 18 | `mid`, 19 | `sms`, 20 | `tel`, 21 | `xmpp`, 22 | `spotify`, 23 | `facetime`, 24 | `facetime-audio`, 25 | } 26 | anyScheme = `(?:[a-zA-Z][a-zA-Z.\-+]*://|` + anyOf(knownSchemes...) + `:)` 27 | ) 28 | 29 | func anyOf(strs ...string) string { 30 | var b strings.Builder 31 | b.WriteString("(?:") 32 | for i, s := range strs { 33 | if i != 0 { 34 | b.WriteByte('|') 35 | } 36 | b.WriteString(regexp.QuoteMeta(s)) 37 | } 38 | b.WriteByte(')') 39 | return b.String() 40 | } 41 | 42 | func GetURIRegex() *regexp.Regexp { 43 | rgx, err := xurls.StrictMatchingScheme(anyScheme) 44 | if err != nil { 45 | return xurls.Strict() 46 | } 47 | 48 | return rgx 49 | } 50 | 51 | func ExtractURIs(rg *regexp.Regexp, text string) []string { 52 | return rg.FindAllString(text, -1) 53 | } 54 | -------------------------------------------------------------------------------- /internal/utils/urls_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestExtractURIs(t *testing.T) { 10 | testCases := []struct { 11 | name string 12 | input string 13 | expected []string 14 | }{ 15 | // success 16 | { 17 | name: "just a url", 18 | input: `https://someurl.com`, 19 | expected: []string{ 20 | "https://someurl.com", 21 | }, 22 | }, 23 | { 24 | name: "a url with path", 25 | input: `https://someurl.com/path/1`, 26 | expected: []string{ 27 | "https://someurl.com/path/1", 28 | }, 29 | }, 30 | { 31 | name: "a url with query parameters", 32 | input: `https://someurl.com?param=value`, 33 | expected: []string{ 34 | "https://someurl.com?param=value", 35 | }, 36 | }, 37 | { 38 | name: "two urls", 39 | input: `https://someurl.com 40 | https://anotherurl.com`, 41 | expected: []string{ 42 | "https://someurl.com", 43 | "https://anotherurl.com", 44 | }, 45 | }, 46 | { 47 | name: "urls in a paragraph", 48 | input: `A paragraph full of details, containing urls like https://someurl.com/path?query=value 49 | and https://anotherurl.com/path?query=value at several points.`, 50 | expected: []string{ 51 | "https://someurl.com/path?query=value", 52 | "https://anotherurl.com/path?query=value", 53 | }, 54 | }, 55 | { 56 | name: "urls ending with commas and braces", 57 | input: `A paragraph full of details, containing urls 58 | (eg. https://someurl.com/path?query=value, https://anotherurl.com/path?query=value) 59 | at several points.`, 60 | expected: []string{ 61 | "https://someurl.com/path?query=value", 62 | "https://anotherurl.com/path?query=value", 63 | }, 64 | }, 65 | { 66 | name: "uris with custom schemes", 67 | input: ` 68 | slack link: slack://open?team=T12345678&id=C12345678 69 | obsidian link without space after the colon:obsidian://open?vault=VAULT&file=FILE 70 | maps: maps://?q=Central+Park,New+York 71 | a scheme from the future: skynet://someresource?query=param 72 | `, 73 | expected: []string{ 74 | "slack://open?team=T12345678&id=C12345678", 75 | "obsidian://open?vault=VAULT&file=FILE", 76 | "maps://?q=Central+Park,New+York", 77 | "skynet://someresource?query=param", 78 | }, 79 | }, 80 | { 81 | name: "known schemes that use `:`", 82 | input: ` 83 | mail: mailto:example@example.com 84 | telephone: tel:+1234567890 85 | spotify: spotify:track:6rqhFgbbKwnb9MLmUQDhG6 86 | facetime: facetime:example@example.com 87 | facetime-audio: facetime-audio:example@example.com 88 | `, 89 | expected: []string{ 90 | "mailto:example@example.com", 91 | "tel:+1234567890", 92 | "spotify:track:6rqhFgbbKwnb9MLmUQDhG6", 93 | "facetime:example@example.com", 94 | "facetime-audio:example@example.com", 95 | }, 96 | }, 97 | // failures 98 | { 99 | name: "doesn't match a uri without a scheme", 100 | input: `someurl.com`, 101 | }, 102 | { 103 | name: "unknown schemes that use `:`", 104 | input: "unknown: unknown:example@example.com", 105 | }, 106 | } 107 | 108 | for _, tt := range testCases { 109 | t.Run(tt.name, func(t *testing.T) { 110 | rgx := GetURIRegex() 111 | 112 | got := ExtractURIs(rgx, tt.input) 113 | 114 | assert.Equal(t, tt.expected, got) 115 | if len(tt.expected) > 0 { 116 | assert.Equal(t, tt.expected, got) 117 | } else { 118 | assert.Nil(t, got) 119 | } 120 | }) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /internal/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | func RightPadTrim(s string, length int, dots bool) string { 11 | if len(s) >= length { 12 | if dots && length > 3 { 13 | return s[:length-3] + "..." 14 | } 15 | return s[:length] 16 | } 17 | return s + strings.Repeat(" ", length-len(s)) 18 | } 19 | 20 | func Trim(s string, length int) string { 21 | if len(s) >= length { 22 | if length > 3 { 23 | return s[:length-3] + "..." 24 | } 25 | return s[:length] 26 | } 27 | return s 28 | } 29 | 30 | func HumanizeDuration(durationInSecs int) string { 31 | duration := time.Duration(durationInSecs) * time.Second 32 | 33 | if duration.Seconds() < 60 { 34 | return fmt.Sprintf("%ds", int(duration.Seconds())) 35 | } 36 | 37 | if duration.Minutes() < 60 { 38 | return fmt.Sprintf("%dm", int(duration.Minutes())) 39 | } 40 | 41 | modMins := int(math.Mod(duration.Minutes(), 60)) 42 | 43 | if modMins == 0 { 44 | return fmt.Sprintf("%dh", int(duration.Hours())) 45 | } 46 | 47 | return fmt.Sprintf("%dh %dm", int(duration.Hours()), modMins) 48 | } 49 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "runtime/debug" 6 | 7 | "github.com/dhth/omm/cmd" 8 | ) 9 | 10 | var version = "dev" 11 | 12 | func main() { 13 | v := version 14 | if version == "dev" { 15 | info, ok := debug.ReadBuildInfo() 16 | if ok { 17 | v = info.Main.Version 18 | } 19 | } 20 | err := cmd.Execute(v) 21 | if err != nil { 22 | os.Exit(1) 23 | } 24 | } 25 | --------------------------------------------------------------------------------