├── rust-toolchain.toml ├── env.example ├── screenshots ├── undo.png ├── new-task.png ├── top_bars.png ├── full_page.png ├── tag-search.png ├── active_task.png ├── task_details.png ├── custom_queries_list.png ├── custom_queries_selection.png └── task_search_by_id_text_box.png ├── app.http ├── .dockerignore ├── .idea ├── codeStyles │ ├── codeStyleConfig.xml │ └── Project.xml ├── vcs.xml ├── .gitignore ├── jsLibraryMappings.xml ├── modules.xml └── git_toolbox_prj.xml ├── config.sample.toml ├── .gitignore ├── frontend ├── package.json ├── tailwind.config.js ├── rollup.config.js ├── templates │ ├── desc.html │ ├── base.html │ ├── task_action_bar.html │ ├── active_task.html │ ├── flash_msg.html │ ├── query_bar.html │ ├── tag_bar.html │ ├── undo_report.html │ ├── task_delete_confirm.html │ ├── task_add.html │ ├── left_action_bar.html │ ├── task_details.html │ └── tasks.html ├── src │ ├── theme.ts │ └── main.ts └── css │ └── style.css ├── .run ├── Test all.run.xml ├── Run taskwarrior-web.run.xml └── Dockerfile.run.xml ├── src ├── endpoints │ ├── mod.rs │ └── tasks │ │ ├── task_query_builder │ │ └── tests.rs │ │ ├── task_modify.rs │ │ └── task_query_builder.rs ├── backend │ ├── mod.rs │ └── serde.rs ├── core │ ├── mod.rs │ ├── utils.rs │ ├── errors.rs │ ├── app.rs │ ├── config.rs │ └── cache.rs └── lib.rs ├── Cargo.toml ├── .github └── workflows │ ├── docker.yml │ └── rust.yml ├── docker └── start.sh ├── CHANGELOG.md ├── Dockerfile └── README.md /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "nightly" 3 | -------------------------------------------------------------------------------- /env.example: -------------------------------------------------------------------------------- 1 | TWK_SERVER_PORT=9080 2 | DISPLAY_TIME_OF_THE_DAY=1 3 | TWK_USE_FONT='Maple Mono' 4 | -------------------------------------------------------------------------------- /screenshots/undo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmahmood/taskwarrior-web/main/screenshots/undo.png -------------------------------------------------------------------------------- /screenshots/new-task.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmahmood/taskwarrior-web/main/screenshots/new-task.png -------------------------------------------------------------------------------- /screenshots/top_bars.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmahmood/taskwarrior-web/main/screenshots/top_bars.png -------------------------------------------------------------------------------- /screenshots/full_page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmahmood/taskwarrior-web/main/screenshots/full_page.png -------------------------------------------------------------------------------- /screenshots/tag-search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmahmood/taskwarrior-web/main/screenshots/tag-search.png -------------------------------------------------------------------------------- /screenshots/active_task.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmahmood/taskwarrior-web/main/screenshots/active_task.png -------------------------------------------------------------------------------- /screenshots/task_details.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmahmood/taskwarrior-web/main/screenshots/task_details.png -------------------------------------------------------------------------------- /screenshots/custom_queries_list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmahmood/taskwarrior-web/main/screenshots/custom_queries_list.png -------------------------------------------------------------------------------- /app.http: -------------------------------------------------------------------------------- 1 | ### List all tasks 2 | GET localhost:3000/tasks 3 | 4 | 5 | ### List all projects 6 | GET localhost:3000/projects 7 | 8 | 9 | -------------------------------------------------------------------------------- /screenshots/custom_queries_selection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmahmood/taskwarrior-web/main/screenshots/custom_queries_selection.png -------------------------------------------------------------------------------- /screenshots/task_search_by_id_text_box.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmahmood/taskwarrior-web/main/screenshots/task_search_by_id_text_box.png -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | 2 | target 3 | frontend/node_modules 4 | dist 5 | .env 6 | tailwindcss 7 | screenshots 8 | .idea 9 | .github 10 | *.json 11 | *.out 12 | README.md -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.idea/jsLibraryMappings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /config.sample.toml: -------------------------------------------------------------------------------- 1 | [custom_queries] 2 | 3 | [custom_queries.one_query] 4 | description = "report of something" 5 | 6 | [custom_queries.two_query] 7 | description = "report of another thing" 8 | fixed_key = "n" # this will override randomly generated key -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | /target/ 26 | /data.json 27 | /output.out 28 | /data_edit.json 29 | /.env 30 | /tailwindcss 31 | /frontend/package-lock.json 32 | /lcov.info 33 | /coverage -------------------------------------------------------------------------------- /.idea/git_toolbox_prj.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 14 | 15 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "devDependencies": { 7 | "@rollup/plugin-commonjs": "^28.0.2", 8 | "@rollup/plugin-inject": "^5.0.5", 9 | "@rollup/plugin-node-resolve": "^16.0.0", 10 | "@rollup/plugin-typescript": "^12.1.2", 11 | "@tailwindcss/forms": "^0.5.10", 12 | "@tailwindcss/typography": "^0.5.16", 13 | "@tailwindcss/cli": "^4.1.3", 14 | "daisyui": "^5.0", 15 | "hotkeys-js": "^3.13.9", 16 | "htmx.org": "2.0.0-beta4", 17 | "hyperscript.org": "^0.9.14", 18 | "postcss": "^8.5.3", 19 | "rollup": "^4.38.0", 20 | "tailwindcss": "^4.0.9", 21 | "tslib": "^2.8.1", 22 | "typescript": "^5.7.3" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.run/Test all.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 20 | -------------------------------------------------------------------------------- /src/endpoints/mod.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025 Tarin Mahmood 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | * 6 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | * 8 | * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | */ 10 | 11 | 12 | pub mod tasks; -------------------------------------------------------------------------------- /src/backend/mod.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025 Tarin Mahmood 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | * 6 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | * 8 | * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | */ 10 | 11 | 12 | pub mod task; 13 | pub(crate) mod serde; -------------------------------------------------------------------------------- /src/core/mod.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025 Tarin Mahmood 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | * 6 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | * 8 | * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | */ 10 | 11 | pub mod app; 12 | pub mod cache; 13 | pub mod config; 14 | pub mod errors; 15 | pub mod utils; 16 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "taskwarrior-web" 3 | version = "2.0.1" 4 | edition = "2024" 5 | resolver = "3" 6 | 7 | [dependencies] 8 | axum = { version = "0.8.1", features = ["multipart"] } 9 | serde = { version = "1.0.197", features = ["derive"] } 10 | tokio = { version = "1.36.0", features = ["full", "parking_lot", "tracing"] } 11 | tracing = "0.1.40" 12 | tracing-subscriber = { version = "0.3.18", features = [ 13 | "env-filter", 14 | "parking_lot", 15 | ] } 16 | serde_json = "1.0.114" 17 | tera = { version = "1.20.0" } 18 | anyhow = "1.0.80" 19 | lazy_static = "1.4.0" 20 | tower = "0.5.2" 21 | tower-http = { version = "0.6.2", features = ["fs", "tracing", "trace"] } 22 | chrono = { version = "0.4.34", features = ["serde"] } 23 | csv = "1.3.1" 24 | indexmap = { version = "2.2.5", features = ["serde"] } 25 | rand = "0.9.0-beta.3" 26 | dotenvy = { version = "0.15.7" } 27 | taskchampion = { version = "2.0.3", default-features = false, features = [] } 28 | serde_path_to_error = "0.1.17" 29 | shell-words = "1.1.0" 30 | directories = "6.0.0" 31 | toml = "0.8.22" 32 | config = { version = "0.15.11", default-features = false, features = ["toml"] } 33 | linkify = "0.10.0" 34 | listenfd = "1.0.2" 35 | 36 | [dev-dependencies] 37 | tempfile = "3.19.1" 38 | -------------------------------------------------------------------------------- /frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025 Tarin Mahmood 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | * 6 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | * 8 | * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | */ 10 | 11 | /** @type {import('tailwindcss').Config} */ 12 | module.exports = { 13 | content: [ 14 | "templates/*.html", 15 | "src/*.ts", 16 | ], 17 | safelist: [ 18 | 'link' 19 | ] 20 | } 21 | 22 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | upload-tag: 7 | type: string 8 | default: "nightly" 9 | push: 10 | branches: 11 | - "main" 12 | pull_request: 13 | branches: 14 | - "main" 15 | 16 | env: 17 | REGISTRY: ghcr.io 18 | IMAGE_NAME: ${{ github.repository }} 19 | 20 | jobs: 21 | docker: 22 | runs-on: ubuntu-latest 23 | permissions: 24 | contents: read 25 | packages: write 26 | attestations: write 27 | id-token: write 28 | steps: 29 | - name: Login to Docker Hub 30 | # if: github.event_name != 'pull_request' 31 | uses: docker/login-action@v3 32 | with: 33 | registry: ${{ env.REGISTRY }} 34 | username: ${{ github.actor }} 35 | password: ${{ secrets.GITHUB_TOKEN }} 36 | 37 | - name: Extract metadata (tags, labels) for Docker 38 | id: meta 39 | uses: docker/metadata-action@v5 40 | with: 41 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 42 | 43 | - name: Build and push 44 | uses: docker/build-push-action@v6 45 | with: 46 | push: ${{ github.event_name != 'pull_request' }} 47 | tags: ${{ steps.meta.outputs.tags }} 48 | labels: ${{ steps.meta.outputs.labels }} 49 | -------------------------------------------------------------------------------- /frontend/rollup.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025 Tarin Mahmood 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | * 6 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | * 8 | * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | */ 10 | 11 | import typescript from "@rollup/plugin-typescript"; 12 | import resolve from '@rollup/plugin-node-resolve'; 13 | import commonjs from '@rollup/plugin-commonjs'; 14 | 15 | export default { 16 | compilerOptions: {}, 17 | plugins: [ 18 | typescript(), 19 | resolve(), 20 | commonjs(), 21 | ], 22 | input: 'frontend/src/main.ts', 23 | output: { 24 | file: 'dist/bundle.js', 25 | } 26 | }; -------------------------------------------------------------------------------- /docker/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 4 | # Copyright 2025 Tarin Mahmood 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | # 8 | # The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | # 10 | # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | # 12 | 13 | set -e 14 | DOTENV_FILE="$HOME/.env" 15 | # create empty dot env file 16 | echo "" > $DOTENV_FILE 17 | 18 | while IFS='=' read -r -d '' n v; do 19 | if [[ $n == TASK_WEB_* ]]; then 20 | echo "${n/TASK_WEB_/}=\"$v\"" >> $DOTENV_FILE 21 | fi 22 | done < <(env -0) 23 | 24 | # check if taskrc exists. 25 | if [[ ! -f "$TASKRC" ]]; then 26 | echo "yes" | task || true 27 | fi 28 | 29 | cd $HOME/bin 30 | exec ./taskwarrior-web & 31 | pid=$! 32 | trap 'kill -SIGTERM $pid; wait $pid' SIGTERM 33 | wait $pidh 34 | -------------------------------------------------------------------------------- /frontend/templates/desc.html: -------------------------------------------------------------------------------- 1 | {% macro desc(task) %} 2 | {% if task.annotations %} 3 | 12 | 13 |

14 | {{ task.description | linkify | safe }} 15 |

16 | 23 | {% else %} 24 |

25 | {{ task.description | linkify | safe }} 26 |

27 | {% endif %} 28 | {% endmacro desc %} 29 | -------------------------------------------------------------------------------- /frontend/templates/base.html: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 23 | 24 | 25 | Org.Me 26 | 27 | 28 | 29 |
30 |
31 | 32 |
{% include "tasks.html" %}
33 |
34 |
35 |
36 | 37 | 38 | -------------------------------------------------------------------------------- /.run/Run taskwarrior-web.run.xml: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 33 | -------------------------------------------------------------------------------- /frontend/templates/task_action_bar.html: -------------------------------------------------------------------------------- 1 | 10 | 11 |
12 | 20 | 21 | 34 |
35 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Sun 04, May, 2025 2 | - [x] Remember shortcuts for project and tags in a cache file. 3 | Default cache path: $HOME/.cache/taskwarrior-web/mnemonics.cache 4 | 5 | ## Thu 01, May, 2025 6 | - [x] Improved CI pipeline in order to increase continues quality checks. 7 | - [x] Improved mobile view: proper scaling of the pages as well as headers are now really sticky, not fixed anymore. This ensures, that the header does not cover any tasks on small displays. 8 | 9 | ## Tue 29, April, 2025 10 | - [x] Rework of task modifications. 11 | - [x] Adding absolute dates in task details. 12 | - [x] Added possibility to delete tasks. 13 | - [x] For denotation its now possible to select specific annotations. 14 | 15 | ## Fri 28, March, 2025 16 | - [x] Font's can be customized 17 | - [x] Removed packaged font 18 | 19 | ## Tue 04, March, 2025 20 | 21 | - [x] BIG UI UPDATE 22 | - [x] Using tailwindcss 4 23 | - [x] Using daisyui for UI components 24 | - [x] All the projects and tags are listed in single place when making selection 25 | - [x] Dialog boxes are now using default dialog element instead of hacked out modal windows 26 | - [x] Tag selection, Task selection UI should be more responsive 27 | 28 | ## Tue 15, 2024 29 | 30 | - [x] Added time of the day widget 31 | - [x] Project is now split by `.` in the tag bar 32 | 33 | ## Sat 07, 2024 34 | - [1412] 35 | - [x] Top headers are now sticky, so when displaying a long list of tasks and executing any action do not jump to top 36 | - [x] Notification is more visible 37 | - [x] UI is a little bit polished, and cleaner, small tweaks in spacing 38 | - [2125] 39 | - [x] Fixed the way toast is displayed, 40 | - [x] Escape key to close the toast 41 | - [x] Clicking on empty space focuses Cmd Bar 42 | 43 | ## Mon 29, 2024 03:05 44 | - [x] Highlighting due column of a task if due 45 | 46 | ## Mon 29, 2024 47 | - [x] [BUG] Mnemonic tag in the last row in task selection mode getting cut 48 | 49 | ## Fri 26 and older 50 | - [x] Now using GitHub action to create release builds 51 | - [x] Marking a task done with keyboard shortcut 52 | - [x] Bug fix, not unmarking completed task 53 | - [x] Modification 54 | - [x] Editing 55 | - [x] Stopping active task from List 56 | - [x] Starting tasks 57 | - [x] Annotation/Denotation 58 | - [x] Error handling 59 | - [x] Returning error message in case error occurred 60 | - [x] Which port to run 61 | -------------------------------------------------------------------------------- /frontend/templates/active_task.html: -------------------------------------------------------------------------------- 1 | 10 | 11 |
12 | {% if active_task %} 13 |
14 | 15 |
16 |
18 | {{ active_task.description }} 19 |
20 | 21 | {{ timer_value(date=active_task.start) }} 22 | 23 | 32 |
33 |
34 | {% endif %} 35 |
-------------------------------------------------------------------------------- /frontend/templates/flash_msg.html: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 42 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | upload-tag: 7 | type: string 8 | default: "nightly" 9 | push: 10 | branches: [ "main", "main-dev" ] 11 | pull_request: 12 | branches: [ "main" ] 13 | 14 | env: 15 | CARGO_TERM_COLOR: always 16 | 17 | jobs: 18 | test_and_coverage: 19 | name: Test and create coverage report. 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v4 24 | - name: Install Taskwarrior 25 | run: | 26 | sudo apt-get update 27 | echo "confirmation=off" | sudo tee -a /root/.taskrc 28 | sudo DEBIAN_FRONTEND=noninteractive apt-get -yq install taskwarrior 29 | echo "yes" | task || true 30 | - name: Run Tests and Upload Coverage 31 | uses: Reloaded-Project/devops-rust-test-and-coverage@v1 32 | with: 33 | setup-rust-cache: true 34 | rust-project-path: '.' 35 | rust-toolchain: 'nightly' 36 | use-tarpaulin: true 37 | upload-coverage: false 38 | target: 'x86_64-unknown-linux-gnu' 39 | - uses: actions/upload-artifact@v4 40 | with: 41 | name: "Coverage report" 42 | path: cobertura.xml 43 | 44 | build: 45 | runs-on: ubuntu-latest 46 | steps: 47 | - name: Get current date 48 | id: date 49 | run: echo "BUILD_DATE=$(date +'%Y-%m-%d')" >> $GITHUB_ENV 50 | - name: Set env 51 | run: | 52 | echo "TAG_NAME=${{ env.BUILD_DATE }}" >> $GITHUB_ENV 53 | echo "RELEASE_NAME=${{ env.BUILD_DATE }}" >> $GITHUB_ENV 54 | - name: Checkout 55 | uses: actions/checkout@v4 56 | - name: Setup rust 57 | uses: actions-rust-lang/setup-rust-toolchain@v1 58 | with: 59 | toolchain: nightly 60 | - name: Cache 61 | uses: actions/cache@v4 62 | with: 63 | path: | 64 | ~/.cargo/registry 65 | ~/.cargo/git 66 | target 67 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 68 | - name: Building 69 | run: | 70 | cargo build --release 71 | cp target/release/taskwarrior-web taskwarrior-web 72 | tar czf tw-web-${{ env.RELEASE_NAME }}.tar.gz dist taskwarrior-web 73 | - name: Release 74 | uses: softprops/action-gh-release@v2 75 | if: ${{ github.event_name != 'pull_request' }} 76 | with: 77 | prerelease: true 78 | tag_name: ${{ env.TAG_NAME }} 79 | files: tw-web-${{ env.RELEASE_NAME }}.tar.gz 80 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM archlinux:latest AS rustbase 2 | RUN pacman -Suy --needed --noconfirm curl base-devel npm 3 | RUN mkdir /app \ 4 | && curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs > rustup.sh \ 5 | && chmod +x rustup.sh \ 6 | && ./rustup.sh -y --default-toolchain nightly 7 | 8 | FROM rustbase AS buildapp 9 | # Copy files 10 | COPY ./Cargo.toml /app/Cargo.toml 11 | COPY ./frontend /app/frontend 12 | COPY ./src /app/src 13 | COPY ./build.rs /app/ 14 | RUN cd /app \ 15 | && source $HOME/.cargo/env \ 16 | && cargo build --release 17 | 18 | FROM archlinux:latest 19 | ARG TASK_ADDON_BUGWARRIOR="false" 20 | ARG TASK_ADDON_BUGWARRIOR_FEATURES="" 21 | 22 | # Install 23 | RUN echo "NoExtract = !usr/share/doc/timew/*" >> /etc/pacman.conf \ 24 | && echo "NoExtract = !usr/share/doc/task/*" >> /etc/pacman.conf \ 25 | && pacman -Suy --needed --noconfirm git python \ 26 | && pacman -S --noconfirm task timew cronie vi \ 27 | && pacman -S --noconfirm python-pip \ 28 | && useradd -m -d /app task && passwd -d task \ 29 | && mkdir -p /app/bin \ 30 | && mkdir -p /app/taskdata \ 31 | && mkdir -p /app/.task/hooks \ 32 | && mkdir -p /app/.timewarrior/data/ \ 33 | && mkdir -p /app/.config/taskwarrior-web/ \ 34 | && chown -R task:task /app && chmod -R 775 /app \ 35 | && systemctl enable cronie.service \ 36 | && cp /usr/share/doc/timew/ext/on-modify.timewarrior /app/.task/hooks/on-modify.timewarrior \ 37 | && chmod +x /app/.task/hooks/on-modify.timewarrior \ 38 | && ( [[ $TASK_ADDON_BUGWARRIOR != "true" ]] || python3 -m pip install --break-system-packages bugwarrior[$TASK_ADDON_BUGWARRIOR_FEATURES]@git+https://github.com/GothenburgBitFactory/bugwarrior.git ) \ 39 | # cleanup 40 | && pacman --noconfirm -R git python-pip \ 41 | && echo "delete orphaned" \ 42 | && pacman --noconfirm -Qdtq | pacman --noconfirm -Rs - \ 43 | && echo "clear cache" \ 44 | && pacman --noconfirm -Sc \ 45 | && echo "clean folders" \ 46 | && rm -Rf /var/cache \ 47 | && rm -Rf /var/log \ 48 | && rm -Rf /var/db \ 49 | && rm -Rf /var/lib \ 50 | && rm -Rf /usr/include \ 51 | && rm -Rf /run 52 | 53 | ENV HOME=/app 54 | WORKDIR /app 55 | 56 | # Copy files 57 | COPY docker/start.sh /app/bin/start.sh 58 | COPY --from=buildapp /app/dist /app/bin/dist 59 | COPY --from=buildapp /app/target/release/taskwarrior-web /app/bin/taskwarrior-web 60 | 61 | RUN chown -R task /app \ 62 | && chmod +x /app/bin/start.sh 63 | 64 | USER task 65 | 66 | EXPOSE 3000 67 | 68 | # Taskwarrior data volume 69 | VOLUME /app/taskdata/ 70 | VOLUME /app/.timewarrior/ 71 | VOLUME /app/.config/taskwarrior-web/ 72 | 73 | ENV TASKRC="/app/.taskrc" 74 | ENV TASKDATA="/app/taskdata" 75 | WORKDIR /app 76 | 77 | ENTRYPOINT ["/app/bin/start.sh"] 78 | -------------------------------------------------------------------------------- /frontend/templates/query_bar.html: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 |
13 |
14 | 22 | 23 | 34 |
35 | {% for shortcut, query in custom_queries_map %} 36 |
37 |
38 | 48 |
49 |
50 | {% endfor %} 51 |
52 | 53 | -------------------------------------------------------------------------------- /.run/Dockerfile.run.xml: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 14 | 15 | 25 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /frontend/templates/tag_bar.html: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 |
13 |
14 | 22 | 23 | 34 |
35 | {% for tag, shortcut in tags_map %} 36 | {% if tag is keyword_tag %} 37 | {% endif %} 38 | {% if tag is user_tag %} 39 | {% endif %} 40 |
41 |
42 | 60 |
61 |
62 | {% endfor %} 63 |
64 | 65 | -------------------------------------------------------------------------------- /src/endpoints/tasks/task_query_builder/tests.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025 Tarin Mahmood 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | * 6 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | * 8 | * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | */ 10 | 11 | 12 | use super::*; 13 | use crate::endpoints::tasks::read_task_file; 14 | 15 | #[test] 16 | fn modifying_existing_task_query() { 17 | let p = TWGlobalState { 18 | query: Some("priority:H".to_string()), 19 | report: None, 20 | ..TWGlobalState::default() 21 | }; 22 | let mut task_query = TaskQuery::new(p); 23 | task_query.update(TWGlobalState { 24 | report: None, 25 | status: Some("pending".to_string()), 26 | ..TWGlobalState::default() 27 | }); 28 | assert_eq!( 29 | &task_query.as_filter_text().join(" "), 30 | "priority:H status:pending" 31 | ) 32 | } 33 | 34 | #[test] 35 | fn with_priority_string_with_status() { 36 | let p = TWGlobalState { 37 | query: Some("priority:H".to_string()), 38 | report: None, 39 | status: Some("pending".to_string()), 40 | ..TWGlobalState::default() 41 | }; 42 | let task_query = TaskQuery::new(p); 43 | assert_eq!( 44 | &task_query.as_filter_text().join(" "), 45 | "priority:H status:pending" 46 | ) 47 | } 48 | 49 | #[test] 50 | fn with_priority_string_with_no_status() { 51 | let p = TWGlobalState { 52 | query: Some("priority:H".to_string()), 53 | report: None, 54 | ..TWGlobalState::default() 55 | }; 56 | let task_query = TaskQuery::new(p); 57 | assert_eq!(&task_query.as_filter_text().join(" "), "priority:H next") 58 | } 59 | 60 | #[test] 61 | fn with_empty_search_param() { 62 | let p = TWGlobalState { 63 | report: None, 64 | ..TWGlobalState::default() 65 | }; 66 | let task_query = TaskQuery::new(p); 67 | assert_eq!(&task_query.as_filter_text().join(" "), "next") 68 | } 69 | 70 | #[test] 71 | fn when_containing_status() { 72 | let p = TWGlobalState { 73 | report: None, 74 | status: Some("completed".to_string()), 75 | ..TWGlobalState::default() 76 | }; 77 | let query = TaskQuery::new(p).as_filter_text(); 78 | assert_eq!(&query.join(" "), "status:completed") 79 | } 80 | 81 | #[test] 82 | fn task_by_uuid() { 83 | let mut p = TWGlobalState::default(); 84 | let test_uuid = "794618dd-7a41-4aca-ab2e-70cc4a04b5e6".to_string(); 85 | p.filter = Some(test_uuid); 86 | let t = TaskQuery::new(p); 87 | println!("{:?}", t); 88 | println!("{:?}", t.as_filter_text()); 89 | let tasks = read_task_file(&t).unwrap(); 90 | println!("{:#?}", tasks); 91 | } 92 | -------------------------------------------------------------------------------- /src/core/utils.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025 Tarin Mahmood 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | * 6 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | * 8 | * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | */ 10 | 11 | use rand::distr::{Alphanumeric, SampleString}; 12 | use std::collections::HashSet; 13 | use tracing::{error, trace}; 14 | 15 | use super::{app::AppState, cache::MnemonicsType}; 16 | 17 | pub fn make_shortcut(shortcuts: &mut HashSet) -> String { 18 | let alpha = Alphanumeric::default(); 19 | let mut len = 2; 20 | let mut tries = 0; 21 | loop { 22 | let shortcut = alpha.sample_string(&mut rand::rng(), len).to_lowercase(); 23 | if !shortcuts.contains(&shortcut) { 24 | shortcuts.insert(shortcut.clone()); 25 | return shortcut; 26 | } 27 | tries += 1; 28 | if tries > 1000 { 29 | len += 1; 30 | if len > 3 { 31 | panic!("too many shortcuts! this should not happen"); 32 | } 33 | tries = 0; 34 | } 35 | } 36 | } 37 | 38 | pub fn make_shortcut_cache(mn_type: MnemonicsType, key: &str, app_state: &AppState) -> String { 39 | let alpha = Alphanumeric::default(); 40 | let mut len = 2; 41 | let mut tries = 0; 42 | // Check if available in cache. 43 | let shortcut_cache = app_state 44 | .app_cache 45 | .read() 46 | .unwrap() 47 | .get(mn_type.clone(), key); 48 | if let Some(shortcut_cache) = shortcut_cache { 49 | return shortcut_cache; 50 | } 51 | 52 | loop { 53 | let shortcut = alpha.sample_string(&mut rand::rng(), len).to_lowercase(); 54 | let shortcut_insert = 55 | app_state 56 | .app_cache 57 | .write() 58 | .unwrap() 59 | .insert(mn_type.clone(), key, &shortcut, false); 60 | if shortcut_insert.is_ok() { 61 | trace!( 62 | "Searching shortcut for type {:?} with key {} and found {}", 63 | &mn_type, 64 | key, 65 | &shortcut 66 | ); 67 | return shortcut; 68 | } else { 69 | error!( 70 | "Failed generating and saving shortcut {} for type {:?} - error: {:?}", 71 | shortcut, 72 | &mn_type, 73 | shortcut_insert.err() 74 | ); 75 | } 76 | tries += 1; 77 | if tries > 1000 { 78 | len += 1; 79 | if len > 3 { 80 | panic!("too many shortcuts! this should not happen"); 81 | } 82 | tries = 0; 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /frontend/src/theme.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025 Tarin Mahmood 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | * 6 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | * 8 | * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | */ 10 | 11 | export const SUPPORTED_THEMES = ["taskwarrior-dark", "taskwarrior-light"]; 12 | export const THEME_ICONS = ["⚹", "☽", "🌣"]; 13 | const STORAGE_THEME_KEY = "TWK_THEME"; 14 | const DOM_THEME_KEY = "data-theme"; 15 | 16 | function getThemeStorage() : string | null { 17 | const theme = localStorage.getItem(STORAGE_THEME_KEY); 18 | return theme; 19 | } 20 | 21 | function getThemeDom() : string | null { 22 | const theme = document.getElementsByTagName('html')[0].getAttribute(DOM_THEME_KEY); 23 | return theme; 24 | } 25 | 26 | function getTheme() : string | null { 27 | const themeStorage = getThemeStorage(); 28 | const themeDom = getThemeDom(); 29 | 30 | return themeDom === null ? themeStorage : themeDom; 31 | } 32 | 33 | function setTheme(theme: string | null, overrideStorage: boolean = true) : boolean { 34 | if (theme === null) { 35 | if (overrideStorage) { 36 | localStorage.removeItem(STORAGE_THEME_KEY); 37 | } 38 | document.getElementsByTagName('html')[0].removeAttribute(DOM_THEME_KEY); 39 | } else { 40 | if (overrideStorage) { 41 | localStorage.setItem(STORAGE_THEME_KEY, theme); 42 | } 43 | document.getElementsByTagName('html')[0].setAttribute(DOM_THEME_KEY, theme); 44 | } 45 | 46 | let themeIndex = -1; 47 | if (theme != null) { 48 | themeIndex = SUPPORTED_THEMES.indexOf(theme); 49 | } 50 | const iconIndex = themeIndex + 1; 51 | document.getElementById('theme-switcher')?.innerText = THEME_ICONS.at(iconIndex); 52 | 53 | return true; 54 | } 55 | 56 | export function switchTheme() { 57 | const currentTheme = getTheme(); 58 | let themeIndex = -1; 59 | if (currentTheme != null) { 60 | themeIndex = SUPPORTED_THEMES.indexOf(currentTheme); 61 | } 62 | themeIndex = themeIndex + 1; 63 | if (themeIndex >= SUPPORTED_THEMES.length) { 64 | themeIndex = -1; 65 | } 66 | 67 | if (themeIndex >= 0) { 68 | setTheme(SUPPORTED_THEMES.at(themeIndex)!); 69 | } else { 70 | setTheme(null); 71 | } 72 | } 73 | 74 | export function init() { 75 | // If a theme is already set on storage, force it! 76 | const theme = getThemeStorage(); 77 | if (theme != null) { 78 | setTheme(theme!); 79 | return; 80 | } 81 | 82 | // Ensure, that the icon is set correctly. 83 | // Do not override the storage! 84 | // This part is only done, if nothing is given yet in storage. 85 | const themeDom = getThemeDom(); 86 | setTheme(themeDom!, false); 87 | } -------------------------------------------------------------------------------- /frontend/css/style.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025 Tarin Mahmood 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | * 6 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | * 8 | * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | */ 10 | 11 | @import "tailwindcss"; 12 | @config "../tailwind.config.js"; 13 | @plugin "daisyui" { 14 | themes: taskwarrior-light, taskwarrior-dark; 15 | root: ":root"; 16 | darktheme: "taskwarrior-dark"; 17 | logs: false; 18 | } 19 | 20 | @plugin "daisyui/theme" { 21 | name: "taskwarrior-dark"; 22 | default: false; 23 | prefersdark: true; 24 | color-scheme: "dark"; 25 | --color-base-100: oklch(21% 0.006 56.043); 26 | --color-base-200: oklch(14% 0.004 49.25); 27 | --color-base-300: oklch(0% 0 0); 28 | --color-base-content: oklch(84.955% 0 0); 29 | --color-primary: oklch(50% 0.134 242.749); 30 | --color-primary-content: oklch(19.693% 0.004 196.779); 31 | --color-secondary: oklch(43% 0 0); 32 | --color-secondary-content: oklch(89.196% 0.049 305.03); 33 | --color-accent: oklch(55% 0.027 264.364); 34 | --color-accent-content: oklch(0% 0 0); 35 | --color-neutral: oklch(37% 0.034 259.733); 36 | --color-neutral-content: oklch(84.874% 0.009 65.681); 37 | --color-info: oklch(54.615% 0.215 262.88); 38 | --color-info-content: oklch(90.923% 0.043 262.88); 39 | --color-success: oklch(62.705% 0.169 149.213); 40 | --color-success-content: oklch(12.541% 0.033 149.213); 41 | --color-warning: oklch(66.584% 0.157 58.318); 42 | --color-warning-content: oklch(13.316% 0.031 58.318); 43 | --color-error: oklch(65.72% 0.199 27.33); 44 | --color-error-content: oklch(13.144% 0.039 27.33); 45 | --radius-selector: 0.25rem; 46 | --radius-field: 0.25rem; 47 | --radius-box: 0.25rem; 48 | --size-selector: 0.25rem; 49 | --size-field: 0.25rem; 50 | --border: 1px; 51 | --depth: 1; 52 | --noise: 0; 53 | } 54 | 55 | @plugin "daisyui/theme" { 56 | name: "taskwarrior-light"; 57 | default: false; 58 | prefersdark: false; 59 | color-scheme: "light"; 60 | --color-base-100: oklch(96% 0.059 95.617); 61 | --color-base-200: oklch(88.272% 0.049 91.774); 62 | --color-base-300: oklch(84.133% 0.065 90.856); 63 | --color-base-content: oklch(44% 0.011 73.639); 64 | --color-primary: oklch(52% 0.105 223.128); 65 | --color-primary-content: oklch(97% 0.013 236.62); 66 | --color-secondary: oklch(92% 0.084 155.995); 67 | --color-secondary-content: oklch(44% 0.119 151.328); 68 | --color-accent: oklch(68% 0.162 75.834); 69 | --color-accent-content: oklch(98% 0.022 95.277); 70 | --color-neutral: oklch(44% 0.011 73.639); 71 | --color-neutral-content: oklch(86% 0.005 56.366); 72 | --color-info: oklch(58% 0.158 241.966); 73 | --color-info-content: oklch(96% 0.059 95.617); 74 | --color-success: oklch(51% 0.096 186.391); 75 | --color-success-content: oklch(96% 0.059 95.617); 76 | --color-warning: oklch(64% 0.222 41.116); 77 | --color-warning-content: oklch(96% 0.059 95.617); 78 | --color-error: oklch(70% 0.191 22.216); 79 | --color-error-content: oklch(40% 0.123 38.172); 80 | --radius-selector: 0.25rem; 81 | --radius-field: 0.25rem; 82 | --radius-box: 0.25rem; 83 | --size-selector: 0.25rem; 84 | --size-field: 0.25rem; 85 | --border: 1px; 86 | --depth: 1; 87 | --noise: 0; 88 | } 89 | 90 | @plugin "@tailwindcss/typography"; 91 | @plugin "@tailwindcss/forms"; 92 | 93 | .shortcut_key { 94 | @apply px-1 mx-0.5 space-x-0 text-base-content bg-base-100 rounded-sm ; 95 | } 96 | -------------------------------------------------------------------------------- /frontend/templates/undo_report.html: -------------------------------------------------------------------------------- 1 | 10 | 11 | 84 | -------------------------------------------------------------------------------- /src/core/errors.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025 Tarin Mahmood 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | * 6 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | * 8 | * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | */ 10 | 11 | use std::collections::HashMap; 12 | 13 | use axum::{ 14 | http::StatusCode, 15 | response::{IntoResponse, Response}, 16 | }; 17 | use serde::{Deserialize, Serialize}; 18 | 19 | pub struct AppError(anyhow::Error); 20 | 21 | // Tell axum how to convert `AppError` into a response. 22 | impl IntoResponse for AppError { 23 | fn into_response(self) -> Response { 24 | ( 25 | StatusCode::INTERNAL_SERVER_ERROR, 26 | format!("Something went wrong: {}", self.0), 27 | ) 28 | .into_response() 29 | } 30 | } 31 | 32 | // This enables using `?` on functions that return `Result<_, anyhow::Error>` to turn them into 33 | // `Result<_, AppError>`. That way you don't need to do that manually. 34 | impl From for AppError 35 | where 36 | E: Into, 37 | { 38 | fn from(err: E) -> Self { 39 | Self(err.into()) 40 | } 41 | } 42 | 43 | impl ToString for AppError { 44 | fn to_string(&self) -> String { 45 | self.0.to_string() 46 | } 47 | } 48 | 49 | #[derive(Clone, Debug, Serialize, Deserialize)] 50 | pub struct FieldError { 51 | pub field: String, 52 | pub message: String, 53 | } 54 | 55 | #[derive(Clone, Debug, Serialize, Deserialize)] 56 | pub struct FormValidation { 57 | pub fields: HashMap>, 58 | pub msg: Option, 59 | success: bool, 60 | } 61 | 62 | impl Default for FormValidation { 63 | fn default() -> Self { 64 | Self { 65 | fields: Default::default(), 66 | msg: None, 67 | success: true, 68 | } 69 | } 70 | } 71 | 72 | impl std::fmt::Display for FormValidation { 73 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 74 | let field_list: Vec = self.fields.values().map(|f| { 75 | let msg_list: Vec = f.iter().map(|x| format!("{}={}", x.field.to_string(), x.message.to_string())).collect(); 76 | msg_list.join(", ") 77 | }).collect(); 78 | write!(f, "FormValidation error was {:?}, affected fields: {}", self.success, field_list.join("; ")) 79 | } 80 | } 81 | 82 | impl std::error::Error for FormValidation { 83 | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 84 | None 85 | } 86 | 87 | fn description(&self) -> &str { 88 | "description() is deprecated; use Display" 89 | } 90 | 91 | fn cause(&self) -> Option<&dyn std::error::Error> { 92 | self.source() 93 | } 94 | } 95 | 96 | impl From for FormValidation { 97 | fn from(value: anyhow::Error) -> Self { 98 | Self::default().set_error(Some(&value.to_string())).to_owned() 99 | } 100 | } 101 | 102 | impl From for FormValidation { 103 | fn from(value: taskchampion::Error) -> Self { 104 | Self::default().set_error(Some(&value.to_string())).to_owned() 105 | } 106 | } 107 | 108 | impl FormValidation { 109 | pub fn push(&mut self, error: FieldError) -> () { 110 | self.success = false; 111 | if let Some(val) = self.fields.get_mut(&error.field) { 112 | val.push(error); 113 | } else { 114 | self.fields.insert(error.field.to_string(), vec![error]); 115 | } 116 | } 117 | 118 | /// Check if any validation errors occured or if no errors were recognized. 119 | /// If everything went fine, `is_success` returns `true`. 120 | pub fn is_success(&self) -> bool { 121 | self.success 122 | } 123 | 124 | pub fn set_error(&mut self, msg: Option<&str>) -> &Self { 125 | if let Some(err_msg) = msg { 126 | self.success = false; 127 | self.msg = Some(err_msg.to_string()); 128 | } else { 129 | self.success = !self.fields.is_empty(); 130 | self.msg = None; 131 | } 132 | 133 | self 134 | } 135 | 136 | /// Checks whether errors occured for given `field`. 137 | /// If at least one error to the given `field`, a `true` 138 | /// is returned. 139 | pub fn has_error(&self, field: &str) -> bool { 140 | self.fields.contains_key(field) 141 | } 142 | 143 | } 144 | -------------------------------------------------------------------------------- /src/backend/serde.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025 Tarin Mahmood 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | * 6 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | * 8 | * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | */ 10 | 11 | 12 | 13 | pub(crate) mod task_status_serde { 14 | use serde::{self, Deserialize, Deserializer, Serializer}; 15 | 16 | pub fn serialize(status: &Option, s: S) -> Result 17 | where 18 | S: Serializer, 19 | { 20 | if let Some(ref d) = *status { 21 | let status = match d { 22 | taskchampion::Status::Pending => "pending", 23 | taskchampion::Status::Completed => "completed", 24 | taskchampion::Status::Deleted => "deleted", 25 | taskchampion::Status::Recurring => "recurring", 26 | taskchampion::Status::Unknown(v) => v.as_ref(), 27 | }; 28 | return s.serialize_str(status); 29 | } 30 | s.serialize_none() 31 | } 32 | 33 | pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> 34 | where 35 | D: Deserializer<'de>, 36 | { 37 | let s: Option = Option::deserialize(deserializer)?; 38 | if let Some(s) = s { 39 | let t = s.to_lowercase(); 40 | return Ok(Some(match t.as_str() { 41 | "pending" => taskchampion::Status::Pending, 42 | "completed" => taskchampion::Status::Completed, 43 | "deleted" => taskchampion::Status::Deleted, 44 | "recurring" => taskchampion::Status::Recurring, 45 | &_ => taskchampion::Status::Unknown(t), 46 | })); 47 | } 48 | 49 | Ok(None) 50 | } 51 | } 52 | 53 | pub(crate) mod task_date_format { 54 | use chrono::{DateTime, NaiveDateTime, Utc}; 55 | use serde::{self, Deserialize, Deserializer, Serializer}; 56 | 57 | const FORMAT: &'static str = "%Y%m%dT%H%M%SZ"; // Is always in UTC, not able to parse %:z 58 | 59 | // The signature of a serialize_with function must follow the pattern: 60 | // 61 | // fn serialize(&T, S) -> Result 62 | // where 63 | // S: Serializer 64 | // 65 | // although it may also be generic over the input types T. 66 | pub fn serialize(date: &Option>, serializer: S) -> Result 67 | where 68 | S: Serializer, 69 | { 70 | if let Some(dt) = date { 71 | let s = format!("{}", dt.format(FORMAT)); 72 | serializer.serialize_str(&s) 73 | } else { 74 | serializer.serialize_none() 75 | } 76 | } 77 | 78 | // The signature of a deserialize_with function must follow the pattern: 79 | // 80 | // fn deserialize<'de, D>(D) -> Result 81 | // where 82 | // D: Deserializer<'de> 83 | // 84 | // although it may also be generic over the output types T. 85 | pub fn deserialize<'de, D>(deserializer: D) -> Result>, D::Error> 86 | where 87 | D: Deserializer<'de>, 88 | { 89 | let s: Option = Option::deserialize(deserializer)?; 90 | if let Some(date_str) = s { 91 | match NaiveDateTime::parse_from_str(&date_str, FORMAT) { 92 | Ok(dt) => Ok(Some(DateTime::::from_naive_utc_and_offset(dt, Utc))), 93 | Err(_) => Ok(None), 94 | } 95 | } else { 96 | Ok(None) 97 | } 98 | } 99 | } 100 | 101 | pub(crate) mod task_date_format_mandatory { 102 | use chrono::{DateTime, NaiveDateTime, Utc}; 103 | use serde::{self, Deserialize, Deserializer, Serializer}; 104 | 105 | const FORMAT: &'static str = "%Y%m%dT%H%M%SZ"; // Is always in UTC, not able to parse %:z 106 | 107 | // The signature of a serialize_with function must follow the pattern: 108 | // 109 | // fn serialize(&T, S) -> Result 110 | // where 111 | // S: Serializer 112 | // 113 | // although it may also be generic over the input types T. 114 | pub fn serialize(date: &DateTime, serializer: S) -> Result 115 | where 116 | S: Serializer, 117 | { 118 | let s = format!("{}", date.format(FORMAT)); 119 | serializer.serialize_str(&s) 120 | } 121 | 122 | // The signature of a deserialize_with function must follow the pattern: 123 | // 124 | // fn deserialize<'de, D>(D) -> Result 125 | // where 126 | // D: Deserializer<'de> 127 | // 128 | // although it may also be generic over the output types T. 129 | pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> 130 | where 131 | D: Deserializer<'de>, 132 | { 133 | let date_str = String::deserialize(deserializer)?; 134 | let date_obj = NaiveDateTime::parse_from_str(&date_str, FORMAT) 135 | .map_err(serde::de::Error::custom)?; 136 | Ok(DateTime::::from_naive_utc_and_offset(date_obj, Utc)) 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /frontend/templates/task_delete_confirm.html: -------------------------------------------------------------------------------- 1 | 10 | 11 | 156 | -------------------------------------------------------------------------------- /frontend/templates/task_add.html: -------------------------------------------------------------------------------- 1 | {% set bg_color = "bg-neutral-800" %} 2 | 11 | 12 | 160 | -------------------------------------------------------------------------------- /src/core/app.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025 Tarin Mahmood 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | * 6 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | * 8 | * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | */ 10 | 11 | use directories::ProjectDirs; 12 | use std::{ 13 | env::{self, home_dir}, 14 | fs::create_dir_all, 15 | path::PathBuf, 16 | str::FromStr, 17 | sync::{Arc, Mutex, RwLock}, 18 | }; 19 | use tera::Context; 20 | use tracing::info; 21 | 22 | use super::{ 23 | cache::{FileMnemonicsCache, MnemonicsCacheType}, 24 | config::AppSettings, 25 | }; 26 | 27 | /// Holds state information and configurations 28 | /// required in the API and business logic operations. 29 | /// 30 | /// # Environments 31 | /// Many of the options are configured via environment variables. 32 | /// Following are supported: 33 | /// | Environment variable | AppState field | 34 | /// |---------------------------|--------------------------| 35 | /// | TWK_USE_FONT | font | 36 | /// | TWK_THEME | theme | 37 | /// | DISPLAY_TIME_OF_THE_DAY | display_time_of_the_day | 38 | /// | TASKDATA | task_storage_path | 39 | /// | TWK_CONFIG_FOLDER | app_config_path | 40 | /// | TWK_SYNC | interval in seconds | 41 | /// 42 | #[derive(Clone)] 43 | pub struct AppState { 44 | pub font: Option, 45 | pub fallback_family: String, 46 | pub theme: Option, 47 | pub display_time_of_the_day: i32, 48 | pub task_storage_path: PathBuf, 49 | pub task_hooks_path: Option, 50 | pub app_config_path: PathBuf, 51 | pub app_cache_path: PathBuf, 52 | pub app_cache: Arc>, 53 | pub app_config: Arc, 54 | pub sync_interval: i64, 55 | // Here must be cache object for mnemonics 56 | } 57 | 58 | impl Default for AppState { 59 | fn default() -> Self { 60 | let font = env::var("TWK_USE_FONT").map(|p| Some(p)).unwrap_or(None); 61 | let theme = match env::var("TWK_THEME") { 62 | Ok(p) if p.is_empty() => None, 63 | Ok(p) => Some(p), 64 | Err(_) => None, 65 | }; 66 | let display_time_of_the_day = env::var("DISPLAY_TIME_OF_THE_DAY") 67 | .unwrap_or("0".to_string()) 68 | .parse::() 69 | .unwrap_or(0); 70 | 71 | let home_dir = home_dir().unwrap_or_default(); 72 | let home_dir = home_dir.join(".task"); 73 | let task_storage_path = 74 | env::var("TASKDATA").unwrap_or(home_dir.to_str().unwrap_or("").to_string()); 75 | let sync_interval = if let Ok(sync_interval) = env::var("TWK_SYNC") { 76 | i64::from_str(&sync_interval).unwrap_or_default() 77 | } else { 78 | 0 79 | }; 80 | let task_storage_path = 81 | PathBuf::from_str(&task_storage_path).expect("Storage path cannot be found"); 82 | let task_hooks_path = Some(home_dir.clone().join("hooks")); 83 | 84 | let standard_project_dirs = ProjectDirs::from("", "", "Taskwarrior-Web"); 85 | 86 | // Overall determination of the configuration files. 87 | let mut app_config_path: Option = match env::var("TWK_CONFIG_FOLDER") { 88 | Ok(p) => Some(p.into()), 89 | Err(_) => None, 90 | }; 91 | if app_config_path.is_none() 92 | && standard_project_dirs.is_some() 93 | && let Some(ref proj_dirs) = standard_project_dirs 94 | { 95 | app_config_path = Some(proj_dirs.config_dir().to_path_buf()); 96 | } 97 | 98 | let app_config_path = app_config_path.expect("Configuration path not found"); 99 | create_dir_all(app_config_path.as_path()).expect("Config folder cannot be created."); 100 | let app_config_path = app_config_path.join("config.toml"); 101 | let app_settings = match AppSettings::new(app_config_path.as_path()) { 102 | Ok(s) => Ok(s), 103 | Err(e) => match e { 104 | config::ConfigError::Foreign(_) => { 105 | info!( 106 | "Configuration file could not be found ({}). Fallback to default.", 107 | e.to_string() 108 | ); 109 | Ok(AppSettings::default()) 110 | } 111 | _ => Err(e), 112 | }, 113 | } 114 | .expect("Proper configuration file does not exist"); 115 | 116 | // Overall determination of the cache folder. 117 | let app_cache_path = standard_project_dirs 118 | .map(|p| p.cache_dir().to_path_buf()) 119 | .expect("Cache folder not usable."); 120 | 121 | // initialize cache. 122 | // ensure, the folder exists. 123 | create_dir_all(app_cache_path.as_path()).expect("Cache folder cannot be created."); 124 | let cache_path = app_cache_path.join("mnemonics.cache"); 125 | info!( 126 | "Cache file to store mnemonics is placed at {:?}", 127 | &cache_path 128 | ); 129 | let mut cache = FileMnemonicsCache::new(Arc::new(Mutex::new(cache_path))); 130 | cache 131 | .load() 132 | .inspect_err(|e| { 133 | tracing::error!( 134 | "Cannot parse the configuration file, error: {}", 135 | e.to_string() 136 | ); 137 | }) 138 | .expect("Configuration file exists, but is not parsable!"); 139 | 140 | // Now ensure, that fixed keys are directly assigned to the custom queries. 141 | // For this, we need also to ensure, that conflicting cache entries are removed! 142 | app_settings.register_shortcuts(&mut cache); 143 | 144 | Self { 145 | font, 146 | fallback_family: "monospace".to_string(), 147 | theme, 148 | display_time_of_the_day, 149 | task_storage_path, 150 | task_hooks_path, 151 | app_config_path, 152 | app_cache_path, 153 | app_cache: Arc::new(RwLock::new(cache)), 154 | app_config: Arc::new(app_settings), 155 | sync_interval, 156 | } 157 | } 158 | } 159 | 160 | impl From<&AppState> for Context { 161 | fn from(val: &AppState) -> Self { 162 | let mut ctx = Context::new(); 163 | ctx.insert("USE_FONT", &val.font); 164 | ctx.insert("FALLBACK_FAMILY", &val.fallback_family); 165 | ctx.insert("DEFAULT_THEME", &val.theme); 166 | ctx.insert("display_time_of_the_day", &val.display_time_of_the_day); 167 | ctx 168 | } 169 | } 170 | 171 | pub fn get_default_context(state: &AppState) -> Context { 172 | state.into() 173 | } 174 | -------------------------------------------------------------------------------- /frontend/templates/left_action_bar.html: -------------------------------------------------------------------------------- 1 | 10 | 11 |
12 | 13 | 25 | 26 |
27 | 34 | 42 |
43 | 44 |
45 | 52 | 59 | 66 | 74 |
75 | 76 |
77 | 84 | 91 | 98 |
99 | 100 |
101 | 108 | 115 | 122 |
123 | 124 |
125 | 126 |
127 | 128 |
129 | 130 | 132 |
133 |
134 | -------------------------------------------------------------------------------- /frontend/src/main.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025 Tarin Mahmood 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | * 6 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | * 8 | * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | */ 10 | 11 | import 'htmx.org'; 12 | import 'hyperscript.org'; 13 | import * as _hyperscript from "hyperscript.org"; 14 | import hotkeys from "hotkeys-js"; 15 | import * as theme from "./theme"; 16 | 17 | _hyperscript.browserInit(); 18 | 19 | export const doing_something = () => { 20 | console.log("Hello world"); 21 | } 22 | 23 | hotkeys.filter = function (event) { 24 | // @ts-ignore 25 | let tagName = event.target.tagName; 26 | hotkeys.setScope(/^(INPUT|TEXTAREA|SELECT)$/.test(tagName) ? 'input' : 'other'); 27 | return true; 28 | }; 29 | 30 | function focusTextInput(event: KeyboardEvent | MouseEvent) { 31 | let ss = document.getElementById('task-details-inp'); 32 | if (ss !== null) { 33 | event.preventDefault() 34 | ss.focus(); 35 | } else { 36 | document.getElementById('cmd-inp')?.focus(); 37 | } 38 | } 39 | 40 | window.handleTaskAnnotations = (event: KeyboardEvent | MouseEvent) => { 41 | if(event.target != document.getElementById('btn-denotate-task')) { 42 | console.log("not processing") 43 | return 44 | } 45 | event.preventDefault(); 46 | let annoSelector = document.getElementById('anno-inp'); 47 | document.querySelector('#anno-inp')?.classList.toggle('hidden'); 48 | Array.from(document.querySelectorAll('.is-a-annotation')).forEach((value) => { 49 | value.classList.toggle('hidden'); 50 | }); 51 | if (annoSelector?.checkVisibility()) { 52 | annoSelector.focus(); 53 | } 54 | return false; 55 | }; 56 | 57 | window.handleTaskAnnotationTrigger = (event: KeyboardEvent | MouseEvent) => { 58 | event.preventDefault(); 59 | if (event.target) { 60 | let shortkey = event.target.value; 61 | if (shortkey.length >= 2) { 62 | let element = document.getElementById("anno_dlt_" + shortkey); 63 | if (element) { 64 | element.click(); 65 | } 66 | }; 67 | } 68 | } 69 | 70 | hotkeys('esc', function (event, handler) { 71 | // Prevent the default refresh event under WINDOWS system 72 | if(event.target != document.getElementById('tag-inp') && 73 | event.target != document.getElementById('query-inp')) { 74 | console.log("not processing") 75 | return 76 | } 77 | event.preventDefault(); 78 | let tag_selector = document.getElementById('cmd-inp'); 79 | if (event.target == document.getElementById('tag-inp')) { 80 | document.querySelector('#tags_map_drawer')?.classList.toggle('hidden'); 81 | } else if (event.target == document.getElementById('query-inp')) { 82 | document.querySelector('#querys_map_drawer')?.classList.toggle('hidden'); 83 | } 84 | if (tag_selector?.checkVisibility()) { 85 | tag_selector.focus(); 86 | } 87 | return false; 88 | }); 89 | 90 | hotkeys('ctrl+shift+K', function (event, handler) { 91 | // Prevent the default refresh event under WINDOWS system 92 | event.preventDefault(); 93 | focusTextInput(event); 94 | return false; 95 | }); 96 | 97 | hotkeys('t', function (event, handler) { 98 | // Prevent the default refresh event under WINDOWS system 99 | if(event.target != document.getElementById('cmd-inp')) { 100 | console.debug("not processing") 101 | return 102 | } 103 | event.preventDefault() 104 | window['togglePanel']('tag'); 105 | }); 106 | 107 | hotkeys('q', function (event, handler) { 108 | // Prevent the default refresh event under WINDOWS system 109 | if(event.target != document.getElementById('cmd-inp')) { 110 | console.debug("not processing") 111 | return 112 | } 113 | event.preventDefault() 114 | window['togglePanel']('query'); 115 | }); 116 | 117 | window['togglePanel'] = (panelType: string) => { 118 | let tagSelector = document.getElementById(panelType + '-inp') 119 | document.querySelector('#' + panelType + 's_map_drawer')?.classList.toggle('hidden') 120 | if (tagSelector?.checkVisibility()) { 121 | tagSelector.focus(); 122 | } 123 | return false; 124 | }; 125 | 126 | window['processPanelShortcut'] = (event: KeyboardEvent, panelType: string) { 127 | const shortcut = event.target?.value; 128 | if (shortcut.length >= 2) { 129 | document.getElementById(panelType + "s_" + shortcut)?.click() 130 | }; 131 | }; 132 | 133 | document.addEventListener('click', function (event) { 134 | let element = document.getElementsByTagName('html')[0]; 135 | switch(event.target) { 136 | case element: 137 | focusTextInput(event); 138 | break; 139 | case document.getElementById('theme-switcher'): 140 | event.preventDefault(); 141 | theme.switchTheme(); 142 | break; 143 | } 144 | return; 145 | }) 146 | 147 | document.addEventListener("DOMContentLoaded", function () { 148 | theme.init(); 149 | 150 | let n = setInterval( 151 | () => { 152 | let whichOne = 0; 153 | // document.getElementById('active-timer').querySelectorAll('span.timer-duration')[0] 154 | let dd = document.getElementById('active-timer'); 155 | if (dd === undefined || dd === null) { 156 | return 157 | } 158 | let timeBox = dd.children[1].children[whichOne]; 159 | let s = timeBox.textContent.split(":"); 160 | let second = parseInt(s.pop()); 161 | let minute = parseInt(s.pop()); 162 | if (isNaN(minute)) { 163 | minute = 0; 164 | } 165 | let hour = parseInt(s.pop()); 166 | if (isNaN(hour)) { 167 | hour = 0; 168 | } 169 | second += 1; 170 | if (second >= 60) { 171 | second = 0; 172 | minute += 1; 173 | if (minute > 60) { 174 | hour += 1; 175 | } 176 | } 177 | timeBox.textContent = hour.toString() 178 | // @ts-ignore 179 | .padStart(2, "0") + ":" + 180 | minute.toString() 181 | // @ts-ignore 182 | .padStart(2, "0") + ":" + 183 | second.toString() 184 | // @ts-ignore 185 | .padStart(2, "0"); 186 | }, 1000 187 | ) 188 | 189 | let dayProgress = setInterval( 190 | () => { 191 | const dd = document.getElementById('time_of_the_day'); 192 | if (dd === undefined || dd === null) { 193 | return 194 | } 195 | // 196 | const now = new Date(); 197 | const totalMinutesPassed = now.getMinutes() + (now.getHours() * 60); 198 | const totalMinutesInDay = 24 * 60; 199 | const hoursLeft = 24 - now.getHours(); 200 | dd.style.width = totalMinutesPassed * 100 / totalMinutesInDay + "%"; 201 | dd.children[0].children[0].innerHTML = hoursLeft + "h"; 202 | }, 1000 203 | ) 204 | }); 205 | 206 | -------------------------------------------------------------------------------- /src/endpoints/tasks/task_modify.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025 Tarin Mahmood 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | * 6 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | * 8 | * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | */ 10 | 11 | 12 | use std::str::FromStr; 13 | 14 | use chrono::{DateTime, NaiveDate, NaiveTime, Utc}; 15 | use taskchampion::{Replica, Tag, Uuid}; 16 | 17 | use crate::{ 18 | backend::task::convert_task_status, 19 | core::errors::{FieldError, FormValidation}, 20 | }; 21 | 22 | pub(crate) fn task_apply_tag_add( 23 | t: &mut taskchampion::Task, 24 | mut ops: &mut Vec, 25 | validation_result: &mut FormValidation, 26 | b1: (String, Option), 27 | ) { 28 | let tag_name = b1.0.strip_prefix("+").unwrap(); 29 | match &Tag::from_str(tag_name).map_err(|p| FieldError { 30 | field: "additional".to_string(), 31 | message: p.to_string(), 32 | }) { 33 | Ok(tag) => match t.add_tag(tag, &mut ops).map_err(|p| FieldError { 34 | field: "additional".to_string(), 35 | message: p.to_string(), 36 | }) { 37 | Ok(_) => (), 38 | Err(e) => validation_result.push(e), 39 | }, 40 | Err(e) => validation_result.push(e.to_owned()), 41 | }; 42 | } 43 | 44 | pub(crate) fn task_apply_tag_remove( 45 | t: &mut taskchampion::Task, 46 | mut ops: &mut Vec, 47 | validation_result: &mut FormValidation, 48 | b1: (String, Option), 49 | ) { 50 | let tag_name = b1.0.strip_prefix("-").unwrap(); 51 | match &Tag::from_str(tag_name).map_err(|p| FieldError { 52 | field: "additional".to_string(), 53 | message: p.to_string(), 54 | }) { 55 | Ok(tag) => match t.remove_tag(tag, &mut ops).map_err(|p| FieldError { 56 | field: "additional".to_string(), 57 | message: p.to_string(), 58 | }) { 59 | Ok(_) => (), 60 | Err(e) => validation_result.push(e), 61 | }, 62 | Err(e) => validation_result.push(e.to_owned()), 63 | }; 64 | } 65 | 66 | pub(crate) fn task_apply_recur( 67 | t: &mut taskchampion::Task, 68 | ops: &mut Vec, 69 | validation_result: &mut FormValidation, 70 | b1: (String, Option), 71 | ) { 72 | match t 73 | .set_value("recur", b1.1, ops) 74 | .map_err(|p| FieldError { 75 | field: "additional".to_string(), 76 | message: format!("Failed change recurrence: {}", p.to_string()), 77 | }) 78 | .and_then(|_| { 79 | t.set_status(taskchampion::Status::Recurring, ops) 80 | .map_err(|p| FieldError { 81 | field: "additional".to_string(), 82 | message: format!("Failed change task status to recurring: {}", p.to_string()), 83 | }) 84 | .and_then(|_| { 85 | t.set_value("rtype", Some("periodic".into()), ops) 86 | .map_err(|p| FieldError { 87 | field: "additional".to_string(), 88 | message: format!( 89 | "Failed change task status to recurring: {}", 90 | p.to_string() 91 | ), 92 | }) 93 | }) 94 | }) { 95 | Ok(_) => (), 96 | Err(e) => validation_result.push(e), 97 | }; 98 | } 99 | 100 | pub(crate) fn task_apply_depends( 101 | t: &mut taskchampion::Task, 102 | replica: &mut Replica, 103 | ops: &mut Vec, 104 | validation_result: &mut FormValidation, 105 | b1: (String, Option), 106 | ) { 107 | let dep_list = b1.1.unwrap_or_default(); 108 | for dep in dep_list 109 | .split(",") 110 | .map(|f| f.trim()) 111 | .filter(|p| !p.is_empty()) 112 | { 113 | let result = match dep.chars().next() { 114 | Some(e) if (e == '+' || e == '-') && dep.len() > 1 => Some((e, dep.get(1..).unwrap())), 115 | Some(_) if !dep.is_empty() => { 116 | // We assume adding. 117 | Some(('+', dep)) 118 | } 119 | Some(_) => None, 120 | None => None, 121 | }; 122 | if let Some(result) = result { 123 | // Try to identify the uuid. 124 | let x = Uuid::try_parse(result.1); 125 | let x = match x { 126 | Ok(e) => Some(e), 127 | Err(_) => { 128 | let tid = result.1.parse::(); 129 | match tid { 130 | Ok(e) => replica.working_set().unwrap().by_index(e), 131 | Err(_) => None, 132 | } 133 | } 134 | }; 135 | if let Some(task_uuid) = x { 136 | let dep_result = match result.0 { 137 | '-' => t.remove_dependency(task_uuid, ops), 138 | _ => t.add_dependency(task_uuid, ops), 139 | }; 140 | match dep_result.map_err(|p| FieldError { 141 | field: "additional".to_string(), 142 | message: format!( 143 | "depends-error for uuid {}: {}", 144 | task_uuid.to_string(), 145 | p.to_string() 146 | ), 147 | }) { 148 | Ok(_) => (), 149 | Err(e) => validation_result.push(e), 150 | }; 151 | } else { 152 | validation_result.push(FieldError { 153 | field: String::from("additional"), 154 | message: format!( 155 | "Dependency task {} not found or invalid ID given.", 156 | result.1 157 | ), 158 | }); 159 | } 160 | }; 161 | } 162 | } 163 | 164 | pub(crate) fn task_apply_description( 165 | t: &mut taskchampion::Task, 166 | ops: &mut Vec, 167 | validation_result: &mut FormValidation, 168 | b1: (String, Option), 169 | ) { 170 | match t 171 | .set_description(b1.1.unwrap_or_default(), ops) 172 | .map_err(|p| FieldError { 173 | field: "additional".to_string(), 174 | message: format!("Invalid description given: {}", p.to_string()), 175 | }) { 176 | Ok(_) => (), 177 | Err(e) => validation_result.push(e), 178 | }; 179 | } 180 | 181 | pub(crate) fn task_apply_priority( 182 | t: &mut taskchampion::Task, 183 | ops: &mut Vec, 184 | validation_result: &mut FormValidation, 185 | b1: (String, Option), 186 | ) { 187 | match t 188 | .set_priority(b1.1.unwrap_or_default(), ops) 189 | .map_err(|p| FieldError { 190 | field: "additional".to_string(), 191 | message: format!("Invalid priority given: {}", p.to_string()), 192 | }) { 193 | Ok(_) => (), 194 | Err(e) => validation_result.push(e), 195 | }; 196 | } 197 | 198 | pub(crate) fn task_apply_timestamps( 199 | t: &mut taskchampion::Task, 200 | ops: &mut Vec, 201 | validation_result: &mut FormValidation, 202 | b1: (String, Option), 203 | ) { 204 | let dt = match b1.1 { 205 | Some(val) if !val.trim().is_empty() => val 206 | .trim() 207 | .parse::>() 208 | .or_else(|_| { 209 | val.parse::().map(|p| { 210 | p.and_time( 211 | NaiveTime::from_num_seconds_from_midnight_opt(0, 0) 212 | .expect("Failed even to create the simplest Time object"), 213 | ) 214 | .and_utc() 215 | }) 216 | }) 217 | .map_err(|p| FieldError { 218 | field: "additional".into(), 219 | message: format!( 220 | "Failed parsing timestamp for {} ({}).", 221 | &b1.0, 222 | p.to_string() 223 | ), 224 | }) 225 | .map(|p| Some(p)), 226 | Some(_) => Ok(None), 227 | None => Ok(None), 228 | }; 229 | match dt { 230 | Ok(e) => { 231 | let result = match b1.0.to_lowercase().trim() { 232 | "entry" => t.set_entry(e, ops), 233 | "wait" => t.set_wait(e, ops), 234 | "due" => t.set_due(e, ops), 235 | _ => Ok(()), 236 | } 237 | .map_err(|p| FieldError { 238 | field: "additional".into(), 239 | message: format!( 240 | "Failed setting timestamp for {} ({}).", 241 | &b1.0, 242 | p.to_string() 243 | ), 244 | }); 245 | if let Err(p) = result { 246 | validation_result.push(p); 247 | } 248 | } 249 | Err(e) => validation_result.push(e), 250 | }; 251 | } 252 | 253 | pub(crate) fn task_apply_status( 254 | t: &mut taskchampion::Task, 255 | ops: &mut Vec, 256 | validation_result: &mut FormValidation, 257 | b1: (String, Option), 258 | ) { 259 | if let Some(val) = b1.1 { 260 | let task_status = convert_task_status(&val); 261 | match t.set_status(task_status, ops).map_err(|p| FieldError { 262 | field: "additional".into(), 263 | message: format!("Invalid status {} ({}).", &val, p.to_string()), 264 | }) { 265 | Ok(_) => (), 266 | Err(p) => validation_result.push(p), 267 | }; 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /src/endpoints/tasks/task_query_builder.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025 Tarin Mahmood 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | * 6 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | * 8 | * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | */ 10 | 11 | use crate::TWGlobalState; 12 | use serde::{Deserialize, Serialize}; 13 | use std::cmp::PartialEq; 14 | use std::fmt::{Display, Formatter}; 15 | use std::process::Command; 16 | use tracing::log::trace; 17 | 18 | pub enum TQUpdateTypes { 19 | Priority(String), 20 | Status(String), 21 | Report(String), 22 | } 23 | 24 | #[derive(Debug, Serialize, Deserialize, Clone)] 25 | pub enum TaskReport { 26 | Next, 27 | New, 28 | Ready, 29 | All, 30 | NotSet, 31 | } 32 | 33 | impl Display for TaskReport { 34 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 35 | write!( 36 | f, 37 | "{}", 38 | match self { 39 | TaskReport::Next => "next", 40 | TaskReport::New => "new", 41 | TaskReport::Ready => "ready", 42 | TaskReport::All => "all", 43 | TaskReport::NotSet => "", 44 | } 45 | ) 46 | } 47 | } 48 | 49 | impl From for TaskReport { 50 | fn from(value: String) -> Self { 51 | match value.as_str() { 52 | "ready" => TaskReport::Ready, 53 | "new" => TaskReport::New, 54 | "next" => TaskReport::Next, 55 | "all" => TaskReport::All, 56 | _ => TaskReport::NotSet, 57 | } 58 | } 59 | } 60 | 61 | #[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)] 62 | pub enum TaskPriority { 63 | High, 64 | Medium, 65 | Low, 66 | NotSet, 67 | } 68 | 69 | impl From for TaskPriority { 70 | fn from(value: String) -> Self { 71 | match value.as_str() { 72 | "H" => TaskPriority::High, 73 | "M" => TaskPriority::Medium, 74 | "L" => TaskPriority::Low, 75 | "priority:H" => TaskPriority::High, 76 | "priority:M" => TaskPriority::Medium, 77 | "priority:L" => TaskPriority::Low, 78 | _ => TaskPriority::NotSet, 79 | } 80 | } 81 | } 82 | 83 | impl Display for TaskPriority { 84 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 85 | write!( 86 | f, 87 | "{}", 88 | match self { 89 | TaskPriority::High => "priority:H", 90 | TaskPriority::Medium => "priority:M", 91 | TaskPriority::Low => "priority:L", 92 | TaskPriority::NotSet => "", 93 | } 94 | ) 95 | } 96 | } 97 | 98 | #[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)] 99 | pub enum TaskStatus { 100 | Pending, 101 | Completed, 102 | Waiting, 103 | NotSet, 104 | } 105 | 106 | impl From for TaskStatus { 107 | fn from(value: String) -> Self { 108 | match value.as_str() { 109 | "pending" => TaskStatus::Pending, 110 | "completed" => TaskStatus::Completed, 111 | "waiting" => TaskStatus::Waiting, 112 | "status:pending" => TaskStatus::Pending, 113 | "status:completed" => TaskStatus::Completed, 114 | "status:waiting" => TaskStatus::Waiting, 115 | _ => TaskStatus::NotSet, 116 | } 117 | } 118 | } 119 | 120 | impl Display for TaskStatus { 121 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 122 | write!( 123 | f, 124 | "{}", 125 | match self { 126 | TaskStatus::Pending => "status:pending", 127 | TaskStatus::Completed => "status:completed", 128 | TaskStatus::Waiting => "status:waiting", 129 | TaskStatus::NotSet => "", 130 | } 131 | ) 132 | } 133 | } 134 | 135 | // this will get the params and build task command 136 | #[derive(Serialize, Deserialize, Debug, Clone)] 137 | pub struct TaskQuery { 138 | status: TaskStatus, 139 | priority: TaskPriority, 140 | report: TaskReport, 141 | tags: Vec, 142 | project: Option, 143 | filter: Option, 144 | new_entry: Option, 145 | custom_query: Option, 146 | } 147 | 148 | impl Default for TaskQuery { 149 | fn default() -> Self { 150 | TaskQuery { 151 | status: TaskStatus::NotSet, 152 | priority: TaskPriority::NotSet, 153 | report: TaskReport::Next, 154 | tags: vec![], 155 | project: None, 156 | filter: None, 157 | new_entry: None, 158 | custom_query: None, 159 | } 160 | } 161 | } 162 | 163 | impl TaskQuery { 164 | pub fn new(params: TWGlobalState) -> Self { 165 | let mut tq = Self::default(); 166 | tq.update(params); 167 | tq 168 | } 169 | 170 | pub fn all() -> Self { 171 | TaskQuery { 172 | status: TaskStatus::NotSet, 173 | priority: TaskPriority::NotSet, 174 | report: TaskReport::All, 175 | tags: vec![], 176 | project: None, 177 | filter: None, 178 | new_entry: None, 179 | custom_query: None, 180 | } 181 | } 182 | 183 | pub fn empty() -> Self { 184 | TaskQuery { 185 | status: TaskStatus::NotSet, 186 | priority: TaskPriority::NotSet, 187 | report: TaskReport::NotSet, 188 | tags: vec![], 189 | project: None, 190 | filter: None, 191 | new_entry: None, 192 | custom_query: None, 193 | } 194 | } 195 | 196 | pub fn update(&mut self, params: TWGlobalState) { 197 | if params.report.as_ref().is_none() && params.status.as_ref().is_none() { 198 | if params.custom_query.as_ref().is_some_and(|f| !f.is_empty()) { 199 | self.report = TaskReport::NotSet; 200 | self.custom_query = params.custom_query; 201 | self.filter = params.filter; 202 | } 203 | } else { 204 | self.custom_query = None; 205 | self.filter = None; 206 | } 207 | 208 | if let Some(rep) = params.report { 209 | self.report = rep.into(); 210 | self.status = TaskStatus::NotSet; 211 | } 212 | 213 | if let Some(status) = params.status { 214 | let s: TaskStatus = status.into(); 215 | if s == self.status { 216 | self.status = TaskStatus::NotSet; 217 | self.report = TaskReport::Next; 218 | } else { 219 | self.status = s; 220 | self.report = TaskReport::NotSet; 221 | } 222 | } 223 | 224 | if let Some(t) = params.query { 225 | if t.starts_with("project:") { 226 | // already have a project set, 227 | // so do not set project 228 | if self.project == Some(t.clone()) { 229 | self.project = None; 230 | } else { 231 | self.project = Some(t); 232 | } 233 | } else if t.starts_with("+") { 234 | if self.tags.contains(&t) { 235 | self.tags.retain_mut(|iv| iv != &t); 236 | } else { 237 | self.tags.push(t); 238 | } 239 | } else if t.starts_with("priority:") { 240 | let tp: TaskPriority = t.clone().into(); 241 | if self.priority == tp { 242 | self.priority = TaskPriority::NotSet; 243 | } else { 244 | self.priority = tp; 245 | } 246 | } 247 | } 248 | self.new_entry = params.task_entry; 249 | trace!("{:?}", self); 250 | } 251 | 252 | pub fn set_filter(&mut self, filter: &str) { 253 | self.filter = Some(filter.to_string()) 254 | } 255 | 256 | pub fn get_query(&self, with_export: bool) -> Vec { 257 | let mut output = vec![]; 258 | let mut export_suffix = vec![]; 259 | let mut export_prefix = vec![]; 260 | if let Some(f) = &self.filter.clone() { 261 | let task_filter = shell_words::split(f); 262 | if let Ok(task_filter) = task_filter { 263 | export_prefix.extend(task_filter); 264 | } 265 | } 266 | match &self.report { 267 | TaskReport::NotSet => {} 268 | v => export_suffix.push(v.to_string()), 269 | } 270 | match &self.priority { 271 | TaskPriority::NotSet => {} 272 | v => export_prefix.push(v.to_string()), 273 | } 274 | if let Some(p) = self.project.clone() { 275 | export_prefix.push(p) 276 | } 277 | if self.tags.len() > 0 { 278 | export_prefix.extend(self.tags.clone()) 279 | } 280 | match &self.status { 281 | TaskStatus::NotSet => {} 282 | v => export_prefix.push(v.to_string()), 283 | } 284 | if let Some(e) = self.new_entry.clone() { 285 | export_prefix.push(e); 286 | } 287 | output.extend(export_prefix); 288 | if with_export { 289 | output.extend(vec!["export".to_string()]); 290 | } 291 | output.extend(export_suffix); 292 | output 293 | } 294 | 295 | pub fn as_filter_text(&self) -> Vec { 296 | self.get_query(false) 297 | } 298 | 299 | pub fn build(&self) -> Command { 300 | let mut task = Command::new("task"); 301 | let output = self.get_query(true); 302 | task.args(&output); 303 | task 304 | } 305 | pub fn status(&self) -> &TaskStatus { 306 | &self.status 307 | } 308 | pub fn priority(&self) -> &TaskPriority { 309 | &self.priority 310 | } 311 | pub fn report(&self) -> &TaskReport { 312 | &self.report 313 | } 314 | pub fn tags(&self) -> &Vec { 315 | &self.tags 316 | } 317 | pub fn project(&self) -> &Option { 318 | &self.project 319 | } 320 | pub fn filter(&self) -> &Option { 321 | &self.filter 322 | } 323 | pub fn new_entry(&self) -> &Option { 324 | &self.new_entry 325 | } 326 | pub fn custom_query(&self) -> &Option { 327 | &self.custom_query 328 | } 329 | } 330 | 331 | #[cfg(test)] 332 | mod tests; 333 | -------------------------------------------------------------------------------- /src/core/config.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025 Tarin Mahmood 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | * 6 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | * 8 | * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | */ 10 | 11 | use std::collections::HashMap; 12 | 13 | use super::{cache::{MnemonicsCache, MnemonicsType}, errors::FieldError}; 14 | 15 | pub trait ValidateSetting { 16 | fn validate(&self) -> Vec; 17 | } 18 | 19 | #[derive(serde::Deserialize, serde::Serialize, Clone, Debug)] 20 | pub struct CustomQuery { 21 | pub query: String, 22 | pub description: String, 23 | pub fixed_key: Option, 24 | } 25 | 26 | #[derive(serde::Deserialize, Clone, Debug)] 27 | pub struct AppSettings { 28 | #[serde(default)] 29 | pub custom_queries: HashMap, 30 | } 31 | 32 | impl AppSettings { 33 | pub fn new(config_path: &std::path::Path) -> Result { 34 | let settings = config::Config::builder() 35 | .add_source(config::File::from(config_path).required(false)) 36 | .add_source( 37 | config::Environment::with_prefix("TWK") 38 | .prefix_separator("_") 39 | .separator("__"), 40 | ) 41 | .build()?; 42 | match settings.try_deserialize::() { 43 | Ok(s) => { 44 | let validation = s.validate(); 45 | match validation.len() { 46 | 0 => Ok(s), 47 | _ => { 48 | let error_message = format!( 49 | "Configuration file couldn't be read. Following error came up: {:?}", 50 | validation 51 | ); 52 | Err(config::ConfigError::Message(error_message)) 53 | } 54 | } 55 | } 56 | Err(e) => Err(e), 57 | } 58 | } 59 | } 60 | 61 | impl Default for AppSettings { 62 | fn default() -> Self { 63 | Self { 64 | custom_queries: Default::default(), 65 | } 66 | } 67 | } 68 | 69 | impl AppSettings { 70 | pub fn register_shortcuts(&self, cache: &mut dyn MnemonicsCache) { 71 | for f in &self.custom_queries { 72 | if let Some(fixed_key) = &f.1.fixed_key { 73 | cache.get(MnemonicsType::CustomQuery, f.0).is_some_and(|f| f != *fixed_key).then(|| { 74 | cache.remove(MnemonicsType::CustomQuery, f.0) 75 | }); 76 | let _ = cache.insert(MnemonicsType::CustomQuery, f.0, fixed_key, true); 77 | } 78 | } 79 | } 80 | } 81 | 82 | impl ValidateSetting for CustomQuery { 83 | fn validate(&self) -> Vec { 84 | let mut errors: Vec = Vec::new(); 85 | if self.fixed_key.as_ref().is_some_and(|f| f.len() != 2) { 86 | errors.push(FieldError { 87 | field: String::from("fixed_key"), 88 | message: format!( 89 | "Fixed key must be 2 unique characters. Currently assigned {:?} for {}!", 90 | self.fixed_key.as_ref(), 91 | &self.description 92 | ), 93 | }); 94 | } 95 | 96 | errors 97 | } 98 | } 99 | 100 | impl ValidateSetting for HashMap { 101 | fn validate(&self) -> Vec { 102 | let mut shortcuts: Vec = Vec::new(); 103 | 104 | self.iter() 105 | .map(|p| { 106 | let mut validations = p.1.validate(); 107 | if let Some(fixed_key) = p.1.fixed_key.as_ref() { 108 | if shortcuts.contains(fixed_key) { 109 | validations.push(FieldError { 110 | field: String::from("fixed_key"), 111 | message: format!( 112 | "Duplicate shortcut {} asssigned to query {}", 113 | fixed_key, p.0 114 | ), 115 | }); 116 | } else { 117 | shortcuts.push(fixed_key.clone()); 118 | } 119 | } 120 | validations 121 | }) 122 | .collect::>>() 123 | .iter() 124 | .flat_map(|p| p.to_owned()) 125 | .collect::>() 126 | } 127 | } 128 | 129 | impl ValidateSetting for AppSettings { 130 | fn validate(&self) -> Vec { 131 | [&self.custom_queries] 132 | .iter() 133 | .map(|p| p.validate()) 134 | .collect::>>() 135 | .iter() 136 | .flat_map(|p| p.to_owned()) 137 | .collect::>() 138 | } 139 | } 140 | 141 | #[cfg(test)] 142 | mod tests { 143 | use std::{io::{Seek, Write}, path::PathBuf, sync::{Arc, Mutex}}; 144 | 145 | use crate::core::cache::FileMnemonicsCache; 146 | 147 | use super::*; 148 | use tempfile::NamedTempFile; 149 | 150 | #[test] 151 | fn test_config_default() { 152 | let cq = AppSettings::default(); 153 | assert_eq!(cq.custom_queries.len(), 0); 154 | } 155 | 156 | #[test] 157 | fn test_config_file() { 158 | let mut file1 = NamedTempFile::with_suffix(".toml").expect("Cannot create named temp files."); 159 | // let file1_pb = PathBuf::from(file1.path()); 160 | let _ = file1.as_file().set_len(0); 161 | let _ = file1.seek(std::io::SeekFrom::Start(0)); 162 | let data = String::from("[custom_queries]\n\n[custom_queries.one_query]\nquery = \"end:20250502T043247Z limit:5\"\ndescription = \"report of something\"\n\n[custom_queries.two_query]\nquery = \"limit:1\"\ndescription = \"report of another thing\"\nfixed_key = \"ni\" # this will override randomly generated key\n\n"); 163 | let _ = file1.write_all(data.as_bytes()); 164 | let _ = file1.flush(); 165 | 166 | let appconf = AppSettings::new(file1.path()); 167 | println!("{:?}", appconf); 168 | assert_eq!(appconf.is_ok(), true); 169 | let appconf = appconf.unwrap(); 170 | assert_eq!(appconf.custom_queries.len(), 2); 171 | assert_eq!(appconf.custom_queries.contains_key("one_query"), true); 172 | assert_eq!(appconf.custom_queries.contains_key("two_query"), true); 173 | } 174 | 175 | #[test] 176 | fn test_config_file_syntax() { 177 | let mut file1 = NamedTempFile::with_suffix(".toml").expect("Cannot create named temp files."); 178 | // let file1_pb = PathBuf::from(file1.path()); 179 | let _ = file1.as_file().set_len(0); 180 | let _ = file1.seek(std::io::SeekFrom::Start(0)); 181 | let data = String::from("[custom_queries]\nquery = \"end:20250502T043247Z limit:5\"\ndescription = \"report of something\"\n"); 182 | let _ = file1.write_all(data.as_bytes()); 183 | let _ = file1.flush(); 184 | 185 | let appconf = AppSettings::new(file1.path()); 186 | assert_eq!(appconf.is_err(), true); 187 | } 188 | 189 | #[test] 190 | fn test_config_file_validation() { 191 | let mut file1 = NamedTempFile::with_suffix(".toml").expect("Cannot create named temp files."); 192 | // let file1_pb = PathBuf::from(file1.path()); 193 | let _ = file1.as_file().set_len(0); 194 | let _ = file1.seek(std::io::SeekFrom::Start(0)); 195 | let data = String::from("[custom_queries]\n\n[custom_queries.one_query]\nquery = \"end:20250502T043247Z limit:5\"\ndescription = \"report of something\"\nfixed_key = \"ni\"\n\n[custom_queries.two_query]\nquery = \"limit:1\"\ndescription = \"report of another thing\"\nfixed_key = \"ni\" # this will override randomly generated key\n\n"); 196 | let _ = file1.write_all(data.as_bytes()); 197 | let _ = file1.flush(); 198 | 199 | let appconf = AppSettings::new(file1.path()); 200 | assert_eq!(appconf.is_err(), true); 201 | } 202 | 203 | #[test] 204 | fn test_config_validation() { 205 | let mut appconf = AppSettings::default(); 206 | 207 | appconf.custom_queries.insert( 208 | String::from("two_query"), 209 | CustomQuery { query: String::from("limit:1"), description: String::from("report of another thing"), fixed_key: Some(String::from("ni")) } 210 | ); 211 | assert_eq!(appconf.custom_queries.len(), 1); 212 | 213 | appconf.custom_queries.insert( 214 | String::from("third_query"), 215 | CustomQuery { query: String::from("limit:10"), description: String::from("Simple query"), fixed_key: Some(String::from("ni")) } 216 | ); 217 | let valid = appconf.validate(); 218 | assert_eq!(valid.len(), 1); 219 | 220 | // Lets add further query with only a fixed_key with less than one char. 221 | appconf.custom_queries.insert( 222 | String::from("fourth_query"), 223 | CustomQuery { query: String::from("project:TWK"), description: String::from("Simple query #4"), fixed_key: Some(String::from("n")) } 224 | ); 225 | let valid = appconf.validate(); 226 | assert_eq!(valid.len(), 2); 227 | } 228 | 229 | #[test] 230 | fn test_config_register_shortcut() { 231 | let mut appconf = AppSettings::default(); 232 | 233 | let file1 = NamedTempFile::new().expect("Cannot create named temp files."); 234 | let file_mtx = Arc::new(Mutex::new(PathBuf::from(file1.path()))); 235 | 236 | let mut mock = FileMnemonicsCache::new(file_mtx); 237 | 238 | let result = mock.insert(MnemonicsType::CustomQuery, "two_query", "ad", false); 239 | assert_eq!(result.is_ok(), true); 240 | assert_eq!(mock.get(MnemonicsType::CustomQuery, "two_query"), Some(String::from("ad"))); 241 | 242 | appconf.custom_queries.insert( 243 | String::from("two_query"), 244 | CustomQuery { query: String::from("limit:1"), description: String::from("report of another thing"), fixed_key: Some(String::from("ni")) } 245 | ); 246 | appconf.custom_queries.insert( 247 | String::from("third_query"), 248 | CustomQuery { query: String::from("limit:10"), description: String::from("Simple query"), fixed_key: None } 249 | ); 250 | 251 | appconf.register_shortcuts(&mut mock); 252 | 253 | assert_eq!(mock.get(MnemonicsType::CustomQuery, "two_query"), Some(String::from("ni"))); 254 | assert_eq!(mock.get(MnemonicsType::CustomQuery, "third_query"), None); 255 | } 256 | } -------------------------------------------------------------------------------- /frontend/templates/task_details.html: -------------------------------------------------------------------------------- 1 | {% import "desc.html" as desc %} 2 | 11 | 12 | 311 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Current Update 4 | 5 | Hey! 6 | Small bug fixes, code cleanup, and support for auto-reload using `systemfd` during development. Not suggested for daily usage. 7 | 8 | 9 | [I would appreciate if you can support the development effort](https://tmahmood.gumroad.com/coffee) 10 | 11 | 12 | Please report any bugs, contributions are welcome. 13 | 14 | # What is this? 15 | 16 | A Minimalistic Web UI for Task Warrior focusing on Keyboard navigation. 17 | 18 | It's completely local. No intention to have any kind of online interactions. 19 | Font in the screenshot is [Maple Mono NF](https://github.com/subframe7536/maple-font) 20 | 21 | ## Stack 22 | 23 | - [Rust](https://www.rust-lang.org/) [nightly, will fail to build on stable] 24 | - [axum](https://github.com/tokio-rs/axum) 25 | - [tera](https://github.com/Keats/tera) 26 | - [TailwindCSS](https://tailwindcss.com/) 27 | - [daisyUI](https://daisyui.com/) 28 | - [HTMX](https://htmx.org) 29 | - [hotkeys](https://github.com/jaywcjlove/hotkeys-js) 30 | - [rollup](https://rollupjs.org/) 31 | - [Taskwarrior](https://taskwarrior.org/) (obviously :)) 32 | - [Timewarrior](https://timewarrior.net) 33 | 34 | Still work in progress. But in the current stage it is pretty usable. You can see the list at the bottom, for what I intend to add, and what's been done. 35 | 36 | ![Application](./screenshots/full_page.png) 37 | 38 | # Using Release Binary 39 | 40 | Latest release binaries are now available. Check the release tags on the sidebar 41 | 42 | # Using Docker 43 | 44 | Docker image is provided. A lot of thanks go to [DCsunset](https://github.com/DCsunset/taskwarrior-webui) 45 | and [RustDesk](https://github.com/rustdesk/rustdesk/) 46 | 47 | ```shell 48 | docker build -t taskwarrior-web-rs . \ 49 | && docker run --init -d -p 3000:3000 \ 50 | -v ~/.task/:/app/taskdata/ \ 51 | -v ~/.taskrc:/app/.taskrc \ 52 | -v ~/.timewarrior/:/app/.timewarrior/ \ 53 | --name taskwarrior-web-rs taskwarrior-web-rs 54 | ``` 55 | 56 | As a service, every push to the `main` branch of this repository will provide automatic a docker image and can be pulled via 57 | 58 | ```shell 59 | docker pull ghcr.io/tmahmood/taskwarrior-web:main 60 | ``` 61 | 62 | That should do it. 63 | 64 | ## Volumes 65 | 66 | The docker shares following directories as volumes to store data: 67 | 68 | | Volume path | Purpose | 69 | | ----------------- | ---------------------------------------------- | 70 | | /app/taskdata | Stores task data (mostly taskchampion.sqlite3) | 71 | | /app/.timewarrior | Stores timewarrior data | 72 | | /app/.config/taskwarrior-web | Stores taskwarrior-web configuration file | 73 | 74 | It is recommend to specify the corresponding volume in order to persist the data. 75 | 76 | ## Ports 77 | 78 | `taskwarrior-web` is by default internally listening on port `3000`: 79 | 80 | | Port | Protocol | Purpose | 81 | | ---- | -------- | -------------------------------- | 82 | | 3000 | tcp | Main webserver to serve the page | 83 | 84 | ## Environment variables 85 | 86 | In order to configure the environment variables and contexts for `timewarrior-web`, docker environments can be specified: 87 | 88 | | Docker environment | Shell environment | Purpose | 89 | | -------------------------------- | ----------------------- | -------------------------------------------------------- | 90 | | TASK_WEB_TWK_SERVER_PORT | TWK_SERVER_PORT | Specifies the server port (see "Ports") | 91 | | TASK_WEB_DISPLAY_TIME_OF_THE_DAY | DISPLAY_TIME_OF_THE_DAY | Displays a time of the day widget in case of value `1` | 92 | | TASK_WEB_TWK_USE_FONT | TWK_USE_FONT | Font to be used. If not, browsers default fonts are used | 93 | | TASK_WEB_TWK_THEME | TWK_THEME | Defines the theme to be used (see "Themes") | 94 | 95 | ## Hooks 96 | 97 | NOTE: If you have any hooks 98 | (eg. Starting time tracking using time-warrior when we start a task, 99 | you'll need to install the required application in in the docker, also the config files) 100 | 101 | By default, the `timewarrior` on-modify hook is installed. 102 | 103 | # Manual Installation 104 | 105 | ## Requirements 106 | 107 | - rust nightly 108 | - npm 109 | 110 | ### Installing rust nightly 111 | 112 | Should be installable through `rustup` 113 | https://rustup.rs/ 114 | 115 | ### Building and Running 116 | 117 | 1. Clone the latest version from GitHub. 118 | 2. `cargo run --release` 119 | 120 | That should be it! Now you have the server running at `localhost:3000` accessible by your browser. 121 | 122 | ### Troubleshooting 123 | 124 | By default the log level is set to `INFO`. If a more detailed log is required, the application can be run with DEBUG or even TRACE messages. 125 | For debug messages, just set the environment "RUST_LOG" to "DEBUG": 126 | ```shell 127 | env RUST_LOG="DEBUG" cargo run 128 | ``` 129 | 130 | If a fine granular configuration is desired - the application log itself is captured with the name `taskwarrior_web`. 131 | 132 | ## Customizing 133 | 134 | ### Customizing the port 135 | 136 | By default, the program will use 3000 as port, 137 | you can customize through `.env` file or enviornment variable, check `env.example` 138 | 139 | variable name: `TWK_SERVER_PORT` 140 | 141 | ### Displaying `time of the day` widget 142 | 143 | By default the "time of the day" widget is not visible, to display it put 144 | 145 | `DISPLAY_TIME_OF_THE_DAY=1` 146 | 147 | in the `.env` file 148 | 149 | ### Font customization 150 | 151 | Previously the app used `Departure Mono` as default font, which was also included in the repo. 152 | It's now removed. 153 | And the font can be set using env variable. 154 | 155 | Add the following to change default font: 156 | 157 | `TWK_USE_FONT='Maple Mono'` 158 | 159 | ### Themes 160 | 161 | By default, `taskwarrior-web` provides two themes: 162 | 163 | 1. taskwarrior-dark (intended for dark mode) 164 | 2. taskwarrior-light (intended for light mode) 165 | 166 | `taskwarrior-web` decides automatically based on the operating system and [browser preferences](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme) whether the light or the dark theme should be shown. 167 | 168 | If a specific theme should be set fixed, the theme can be set as following in the environment: 169 | 170 | `TWK_THEME=taskwarrior-dark` (for dark-mode) 171 | 172 | # Using the app 173 | 174 | You can use Mouse or Keyboard to navigate. 175 | 176 | ![Top bar](./screenshots/top_bars.png) 177 | 178 | - All the keyboard mnemonics are underlined. 179 | - The `Cmd Bar` needs to be focused (`Ctrl + Shift + K`) for the keyboard shortcuts to work 180 | 181 | ## Project and Tag selection 182 | 183 | Keyboard shortcut is `t` 184 | 185 | For selecting tag, once you enter tag selection mode, the `tag bar` is visible, 186 | tag mnemonics are displayed on the tags, in red boxes, typing the mnemonics will immediately set the tag/project, 187 | 188 | Note: selecting the tag/project again will remove the tag from filter. 189 | 190 | ![Search bar](./screenshots/tag-search.png) 191 | 192 | ## Creating new task 193 | 194 | Keyboard shortcut is `n` 195 | 196 | Which should bring up the new task dialog box. It will use the current tags and project to create the task 197 | ![New task](./screenshots/new-task.png) 198 | 199 | ## Marking task as done or displaying task details 200 | 201 | Call up task search: `s` 202 | This should update top bar with the following, and also the task mnemonics are displayed with the id, in red boxes. 203 | Typing the mnemonics will immediately mark the task as done, 204 | or display the details of the task depending on mnemonics typed 205 | 206 | ![Task search bar](./screenshots/task_search_by_id_text_box.png) 207 | 208 | In Task Details window, you can mark task as done[d] and start/stop [s] timer. 209 | Also, denotate task using [n] 210 | You can use task command to modify the task. 211 | You only need to enter the modifications. 212 | 213 | ![Task details window](./screenshots/task_details.png) 214 | 215 | Once you start a timer it will be highlighted on the list 216 | ![Task active](./screenshots/active_task.png) 217 | 218 | ## Undo 219 | 220 | Keyboard shortcut is `u` 221 | 222 | This will bring up undo confirmation dialog 223 | ![Undo](./screenshots/undo.png) 224 | 225 | ## Custom queries 226 | 227 | Task organization is a pretty personal thing. And depending on the project or individual base, custom workflows and reportings are required. 228 | Create a configuration file under Linux in `$HOME/.config/taskwarrior-web/config.toml` or under Windows in `%APPDATA%\taskwarrior-web\config.toml` and add custom queries. 229 | 230 | A configuration file can look like: 231 | 232 | ```toml 233 | [custom_queries] 234 | 235 | [custom_queries.completed_last_week] 236 | query = "end.after:today-1wk and status:completed" 237 | description = "completed last 7days" 238 | 239 | [custom_queries.due_today] 240 | query = "due:today" 241 | description = "to be done today" 242 | fixed_key = "ni" # this will override randomly generated key 243 | ``` 244 | 245 | Following options for each query definition is available: 246 | | property | mandatory | meaning | 247 | | ----------- | --------- | ---------------------------------------------------------------------- | 248 | | query | X | specifies the query to be executed on `taskwarrior`. | 249 | | description | X | description to be shown in the Web-UI for recognizing the right query. | 250 | | fixed_key | | Can be specified as two characters which will hardcode the shortcut. | 251 | 252 | The query can be selected via keyboard shortcuts or via click on the right buttons. 253 | In order to select custom queries with the keyboard, first type in `q` as key for queries. 254 | A list is shown with available custom queries: 255 | ![Custom query selections](./screenshots/custom_queries_selection.png) 256 | 257 | On each custom query, either a pre-defined shortcut key is shown or an automatic and cached shortcut is shown. 258 | The right one is typed and automatically the custom query is set: 259 | 260 | ![Custom query list](./screenshots/custom_queries_list.png) 261 | 262 | As soon as one of the other reports like `next`, `pending` or others are selected, the custom query is unset and `taskwarrior-web` standard reports are shown. 263 | 264 | Beside of a configuration file, it is possible to configure via environment variables as well: 265 | ```shell 266 | env TWK_custom_queries__one_query__fixed_key=ni TWK_custom_queries__one_query__query="end.after:today-1wk and status:completed" TWK_custom_queries__one_query__description="completed last 7days" cargo run 267 | ``` 268 | 269 | The same way it is possible to configure the docker container accordingly. 270 | 271 | ## Switch theme 272 | 273 | It is possible to switch the theme, which is saved in local storage too. 274 | 275 | For this following three symbols are used (left of the command bar): 276 | 277 | | Symbol | Purpose | 278 | | ------ | ------------------------------------ | 279 | | ⚹ | Auto Mode or forced mode from server | 280 | | ☽ | Dark mode | 281 | | 🌣 | Light mode | 282 | 283 | # WIP warning 284 | 285 | This is a work in progress application, many things will not work, 286 | there will be errors, as no checks, and there may not be any error messages in case of error. 287 | 288 | # Past Updates 289 | 290 | Now the program is MIT licensed. Thanks to [monofox](https://github.com/monofox) for reminding me of it. And I appreciate his awesome contributions! 291 | 292 | 293 | Even though currently the program is not being updated, I have not given up on it. I will try to get some updates in eventually. Meantime, I may not be able to rectify any issues, but will do my best to give suggestions. 294 | 295 | I will be working on 296 | - UI update on Sync 297 | - Respecting context set in TW 298 | 299 | - Updated to tailwindcss 4 and using daisyui for UI components. 300 | - Cleaned-up code a bit to make it easier to manage 301 | 302 | ## Planned 303 | 304 | - [ ] Better configuration 305 | - [ ] Usability improvements on a long task list 306 | - [x] Hiding empty columns 307 | - [ ] Temporary highlight last modified row, if visible 308 | - [x] Make the mnemonics same for tags on refresh 309 | - [x] Modification 310 | - [x] Deleting 311 | - [ ] Following Context 312 | - [ ] Error handling 313 | - [x] Retaining input in case of error 314 | - [ ] Finetune error handling 315 | - [ ] Add more tests 316 | - [ ] Convert to desktop app using Tauri 317 | - [ ] Reporting 318 | - [ ] Project wise progress 319 | - [ ] Burndown reports 320 | - [ ] Column customization 321 | - [ ] Color customization 322 | - [ ] Time warrior integration, and time reporting 323 | - [ ] Searching by tag name 324 | 325 | ## Issues 326 | 327 | - [ ] Not able to select and copy tags, maybe add a copy button 328 | - [ ] Keyboard shortcut applied when there is a shortcut key and I use a mnemonic 329 | - [x] When marking task as done stop if active 330 | 331 | ![Change Log](CHANGELOG.md) 332 | -------------------------------------------------------------------------------- /frontend/templates/tasks.html: -------------------------------------------------------------------------------- 1 | {% import "desc.html" as desc %} 2 | 11 | 12 | {% if has_toast %} 13 |
14 | {% include 'flash_msg.html' %} 15 |
16 | {% endif %} 17 |
18 | 19 |
20 |
21 | 22 | {% set on_all = "all" %} 23 | {% set on_complete = "btn-success" %} 24 | {% set on_pending = "btn-warning" %} 25 | {% set on_waiting = "btn-accent" %} 26 | {% set mod_key = "" %} 27 |
28 | {% include 'left_action_bar.html' %} 29 | 30 |
31 | {% for f in current_filter %} 32 | 44 | {% endfor %} 45 | 46 |
47 | 48 | 49 |
50 | 51 |
52 |
53 | 54 | {% if display_time_of_the_day == 1 %} 55 |
56 |
57 |
58 |
59 |
60 |
61 | {% endif %} 62 | 63 | 64 | 65 |
66 | 69 | 72 |
73 | {% for task in tasks %} 74 |
75 |
77 |
78 |
79 | 80 | 96 | 97 | 98 | 108 |
109 |
110 | {{ desc::desc(task=task) }} 111 |
112 |
113 |
114 |
115 |
116 | {% if task.project %} 117 |
118 | {% for p in task.project | split(pat=".") %} 119 | {% set ptag = ["project", p] | join(sep=":") %} 120 | 126 | {% endfor %} 127 |
128 | {% endif %} 129 |
130 | {% if task.priority %} 131 | 138 | {% endif %} 139 | {% if task.tags %} 140 | {% for p in task.tags %} 141 | 148 | {% endfor %} 149 | {% endif %} 150 |
151 |
152 | {% if task.depends %} 153 | {% for uuid in task.depends %} 154 | {%if tasks_db[uuid] %} 155 | 161 | {% endif %} 162 | {% endfor %} 163 | {% endif %} 164 | 165 |
URG
166 | {% if task.urgency > 20 %} 167 |
168 | {{ task.urgency }} 169 |
170 | {% elif task.urgency > 10 %} 171 |
{{ task.urgency }}
172 | {% else %} 173 |
174 | {{ task.urgency }}
175 | {% endif %} 176 | 177 | {% if task.due and task.status != 'completed' %} 178 |
DUE
179 |
180 | {{ date_proper(date=task.due, in_future=true) }} 181 |
182 | {% endif %} 183 | 184 | {% if task.start %} 185 |
ACT
186 |
{{ date_proper(date=task.start) }}
187 | {% endif %} 188 | 189 | {% if task.end %} 190 |
END
191 |
{{ date_proper(date=task.end) }}
192 | {% endif %} 193 | 194 |
195 |
AGE
196 | {% if task.entry %}{{ date_proper(date=task.entry) }}{% endif %} 197 |
198 |
199 |
200 |
201 |
202 | {% endfor %} 203 |
204 |
205 |
206 |
207 |
208 | -------------------------------------------------------------------------------- /src/core/cache.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025 Tarin Mahmood 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | * 6 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | * 8 | * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | */ 10 | 11 | use anyhow::anyhow; 12 | use serde::{Deserialize, Serialize}; 13 | use std::{ 14 | collections::HashMap, 15 | fs::File, 16 | io::{Read, Write}, 17 | path::PathBuf, 18 | sync::{Arc, Mutex}, 19 | }; 20 | 21 | #[derive(Debug, Clone, PartialEq)] 22 | pub enum MnemonicsType { 23 | PROJECT, 24 | TAG, 25 | CustomQuery, 26 | } 27 | 28 | pub trait MnemonicsCache { 29 | fn insert( 30 | &mut self, 31 | mn_type: MnemonicsType, 32 | key: &str, 33 | value: &str, 34 | ovrrde: bool, 35 | ) -> Result<(), anyhow::Error>; 36 | fn remove(&mut self, mn_type: MnemonicsType, key: &str) -> Result<(), anyhow::Error>; 37 | fn get(&self, mn_type: MnemonicsType, key: &str) -> Option; 38 | fn save(&self) -> Result<(), anyhow::Error>; 39 | } 40 | 41 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 42 | pub struct MnemonicsTable { 43 | #[serde(default)] 44 | #[serde(skip_serializing_if = "HashMap::is_empty")] 45 | tags: HashMap, 46 | #[serde(default)] 47 | #[serde(skip_serializing_if = "HashMap::is_empty")] 48 | projects: HashMap, 49 | #[serde(default)] 50 | #[serde(skip_serializing_if = "HashMap::is_empty")] 51 | custom_queries: HashMap, 52 | } 53 | 54 | impl MnemonicsTable { 55 | pub fn get(&self, mn_type: MnemonicsType) -> &HashMap { 56 | match mn_type { 57 | MnemonicsType::PROJECT => &self.projects, 58 | MnemonicsType::TAG => &self.tags, 59 | MnemonicsType::CustomQuery => &self.custom_queries, 60 | } 61 | } 62 | 63 | pub fn insert(&mut self, mn_type: MnemonicsType, key: &str, value: &str) { 64 | let _ = match mn_type { 65 | MnemonicsType::PROJECT => self.projects.insert(key.to_string(), value.to_string()), 66 | MnemonicsType::TAG => self.tags.insert(key.to_string(), value.to_string()), 67 | MnemonicsType::CustomQuery => self 68 | .custom_queries 69 | .insert(key.to_string(), value.to_string()), 70 | }; 71 | } 72 | 73 | pub fn remove(&mut self, mn_type: MnemonicsType, key: &str) { 74 | let _ = match mn_type { 75 | MnemonicsType::PROJECT => self.projects.remove(key), 76 | MnemonicsType::TAG => self.tags.remove(key), 77 | MnemonicsType::CustomQuery => self.custom_queries.remove(key), 78 | }; 79 | } 80 | } 81 | 82 | #[derive(Debug, Clone)] 83 | pub struct FileMnemonicsCache { 84 | cfg_path: Arc>, 85 | map: MnemonicsTable, 86 | } 87 | 88 | impl FileMnemonicsCache { 89 | pub fn new(path: Arc>) -> Self { 90 | Self { 91 | cfg_path: path, 92 | map: MnemonicsTable::default(), 93 | } 94 | } 95 | 96 | pub fn load(&mut self) -> Result<(), anyhow::Error> { 97 | let cfg_path_lck = self.cfg_path.lock().expect("Cannot lock file"); 98 | let file = File::open(cfg_path_lck.as_path()); 99 | if let Ok(mut file_obj) = file { 100 | let mut buf = String::new(); 101 | let _ = file_obj.read_to_string(&mut buf); 102 | if !buf.is_empty() { 103 | let x: MnemonicsTable = toml::from_str(&buf).map_err(|p| { 104 | anyhow!("Could not parse configuration file: {}!", p.to_string()) 105 | })?; 106 | self.map = x; 107 | } 108 | } 109 | Ok(()) 110 | } 111 | } 112 | 113 | impl MnemonicsCache for FileMnemonicsCache { 114 | fn insert( 115 | &mut self, 116 | mn_type: MnemonicsType, 117 | key: &str, 118 | value: &str, 119 | ovrrde: bool, 120 | ) -> Result<(), anyhow::Error> { 121 | // Ensure, that the shortcut is duplicate for the own type. 122 | let x = self 123 | .map 124 | .get(mn_type.clone()) 125 | .iter() 126 | .filter(|p| p.0 != &key) 127 | .find(|p| p.1.as_str().eq(value)); 128 | if let Some(x) = x.clone() { 129 | if ovrrde { 130 | let key_dlt = x.0.clone(); 131 | self.map.remove(mn_type.clone(), &key_dlt); 132 | } else { 133 | return Err(anyhow!("Duplicate key generated!")); 134 | } 135 | } 136 | 137 | if mn_type.eq(&MnemonicsType::PROJECT) { 138 | let x = self 139 | .map 140 | .get(MnemonicsType::TAG) 141 | .values() 142 | .find(|p| p.as_str().eq(value)); 143 | if x.is_some() { 144 | return Err(anyhow!("Duplicate key generated!")); 145 | } 146 | } 147 | if mn_type.eq(&MnemonicsType::TAG) { 148 | let x = self 149 | .map 150 | .get(MnemonicsType::PROJECT) 151 | .values() 152 | .find(|p| p.as_str().eq(value)); 153 | if x.is_some() { 154 | return Err(anyhow!("Duplicate key generated!")); 155 | } 156 | } 157 | 158 | self.map.insert(mn_type, key, value); 159 | self.save()?; 160 | Ok(()) 161 | } 162 | 163 | fn remove(&mut self, mn_type: MnemonicsType, key: &str) -> Result<(), anyhow::Error> { 164 | self.map.remove(mn_type, &key); 165 | self.save()?; 166 | Ok(()) 167 | } 168 | 169 | fn get(&self, mn_type: MnemonicsType, key: &str) -> Option { 170 | self.map.get(mn_type).get(key).cloned() 171 | } 172 | 173 | fn save(&self) -> Result<(), anyhow::Error> { 174 | let p = self.cfg_path.lock().expect("Can lock file"); 175 | let toml = toml::to_string(&self.map).unwrap(); 176 | let mut f = File::create(p.as_path())?; 177 | let _ = f.write_all(toml.as_bytes()); 178 | Ok(()) 179 | } 180 | } 181 | 182 | pub(crate) type MnemonicsCacheType = dyn MnemonicsCache + Send + Sync; 183 | 184 | #[cfg(test)] 185 | mod tests { 186 | use std::{ 187 | io::{Read, Seek}, 188 | str::FromStr, 189 | }; 190 | 191 | use super::*; 192 | use tempfile::NamedTempFile; 193 | 194 | #[test] 195 | fn test_mnemonics_cache() { 196 | let mut file1 = NamedTempFile::new().expect("Cannot create named temp files."); 197 | let x = PathBuf::from(file1.path()); 198 | let file_mtx = Arc::new(Mutex::new(x)); 199 | 200 | let mut mock = FileMnemonicsCache::new(file_mtx); 201 | assert_eq!(mock.get(MnemonicsType::PROJECT, "personal"), None); 202 | assert_eq!( 203 | mock.insert(MnemonicsType::TAG, "personal", "xz", false) 204 | .is_ok(), 205 | true 206 | ); 207 | assert_eq!( 208 | mock.get(MnemonicsType::TAG, "personal"), 209 | Some(String::from("xz")) 210 | ); 211 | // how to validate content? 212 | file1.reopen().expect("Cannot reopen"); 213 | let mut buf = String::new(); 214 | let read_result = file1.read_to_string(&mut buf); 215 | assert_eq!(read_result.is_ok(), true); 216 | let read_result = read_result.expect("Could not read from file"); 217 | assert!(read_result > 0); 218 | assert_eq!( 219 | buf, 220 | String::from("[tags]\npersonal = \"xz\"\n") 221 | ); 222 | assert_eq!( 223 | mock.insert(MnemonicsType::PROJECT, "taskwarrior", "xz", false) 224 | .is_ok(), 225 | false 226 | ); 227 | assert_eq!(mock.remove(MnemonicsType::TAG, "personal").is_ok(), true); 228 | assert_eq!(mock.get(MnemonicsType::TAG, "personal"), None); 229 | assert_eq!( 230 | mock.insert(MnemonicsType::PROJECT, "taskwarrior", "xz", false) 231 | .is_ok(), 232 | true 233 | ); 234 | assert_eq!( 235 | mock.insert(MnemonicsType::TAG, "personal", "xz", false) 236 | .is_ok(), 237 | false 238 | ); 239 | assert_eq!( 240 | mock.remove(MnemonicsType::PROJECT, "taskwarrior").is_ok(), 241 | true 242 | ); 243 | file1.reopen().expect("Cannot reopen"); 244 | let _ = file1.as_file().set_len(0); 245 | let _ = file1.seek(std::io::SeekFrom::Start(0)); 246 | let data = String::from("[tags]\npersonal = \"xz\"\n\n[projects]\n"); 247 | let _ = file1.write_all(data.as_bytes()); 248 | let _ = file1.flush(); 249 | assert_eq!(mock.load().is_ok(), true); 250 | assert_eq!( 251 | mock.get(MnemonicsType::TAG, "personal"), 252 | Some(String::from("xz")) 253 | ); 254 | file1.reopen().expect("Cannot reopen"); 255 | let _ = file1.as_file().set_len(0); 256 | let _ = file1.seek(std::io::SeekFrom::Start(0)); 257 | let data = String::from("**********"); 258 | let _ = file1.write_all(data.as_bytes()); 259 | let _ = file1.flush(); 260 | assert_eq!(mock.load().is_ok(), false); 261 | // Empty file cannot be parsed, but should not through an error! 262 | let _ = file1.as_file().set_len(0); 263 | let _ = file1.seek(std::io::SeekFrom::Start(0)); 264 | let _ = file1.flush(); 265 | assert_eq!(mock.load().is_ok(), true); 266 | // If the configuration file does not exist yet (close will delete), 267 | // it is fine as well. 268 | let _ = file1.close(); 269 | assert_eq!(mock.load().is_ok(), true); 270 | } 271 | 272 | #[test] 273 | fn test_custom_queries() { 274 | let mut file1 = NamedTempFile::new().expect("Cannot create named temp files."); 275 | let x = PathBuf::from(file1.path()); 276 | let file_mtx = Arc::new(Mutex::new(x)); 277 | let mut mock = FileMnemonicsCache::new(file_mtx); 278 | 279 | // Check for retrieving custom query shortcuts. 280 | assert_eq!(mock.get(MnemonicsType::CustomQuery, "one_query"), None); 281 | 282 | // Insert a one_query shortcut and verify, that the query shortcut 283 | // is saved. 284 | assert_eq!( 285 | mock.insert(MnemonicsType::CustomQuery, "one_query", "ad", false) 286 | .is_ok(), 287 | true 288 | ); 289 | assert_eq!( 290 | mock.get(MnemonicsType::CustomQuery, "one_query"), 291 | Some(String::from("ad")) 292 | ); 293 | 294 | // Save to file and ensure, its proper written. 295 | let mut buf = String::new(); 296 | let read_result = file1.read_to_string(&mut buf); 297 | assert_eq!(read_result.is_ok(), true); 298 | let read_result = read_result.expect("Could not read from file"); 299 | assert!(read_result > 0); 300 | assert_eq!( 301 | buf, 302 | String::from("[custom_queries]\none_query = \"ad\"\n") 303 | ); 304 | 305 | // Delete again. 306 | assert_eq!(mock.remove(MnemonicsType::CustomQuery, "one_query").is_ok(), true); 307 | assert_eq!(mock.get(MnemonicsType::CustomQuery, "one_query"), None); 308 | 309 | // Test overwriting of queries. 310 | file1.reopen().expect("Cannot reopen"); 311 | let _ = file1.as_file().set_len(0); 312 | let _ = file1.seek(std::io::SeekFrom::Start(0)); 313 | let data = String::from("[custom_queries]\none_query = \"ad\"\n"); 314 | let _ = file1.write_all(data.as_bytes()); 315 | let _ = file1.flush(); 316 | assert_eq!(mock.load().is_ok(), true); 317 | // Add a second query and ensure, that the one_query gets removed. 318 | assert_eq!( 319 | mock.insert(MnemonicsType::CustomQuery, "second_query", "ad", true) 320 | .is_ok(), 321 | true 322 | ); 323 | assert_eq!(mock.get(MnemonicsType::CustomQuery, "one_query"), None); 324 | assert_eq!(mock.get(MnemonicsType::CustomQuery, "second_query"), Some(String::from("ad"))); 325 | // Ensure, an error is produced in case its not overwritten. 326 | assert_eq!( 327 | mock.insert(MnemonicsType::CustomQuery, "one_query", "ad", false) 328 | .is_err(), 329 | true 330 | ); 331 | } 332 | 333 | #[test] 334 | fn test_mnemonics_cache_file_fail() { 335 | let x = PathBuf::from_str("/4bda0a6b-da0d-46be-98e6-e06d43385fba/asdfa.cache").unwrap(); 336 | let file_mtx = Arc::new(Mutex::new(x)); 337 | 338 | let mut mock = FileMnemonicsCache::new(file_mtx); 339 | assert_eq!( 340 | mock.insert(MnemonicsType::TAG, "personal", "xz", false) 341 | .is_ok(), 342 | false 343 | ); 344 | assert_eq!( 345 | mock.remove(MnemonicsType::PROJECT, "taskwarrior").is_ok(), 346 | false 347 | ); 348 | } 349 | } 350 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025 Tarin Mahmood 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | * 6 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | * 8 | * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | */ 10 | 11 | #![feature(exit_status_error)] 12 | 13 | use std::collections::HashMap; 14 | use std::fmt; 15 | use std::str::FromStr; 16 | 17 | use crate::endpoints::tasks::task_query_builder::{TaskQuery, TaskReport}; 18 | use crate::endpoints::tasks::{is_a_tag, is_tag_keyword}; 19 | use chrono::{DateTime, TimeDelta}; 20 | use linkify::LinkKind; 21 | use rand::distr::{Alphanumeric, SampleString}; 22 | use serde::{Deserialize, Deserializer, Serialize, de}; 23 | use taskchampion::Uuid; 24 | use tera::{Context, escape_html}; 25 | use tracing::{trace, warn}; 26 | 27 | lazy_static::lazy_static! { 28 | pub static ref TEMPLATES: tera::Tera = { 29 | let mut tera = match tera::Tera::new("dist/templates/**/*") { 30 | Ok(t) => t, 31 | Err(e) => { 32 | warn!("Parsing error(s): {}", e); 33 | ::std::process::exit(1); 34 | } 35 | }; 36 | tera.register_function("project_name", get_project_name_link()); 37 | tera.register_function("date_proper", get_date_proper()); 38 | tera.register_function("timer_value", get_timer()); 39 | tera.register_function("date", get_date()); 40 | tera.register_function("obj", obj()); 41 | tera.register_function("remove_project_tag", remove_project_from_tag()); 42 | tera.register_function("strip_prefix", strip_prefix()); 43 | tera.register_filter("linkify", linkify_text()); 44 | tera.register_filter("update_unique_tags", update_unique_tags()); 45 | tera.register_filter("update_tag_bar_key_comb", update_tag_bar_key_comb()); 46 | tera.register_tester("keyword_tag", is_tag_keyword_tests()); 47 | tera.register_tester("user_tag", is_tag_tests()); 48 | tera.autoescape_on(vec![ 49 | ".html", 50 | ".sql" 51 | ]); 52 | tera 53 | }; 54 | } 55 | 56 | pub mod backend; 57 | pub mod core; 58 | pub mod endpoints; 59 | 60 | pub enum Requests { 61 | Filtering { 62 | project: Option, 63 | tags: Option>, 64 | }, 65 | } 66 | 67 | #[derive(Debug, Deserialize, Clone, Serialize)] 68 | #[serde(rename_all = "lowercase")] 69 | pub enum FlashMsgRoles { 70 | Success, 71 | Error, 72 | Warning, 73 | Info, 74 | } 75 | 76 | #[allow(dead_code)] 77 | #[derive(Debug, Deserialize, Clone, Serialize)] 78 | pub struct FlashMsg { 79 | msg: String, 80 | timeout: Option, 81 | role: FlashMsgRoles, 82 | } 83 | 84 | impl FlashMsg { 85 | pub fn msg(&self) -> &str { 86 | &self.msg 87 | } 88 | 89 | pub fn role(&self) -> &FlashMsgRoles { 90 | &self.role 91 | } 92 | 93 | pub fn timeout(&self) -> u64 { 94 | self.timeout.clone().unwrap_or(15) 95 | } 96 | 97 | pub fn new(msg: &str, timeout: Option, role: FlashMsgRoles) -> Self { 98 | Self { 99 | msg: msg.to_string(), 100 | timeout, 101 | role: role, 102 | } 103 | } 104 | 105 | pub fn to_context(&self, ctx: &mut Context) { 106 | ctx.insert("has_toast", &true); 107 | ctx.insert("toast_msg", &self.msg()); 108 | ctx.insert("toast_role", &self.role()); 109 | ctx.insert("toast_timeout", &self.timeout()); 110 | } 111 | } 112 | 113 | #[derive(Debug, Deserialize, Clone, Serialize)] 114 | pub enum TaskActions { 115 | StatusUpdate, 116 | ToggleTimer, 117 | ModifyTask, 118 | AnnotateTask, 119 | DenotateTask, 120 | } 121 | 122 | #[allow(dead_code)] 123 | #[derive(Debug, Deserialize, Clone, Serialize)] 124 | pub struct TWGlobalState { 125 | filter: Option, 126 | query: Option, 127 | report: Option, 128 | status: Option, 129 | uuid: Option, 130 | filter_value: Option, 131 | task_entry: Option, 132 | action: Option, 133 | custom_query: Option, 134 | } 135 | 136 | impl TWGlobalState { 137 | pub fn filter(&self) -> &Option { 138 | &self.filter 139 | } 140 | pub fn query(&self) -> &Option { 141 | &self.query 142 | } 143 | pub fn report(&self) -> &Option { 144 | &self.report 145 | } 146 | pub fn status(&self) -> &Option { 147 | &self.status 148 | } 149 | pub fn uuid(&self) -> &Option { 150 | &self.uuid 151 | } 152 | pub fn filter_value(&self) -> &Option { 153 | &self.filter_value 154 | } 155 | pub fn task_entry(&self) -> &Option { 156 | &self.task_entry 157 | } 158 | pub fn action(&self) -> &Option { 159 | &self.action 160 | } 161 | } 162 | 163 | pub fn task_query_merge_previous_params(state: &TWGlobalState) -> TaskQuery { 164 | if let Some(fv) = state.filter_value.clone() { 165 | let mut tq: TaskQuery = serde_json::from_str(&fv).unwrap(); 166 | tq.update(state.clone()); 167 | tq 168 | } else { 169 | TaskQuery::new(TWGlobalState::default()) 170 | } 171 | } 172 | 173 | pub fn task_query_previous_params(params: &TWGlobalState) -> TaskQuery { 174 | if let Some(fv) = params.filter_value.clone() { 175 | serde_json::from_str(&fv).unwrap() 176 | } else { 177 | TaskQuery::new(TWGlobalState::default()) 178 | } 179 | } 180 | 181 | pub fn from_task_to_task_update(params: &TWGlobalState) -> Option { 182 | if let Some(uuid) = params.uuid.as_ref() 183 | && let Some(status) = params.status.as_ref() 184 | { 185 | return Some(TaskUpdateStatus { 186 | status: status.clone(), 187 | uuid: uuid.clone(), 188 | }); 189 | } 190 | None 191 | } 192 | 193 | impl Default for TWGlobalState { 194 | fn default() -> Self { 195 | Self { 196 | filter: None, 197 | query: None, 198 | report: Some(TaskReport::Next.to_string()), 199 | status: None, 200 | uuid: None, 201 | filter_value: None, 202 | task_entry: None, 203 | action: None, 204 | custom_query: None, 205 | } 206 | } 207 | } 208 | 209 | #[derive(Clone)] 210 | pub struct TaskUpdateStatus { 211 | pub status: String, 212 | pub uuid: Uuid, 213 | } 214 | 215 | /// Serde deserialization decorator to map empty Strings to None, 216 | pub fn empty_string_as_none<'de, D, T>(de: D) -> Result, D::Error> 217 | where 218 | D: Deserializer<'de>, 219 | T: FromStr, 220 | T::Err: fmt::Display, 221 | { 222 | let opt = Option::::deserialize(de)?; 223 | match opt.as_deref() { 224 | None | Some("") => Ok(None), 225 | Some(s) => FromStr::from_str(s).map_err(de::Error::custom).map(Some), 226 | } 227 | } 228 | 229 | fn remove_project_from_tag() -> impl tera::Function { 230 | Box::new( 231 | move |args: &HashMap| -> tera::Result { 232 | let mut pname = 233 | tera::from_value::(args.get("task").clone().unwrap().clone()).unwrap(); 234 | pname = pname 235 | .replace("project:", "") 236 | .split(".") 237 | .last() 238 | .unwrap() 239 | .to_string(); 240 | Ok(tera::to_value(pname).unwrap()) 241 | }, 242 | ) 243 | } 244 | 245 | fn strip_prefix() -> impl tera::Function { 246 | Box::new( 247 | move |args: &HashMap| -> tera::Result { 248 | let pname = 249 | tera::from_value::(args.get("string").clone().unwrap().clone()).unwrap(); 250 | let pprefix = 251 | tera::from_value::(args.get("prefix").clone().unwrap().clone()).unwrap(); 252 | 253 | Ok(tera::to_value(pname.strip_prefix(&pprefix).unwrap().to_string()).unwrap()) 254 | }, 255 | ) 256 | } 257 | 258 | fn linkify_text() -> impl tera::Filter { 259 | Box::new( 260 | move |value: &tera::Value, 261 | _args: &HashMap| 262 | -> tera::Result { 263 | let lfy = linkify::LinkFinder::new(); 264 | let base_text = tera::from_value::(value.clone())?; 265 | trace!("Need to linkify {}", base_text); 266 | let mut new_text = String::new(); 267 | for span in lfy.spans(&base_text) { 268 | let txt = match span.kind() { 269 | Some(link) if *link == LinkKind::Url => { 270 | format!( 271 | "{}", 272 | span.as_str(), 273 | span.as_str() 274 | ) 275 | } 276 | Some(link) if *link == LinkKind::Email => { 277 | format!( 278 | "{}", 279 | span.as_str(), 280 | span.as_str() 281 | ) 282 | } 283 | Some(_) => escape_html(span.as_str()), 284 | None => escape_html(span.as_str()), 285 | }; 286 | new_text.push_str(&txt); 287 | } 288 | 289 | Ok(tera::to_value(new_text)?) 290 | }, 291 | ) 292 | } 293 | 294 | fn get_project_name_link() -> impl tera::Function { 295 | Box::new( 296 | move |args: &HashMap| -> tera::Result { 297 | let pname = 298 | tera::from_value::(args.get("full_name").clone().unwrap().clone()).unwrap(); 299 | let index = 300 | tera::from_value::(args.get("index").clone().unwrap().clone()).unwrap(); 301 | let r: Vec<&str> = pname.split(".").take(index).collect(); 302 | Ok(tera::to_value(r.join(".")).unwrap()) 303 | }, 304 | ) 305 | } 306 | 307 | fn is_tag_keyword_tests() -> impl tera::Test { 308 | Box::new( 309 | move |val: Option<&tera::Value>, _values: &[tera::Value]| -> tera::Result { 310 | let v_str = val.as_ref().unwrap().to_string(); 311 | Ok(is_tag_keyword(&v_str)) 312 | }, 313 | ) 314 | } 315 | 316 | fn is_tag_tests() -> impl tera::Test { 317 | Box::new( 318 | move |val: Option<&tera::Value>, _values: &[tera::Value]| -> tera::Result { 319 | let v_str = val.as_ref().unwrap().to_string(); 320 | Ok(is_a_tag(&v_str)) 321 | }, 322 | ) 323 | } 324 | 325 | fn update_unique_tags() -> impl tera::Filter { 326 | Box::new( 327 | move |value: &tera::Value, 328 | args: &HashMap| 329 | -> tera::Result { 330 | let mut tags = tera::from_value::>(value.clone())?; 331 | let new_tag = tera::from_value::(args.get("tag").clone().unwrap().clone())?; 332 | tags.push(new_tag); 333 | Ok(tera::to_value(tags)?) 334 | }, 335 | ) 336 | } 337 | 338 | fn obj() -> impl tera::Function { 339 | Box::new( 340 | move |_args: &HashMap| -> tera::Result { 341 | let hm: HashMap = HashMap::new(); 342 | Ok(tera::to_value(hm)?) 343 | }, 344 | ) 345 | } 346 | 347 | fn update_tag_bar_key_comb() -> impl tera::Filter { 348 | Box::new( 349 | move |value: &tera::Value, 350 | args: &HashMap| 351 | -> tera::Result { 352 | let mut tag_key_comb = tera::from_value::>(value.clone())?; 353 | let tag = tera::from_value::(args.get("tag").clone().unwrap().clone())?; 354 | loop { 355 | let string = Alphanumeric 356 | .sample_string(&mut rand::rng(), 2) 357 | .to_lowercase(); 358 | if tag_key_comb.iter().find(|&(_k, v)| v == &string).is_some() { 359 | continue; 360 | } 361 | tag_key_comb.insert(tag, string); 362 | break; 363 | } 364 | Ok(tera::to_value(tag_key_comb)?) 365 | }, 366 | ) 367 | } 368 | 369 | pub struct DeltaNow { 370 | pub now: DateTime, 371 | pub delta: TimeDelta, 372 | pub time: DateTime, 373 | } 374 | 375 | impl DeltaNow { 376 | pub fn new(time: &str) -> Self { 377 | let time = chrono::prelude::NaiveDateTime::parse_from_str(time, "%Y%m%dT%H%M%SZ") 378 | .unwrap_or_else(|_| 379 | // Try taskchampions variant. 380 | chrono::prelude::NaiveDateTime::parse_from_str(time, "%Y-%m-%dT%H:%M:%SZ").unwrap()) 381 | .and_utc(); 382 | let now = chrono::prelude::Utc::now(); 383 | let delta = now - time; 384 | Self { now, delta, time } 385 | } 386 | } 387 | 388 | fn get_date_proper() -> impl tera::Function { 389 | Box::new( 390 | move |args: &HashMap| -> tera::Result { 391 | // we are working with utc time 392 | let DeltaNow { 393 | now: _, 394 | delta, 395 | time: _time, 396 | } = DeltaNow::new(args.get("date").unwrap().as_str().unwrap()); 397 | 398 | let num_weeks = delta.num_weeks(); 399 | let num_days = delta.num_days(); 400 | let num_hours = delta.num_hours(); 401 | let num_minutes = delta.num_minutes(); 402 | 403 | let in_future = args 404 | .get("in_future") 405 | .cloned() 406 | .unwrap_or(tera::Value::Bool(false)) 407 | .as_bool() 408 | .unwrap(); 409 | 410 | let sign = if in_future { -1 } else { 1 }; 411 | 412 | let s = if num_weeks.abs() > 0 { 413 | format!("{}w", sign * num_weeks) 414 | } else if num_days.abs() > 0 { 415 | format!("{}d", sign * num_days) 416 | } else if num_hours.abs() > 0 { 417 | format!("{}h", sign * num_hours) 418 | } else { 419 | format!("{}m", sign * num_minutes) 420 | }; 421 | Ok(tera::to_value(s).unwrap()) 422 | }, 423 | ) 424 | } 425 | 426 | fn get_date() -> impl tera::Function { 427 | Box::new( 428 | move |args: &HashMap| -> tera::Result { 429 | // we are working with utc time 430 | let DeltaNow { time, .. } = DeltaNow::new(args.get("date").unwrap().as_str().unwrap()); 431 | Ok(tera::to_value(time.format("%Y-%m-%d %H:%MZ").to_string()).unwrap()) 432 | }, 433 | ) 434 | } 435 | 436 | #[derive(Debug, Serialize, Deserialize, Default)] 437 | pub struct NewTask { 438 | description: String, 439 | tags: Option, 440 | project: Option, 441 | filter_value: Option, 442 | additional: Option, 443 | } 444 | 445 | impl NewTask { 446 | pub fn new( 447 | description: Option, 448 | tags: Option, 449 | project: Option, 450 | filter_value: Option, 451 | additional: Option, 452 | ) -> Self { 453 | Self { 454 | description: description.unwrap_or_default(), 455 | tags, 456 | project, 457 | filter_value, 458 | additional, 459 | } 460 | } 461 | pub fn description(&self) -> &str { 462 | &self.description 463 | } 464 | pub fn tags(&self) -> &Option { 465 | &self.tags 466 | } 467 | pub fn project(&self) -> &Option { 468 | &self.project 469 | } 470 | pub fn filter_value(&self) -> &Option { 471 | &self.filter_value 472 | } 473 | pub fn additional(&self) -> &Option { 474 | &self.additional 475 | } 476 | } 477 | 478 | fn get_timer() -> impl tera::Function { 479 | Box::new( 480 | move |args: &HashMap| -> tera::Result { 481 | // we are working with utc time 482 | let DeltaNow { delta, .. } = DeltaNow::new(args.get("date").unwrap().as_str().unwrap()); 483 | let num_seconds = delta.num_seconds(); 484 | 485 | let s = if delta.num_hours() > 0 { 486 | format!( 487 | "{:>02}:{:>02}", 488 | delta.num_hours(), 489 | delta.num_minutes() - (delta.num_hours() * 60) 490 | ) 491 | } else if delta.num_minutes() > 0 { 492 | format!( 493 | "{:>02}:{:>02}:{:>02}", 494 | delta.num_hours(), 495 | delta.num_minutes(), 496 | num_seconds % 60 497 | ) 498 | } else { 499 | format!("{}s", num_seconds) 500 | }; 501 | Ok(tera::to_value(s).unwrap()) 502 | }, 503 | ) 504 | } 505 | 506 | #[cfg(test)] 507 | mod tests { 508 | 509 | use serde_json::value::Value; 510 | use tera::Filter; 511 | 512 | use super::*; 513 | 514 | #[test] 515 | fn test_tera_linkify_text() { 516 | let filter = linkify_text(); 517 | let value = tera::to_value("This is a test").unwrap(); 518 | let args: HashMap = HashMap::new(); 519 | let result = filter.filter(&value, &args); 520 | assert_eq!(result.is_ok(), true); 521 | assert_eq!(result.unwrap(), tera::to_value("This is a test").unwrap()); 522 | 523 | let value = tera::to_value("This is very-important-url.tld a test").unwrap(); 524 | let result = filter.filter(&value, &args); 525 | assert_eq!(result.is_ok(), true); 526 | assert_eq!( 527 | result.unwrap(), 528 | tera::to_value("This is very-important-url.tld a test").unwrap() 529 | ); 530 | 531 | let value = tera::to_value("This is https://very-important-url.tld a test").unwrap(); 532 | let result = filter.filter(&value, &args); 533 | assert_eq!(result.is_ok(), true); 534 | assert_eq!( 535 | result.unwrap(), 536 | tera::to_value("This is https://very-important-url.tld a test").unwrap() 537 | ); 538 | 539 | let value = tera::to_value("This is twk@twk-test.github.com a test").unwrap(); 540 | let result = filter.filter(&value, &args); 541 | assert_eq!(result.is_ok(), true); 542 | assert_eq!( 543 | result.unwrap(), 544 | tera::to_value("This is twk@twk-test.github.com a test").unwrap() 545 | ); 546 | 547 | let value = tera::to_value("This very important is https://very-important-url.tld a test").unwrap(); 548 | let result = filter.filter(&value, &args); 549 | assert_eq!(result.is_ok(), true); 550 | assert_eq!( 551 | result.unwrap(), 552 | tera::to_value("This <a href="https://very-important-url.tld">very important</a> is https://very-important-url.tld a test").unwrap() 553 | ); 554 | } 555 | } 556 | --------------------------------------------------------------------------------