├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── pull_request_template.md └── workflows │ ├── docker.yml │ ├── lint.yml │ └── prettier.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── backend ├── .dockerignore ├── .gitignore ├── Dockerfile ├── README.md ├── controllers │ ├── add_task.go │ ├── app_handlers.go │ ├── complete_task.go │ ├── controllers_test.go │ ├── delete_task.go │ ├── edit_task.go │ ├── get_tasks.go │ ├── job_queue.go │ ├── modify_task.go │ └── websocket.go ├── go.mod ├── go.sum ├── main.go ├── middleware │ └── ratelimit.go ├── models │ ├── request_body.go │ └── task.go └── utils │ ├── exec_command.go │ ├── generate_encryption_secret.go │ ├── generate_uuid.go │ ├── tw │ ├── add_task.go │ ├── complete_task.go │ ├── delete_task.go │ ├── edit_task.go │ ├── export_tasks.go │ ├── fetch_tasks.go │ ├── modify_task.go │ ├── set_config.go │ ├── sync_tasks.go │ └── taskwarrior_test.go │ └── utils_test.go ├── ccsync.postman_collection.json ├── docker-compose.yml └── frontend ├── .eslintrc.cjs ├── .gitignore ├── .prettierrc ├── Dockerfile ├── README.md ├── babel.config.js ├── badge.py ├── components.json ├── coverage-report.json ├── index.html ├── jest.config.cjs ├── jest.setup.ts ├── nginx.conf ├── package-lock.json ├── package.json ├── postcss.config.js ├── src ├── App.css ├── App.tsx ├── assets │ ├── logo.jpg │ ├── logo.png │ ├── logo_header.png │ └── logo_light.png ├── components │ ├── HomeComponents │ │ ├── BottomBar │ │ │ ├── BottomBar.tsx │ │ │ ├── __tests__ │ │ │ │ ├── BottomBar.test.tsx │ │ │ │ └── bottom-bar-utils.test.ts │ │ │ └── bottom-bar-utils.ts │ │ ├── FAQ │ │ │ ├── FAQ.tsx │ │ │ ├── FAQItem.tsx │ │ │ ├── __tests__ │ │ │ │ ├── FAQ.test.tsx │ │ │ │ ├── FAQItem.test.tsx │ │ │ │ └── faq-utils.test.ts │ │ │ └── faq-utils.ts │ │ ├── Footer │ │ │ ├── Footer.tsx │ │ │ └── __tests__ │ │ │ │ └── Footer.test.tsx │ │ ├── Hero │ │ │ ├── CopyButton.tsx │ │ │ ├── Hero.tsx │ │ │ ├── ToastNotification.tsx │ │ │ └── __tests__ │ │ │ │ ├── CopyButton.test.tsx │ │ │ │ ├── Hero.test.tsx │ │ │ │ └── ToastNotification.test.tsx │ │ ├── Navbar │ │ │ ├── Navbar.tsx │ │ │ ├── NavbarDesktop.tsx │ │ │ ├── NavbarMobile.tsx │ │ │ ├── __tests__ │ │ │ │ ├── Navbar.test.tsx │ │ │ │ ├── NavbarDesktop.test.tsx │ │ │ │ ├── NavbarMobile.test.tsx │ │ │ │ └── navbar-utils.test.ts │ │ │ └── navbar-utils.ts │ │ ├── SetupGuide │ │ │ ├── CopyableCode.tsx │ │ │ ├── SetupGuide.tsx │ │ │ └── __tests__ │ │ │ │ ├── CopyableCode.test.tsx │ │ │ │ └── SetupGuide.test.tsx │ │ └── Tasks │ │ │ ├── Pagination.tsx │ │ │ ├── Tasks.tsx │ │ │ ├── __tests__ │ │ │ ├── Pagination.test.tsx │ │ │ ├── Tasks.test.tsx │ │ │ └── tasks-utils.test.ts │ │ │ ├── hooks.ts │ │ │ └── tasks-utils.ts │ ├── HomePage.tsx │ ├── LandingComponents │ │ ├── About │ │ │ ├── About.tsx │ │ │ └── __tests__ │ │ │ │ └── About.test.tsx │ │ ├── Contact │ │ │ ├── Contact.tsx │ │ │ └── __tests__ │ │ │ │ └── Contact.test.tsx │ │ ├── FAQ │ │ │ ├── FAQ.tsx │ │ │ ├── FAQItem.tsx │ │ │ ├── __tests__ │ │ │ │ ├── FAQ.test.tsx │ │ │ │ ├── FAQItem.test.tsx │ │ │ │ └── faq-utils.test.ts │ │ │ └── faq-utils.ts │ │ ├── Footer │ │ │ ├── Footer.tsx │ │ │ └── __tests__ │ │ │ │ └── Footer.test.tsx │ │ ├── Hero │ │ │ ├── Hero.tsx │ │ │ ├── HeroCards.tsx │ │ │ └── __tests__ │ │ │ │ ├── Hero.test.tsx │ │ │ │ └── HeroCards.test.tsx │ │ ├── HowItWorks │ │ │ ├── HowItWorks.tsx │ │ │ └── __tests__ │ │ │ │ └── HowItWorks.test.tsx │ │ └── Navbar │ │ │ ├── Navbar.tsx │ │ │ ├── NavbarDesktop.tsx │ │ │ ├── NavbarMobile.tsx │ │ │ ├── __tests__ │ │ │ ├── Navbar.test.tsx │ │ │ ├── NavbarDesktop.test.tsx │ │ │ └── navbar-utils.test.ts │ │ │ └── navbar-utils.ts │ ├── LandingPage.tsx │ ├── __tests__ │ │ ├── HomePage.test.tsx │ │ └── LandingPage.test.tsx │ ├── ui │ │ ├── accordion.tsx │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── navigation-menu.tsx │ │ ├── select.tsx │ │ ├── sheet.tsx │ │ └── table.tsx │ └── utils │ │ ├── Icons.tsx │ │ ├── ScrollToTop.tsx │ │ ├── URLs.ts │ │ ├── __tests__ │ │ ├── ScrollToTop.test.tsx │ │ ├── theme-mode-toggle.test.tsx │ │ ├── theme-provider.test.tsx │ │ └── types.test.ts │ │ ├── theme-mode-toggle.tsx │ │ ├── theme-provider.tsx │ │ ├── types.ts │ │ └── utils.ts ├── index.css ├── lib │ └── utils.tsx ├── main.tsx └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | 28 | - OS: [e.g. iOS] 29 | - Browser [e.g. chrome, safari] 30 | - Version [e.g. 22] 31 | 32 | **Smartphone (please complete the following information):** 33 | 34 | - Device: [e.g. iPhone6] 35 | - OS: [e.g. iOS8.1] 36 | - Browser [e.g. stock browser, safari] 37 | - Version [e.g. 22] 38 | 39 | **Additional context** 40 | Add any other context about the problem here. 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### Description 2 | 3 | 4 | 5 | - Fixes: #(replace_with_the_issue_fixed) 6 | 7 | ### Checklist 8 | 9 | - [ ] Ran `npx prettier --write .` (for formatting) 10 | - [ ] Ran `gofmt -w .` (for Go backend) 11 | - [ ] Ran `npm test` (for JS/TS testing) 12 | - [ ] Added unit tests, if applicable 13 | - [ ] Verified all tests pass 14 | - [ ] Updated documentation, if needed 15 | 16 | ### Additional Notes 17 | 18 | 19 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Images to GHCR 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build-and-push-frontend: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: read 13 | packages: write 14 | 15 | steps: 16 | - name: Checkout repository code 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up Docker Buildx 20 | uses: docker/setup-buildx-action@v3 21 | 22 | - name: Log in to GitHub Container Registry (GHCR) 23 | uses: docker/login-action@v3 24 | with: 25 | registry: ghcr.io 26 | username: ${{ github.actor }} 27 | password: ${{ secrets.GITHUB_TOKEN }} 28 | 29 | - name: Build and push frontend image to GHCR 30 | uses: docker/build-push-action@v5 31 | with: 32 | context: ./frontend 33 | push: true 34 | tags: | 35 | ghcr.io/ccsync/frontend:latest 36 | ghcr.io/ccsync/frontend:${{ github.sha }} 37 | cache-from: type=gha 38 | cache-to: type=gha,mode=max 39 | 40 | build-and-push-backend: 41 | runs-on: ubuntu-latest 42 | permissions: 43 | contents: read 44 | packages: write 45 | 46 | steps: 47 | - name: Checkout repository code 48 | uses: actions/checkout@v4 49 | 50 | - name: Set up Docker Buildx 51 | uses: docker/setup-buildx-action@v3 52 | 53 | - name: Log in to GitHub Container Registry (GHCR) 54 | uses: docker/login-action@v3 55 | with: 56 | registry: ghcr.io 57 | username: ${{ github.actor }} 58 | password: ${{ secrets.GITHUB_TOKEN }} 59 | 60 | - name: Build and push backend image to GHCR 61 | uses: docker/build-push-action@v5 62 | with: 63 | context: ./backend 64 | push: true 65 | tags: | 66 | ghcr.io/ccsync/backend:latest 67 | ghcr.io/ccsync/backend:${{ github.sha }} 68 | cache-from: type=gha 69 | cache-to: type=gha,mode=max 70 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Go Format 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | pull_request: 8 | branches: 9 | - "*" 10 | 11 | jobs: 12 | gofmt: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v4 19 | with: 20 | go-version: "1.21" 21 | 22 | - name: Check Go formatting 23 | run: | 24 | files=$(gofmt -l ./backend) 25 | if [[ -n "$files" ]]; then 26 | echo "The following files are not formatted correctly:" 27 | echo "$files" 28 | echo "Please run 'gofmt -w .' inside the backend directory before committing." 29 | exit 1 30 | else 31 | echo "All Go files are properly formatted." 32 | fi 33 | -------------------------------------------------------------------------------- /.github/workflows/prettier.yml: -------------------------------------------------------------------------------- 1 | name: Prettier 2 | on: 3 | push: 4 | branches: 5 | - "main" 6 | pull_request: 7 | branches: 8 | - "*" 9 | jobs: 10 | prettier: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Use Node.js 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version: "20.x" 18 | - run: npm ci 19 | working-directory: frontend 20 | - run: npx prettier --check . 21 | working-directory: frontend 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .backend.env 2 | .frontend.env -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Welcome to CCSync contributing guide 2 | 3 | Thank you for investing your time in contributing to our project! :sparkles:. 4 | 5 | Read our [Code of Conduct](./CODE_OF_CONDUCT.md) to keep our community approachable and respectable. 6 | 7 | In this guide you will get an overview of a contribution workflow from opening an issue, creating a PR, reviewing, and merging the PR. 8 | 9 | ## New contributor guide 10 | 11 | To get an overview of the project, read the [documentation of CCSync](https://its-me-abhishek.github.io/ccsync-docs/). 12 | 13 | ### Issues 14 | 15 | #### Create a new issue 16 | 17 | If you spot a problem with the docs, [search if an issue already exists](https://github.com/its-me-abhishek/ccsync/issues). If a related issue doesn't exist, you can open a new issue using a relevant issue form. 18 | 19 | #### Solve an issue 20 | 21 | Scan through our [existing issues](https://github.com/its-me-abhishek/ccsync/issues) to find one that interests you. You can narrow down the search using `labels` as filters. If you find an issue to work on, and after discussion, it comes out to be a valid issue, you are welcome to open a PR with a fix. 22 | 23 | ### Make Changes 24 | 25 | 1. Fork the repository. 26 | 27 | - Using GitHub Desktop: 28 | 29 | - [Getting started with GitHub Desktop](https://docs.github.com/en/desktop/installing-and-configuring-github-desktop/getting-started-with-github-desktop) will guide you through setting up Desktop. 30 | - Once Desktop is set up, you can use it to [fork the repo](https://docs.github.com/en/desktop/contributing-and-collaborating-using-github-desktop/cloning-and-forking-repositories-from-github-desktop)! 31 | 32 | - Using the command line: 33 | - [Fork the repo](https://docs.github.com/en/github/getting-started-with-github/fork-a-repo#fork-an-example-repository) so that you can make your changes without affecting the original project until you're ready to merge them. 34 | 35 | 2. For more information, see the [development guide](https://its-me-abhishek.github.io/ccsync-docs/). 36 | 37 | 3. Create a new working branch and start with your changes! 38 | 39 | ### Commit your updates 40 | 41 | Commit the changes once you are happy with them. 42 | 43 | Please follow these rules or conventions while committing any new changes: 44 | 45 | - `feat`: new feature for the user, not a new feature for build script 46 | - `fix`: bug fix for the user 47 | - `docs`: changes to the documentation 48 | - `style`: formatting, missing semi colons, etc 49 | - `refactor`: refactoring production code, eg. renaming a variable 50 | - `test`: adding missing tests, refactoring tests 51 | - `chore`: updating grunt tasks, etc., no production code change 52 | - Run `npx prettier --write .` before commiting so as to adhere to the linting scheme of the project's frontend 53 | - Run `gofmt -w .` before commiting so as to adhere to the linting scheme of the project's backend 54 | 55 | ### Pull Request 56 | 57 | When you're finished with the changes, create a pull request, also known as a PR. 58 | 59 | - Don't forget to link PR to issue if you are solving one. 60 | - Enable the checkbox to [allow maintainer edits](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/allowing-changes-to-a-pull-request-branch-created-from-a-fork) so the branch can be updated for a merge. 61 | - If you run into any merge issues, checkout this [git tutorial](https://github.com/skills/resolve-merge-conflicts) to help you resolve merge conflicts and other issues. 62 | 63 | ### Your PR is merged! 64 | 65 | Congratulations :tada::tada:. 66 | 67 | Once your PR is merged, your contributions will be publicly visible in [closed PRs](https://github.com/its-me-abhishek/ccsync/pulls?q=is%3Apr+is%3Aclosed). 68 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Abhishek 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /backend/.dockerignore: -------------------------------------------------------------------------------- 1 | task-3.0.2 2 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | task-3.0.2 3 | task-3.0.2.tar.gz -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Use the official Golang image for building the backend 2 | FROM golang:1.23.1-alpine as build 3 | 4 | # Set working directory 5 | WORKDIR /app 6 | 7 | # Copy go.mod and go.sum files 8 | COPY go.mod go.sum ./ 9 | RUN go mod tidy 10 | 11 | # Copy the rest of the application code 12 | COPY . . 13 | 14 | # Copy the .env file 15 | # COPY .env .env 16 | 17 | # Install dependencies for Taskwarrior and libuuid 18 | RUN apk add --no-cache \ 19 | cmake \ 20 | g++ \ 21 | make \ 22 | tar \ 23 | util-linux-dev \ 24 | rust \ 25 | cargo \ 26 | libuuid \ 27 | libstdc++ \ 28 | libgcc \ 29 | # Ensure all required packages are installed 30 | && apk update \ 31 | && apk add --no-cache \ 32 | libuuid \ 33 | libstdc++ \ 34 | libgcc \ 35 | util-linux 36 | 37 | # Download and build Taskwarrior 38 | RUN wget https://github.com/GothenburgBitFactory/taskwarrior/releases/download/v3.1.0/task-3.1.0.tar.gz -O /tmp/task-3.1.0.tar.gz && \ 39 | tar xzf /tmp/task-3.1.0.tar.gz -C /tmp && \ 40 | cd /tmp/task-3.1.0 && \ 41 | cmake -B build -DCMAKE_BUILD_TYPE=None . && \ 42 | cmake --build build && \ 43 | cmake --install build 44 | 45 | # Build the Go application 46 | RUN go build -o main . 47 | 48 | # Use a minimal image for running the backend 49 | FROM alpine:3.20 50 | WORKDIR /root/ 51 | 52 | # Install runtime dependencies 53 | RUN apk add --no-cache \ 54 | libuuid \ 55 | libstdc++ \ 56 | libgcc \ 57 | gnutls 58 | 59 | # Copy the binary and .env file from the build stage 60 | COPY --from=build /app/main . 61 | # COPY --from=build /app/.env . 62 | COPY --from=build /usr/local/bin/task /usr/local/bin/task 63 | 64 | # Expose port 8000 65 | EXPOSE 8000 66 | 67 | # Command to run the executable 68 | CMD ["./main"] 69 | -------------------------------------------------------------------------------- /backend/README.md: -------------------------------------------------------------------------------- 1 | ## Guide to setup the backend for development purposes: 2 | 3 | - Download the requirements 4 | 5 | ```bash 6 | go mod download 7 | go mod tidy 8 | ``` 9 | 10 | - Go to [Google cloud credential page](https://console.cloud.google.com/apis/credentials) for generating client id and secret. 11 | 12 | - Add the Client ID and secret as an environment variable 13 | - Sample .env format: 14 | 15 | ```bash 16 | CLIENT_ID="client_ID" 17 | CLIENT_SEC="client_SECRET" 18 | REDIRECT_URL_DEV="http://localhost:8000/auth/callback" 19 | SESSION_KEY="" 20 | # If using Docker 21 | FRONTEND_ORIGIN_DEV="http://localhost" 22 | CONTAINER_ORIGIN="http://YOUR_CONTAINER_NAME:8080/" 23 | # Else if using npm 24 | FRONTEND_ORIGIN_DEV="http://localhost:5173" 25 | CONTAINER_ORIGIN="http://localhost:8080/" 26 | ``` 27 | 28 | Common pitfall: use the value 29 | 30 | ``` 31 | FRONTEND_ORIGIN_DEV="http://localhost" 32 | CONTAINER_ORIGIN="http://YOUR_CONTAINER_NAME:8080/" 33 | ``` 34 | 35 | only while using Docker Container 36 | 37 | use 38 | 39 | ``` 40 | FRONTEND_ORIGIN_DEV="http://localhost:5173" 41 | CONTAINER_ORIGIN="http://localhost:8080/" 42 | ``` 43 | 44 | if you want to run by `npm run dev` 45 | 46 | - Run the application: 47 | 48 | ```bash 49 | go mod download 50 | go mod tidy 51 | ``` 52 | 53 | - Run the backend container only: 54 | ```bash 55 | docker-compose build backend 56 | docker-compose up 57 | ``` 58 | -------------------------------------------------------------------------------- /backend/controllers/add_task.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "ccsync_backend/models" 5 | "ccsync_backend/utils/tw" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | ) 11 | 12 | var GlobalJobQueue *JobQueue 13 | 14 | func AddTaskHandler(w http.ResponseWriter, r *http.Request) { 15 | if r.Method == http.MethodPost { 16 | body, err := io.ReadAll(r.Body) 17 | if err != nil { 18 | http.Error(w, fmt.Sprintf("error reading request body: %v", err), http.StatusBadRequest) 19 | return 20 | } 21 | defer r.Body.Close() 22 | // fmt.Printf("Raw request body: %s\n", string(body)) 23 | 24 | var requestBody models.AddTaskRequestBody 25 | 26 | err = json.Unmarshal(body, &requestBody) 27 | if err != nil { 28 | http.Error(w, fmt.Sprintf("error decoding request body: %v", err), http.StatusBadRequest) 29 | return 30 | } 31 | email := requestBody.Email 32 | encryptionSecret := requestBody.EncryptionSecret 33 | uuid := requestBody.UUID 34 | description := requestBody.Description 35 | project := requestBody.Project 36 | priority := requestBody.Priority 37 | dueDate := requestBody.DueDate 38 | tags := requestBody.Tags 39 | 40 | if description == "" { 41 | http.Error(w, "Description is required, and cannot be empty!", http.StatusBadRequest) 42 | return 43 | } 44 | if dueDate == "" { 45 | http.Error(w, "Due Date is required, and cannot be empty!", http.StatusBadRequest) 46 | return 47 | } 48 | job := Job{ 49 | Name: "Add Task", 50 | Execute: func() error { 51 | return tw.AddTaskToTaskwarrior(email, encryptionSecret, uuid, description, project, priority, dueDate, tags) 52 | }, 53 | } 54 | GlobalJobQueue.AddJob(job) 55 | w.WriteHeader(http.StatusAccepted) 56 | return 57 | } 58 | http.Error(w, "Invalid request method", http.StatusMethodNotAllowed) 59 | } 60 | -------------------------------------------------------------------------------- /backend/controllers/app_handlers.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "ccsync_backend/utils" 5 | "context" 6 | "encoding/json" 7 | "log" 8 | "net/http" 9 | "os" 10 | 11 | "github.com/gorilla/sessions" 12 | "golang.org/x/oauth2" 13 | ) 14 | 15 | type App struct { 16 | Config *oauth2.Config 17 | SessionStore *sessions.CookieStore 18 | UserEmail string 19 | EncryptionSecret string 20 | UUID string 21 | } 22 | 23 | func (a *App) OAuthHandler(w http.ResponseWriter, r *http.Request) { 24 | url := a.Config.AuthCodeURL("state", oauth2.AccessTypeOffline) 25 | http.Redirect(w, r, url, http.StatusTemporaryRedirect) 26 | } 27 | 28 | // fetching the info 29 | func (a *App) OAuthCallbackHandler(w http.ResponseWriter, r *http.Request) { 30 | log.Println("Fetching user info...") 31 | 32 | code := r.URL.Query().Get("code") 33 | 34 | t, err := a.Config.Exchange(context.Background(), code) 35 | if err != nil { 36 | http.Error(w, err.Error(), http.StatusBadRequest) 37 | return 38 | } 39 | 40 | client := a.Config.Client(context.Background(), t) 41 | resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo") 42 | if err != nil { 43 | http.Error(w, err.Error(), http.StatusBadRequest) 44 | return 45 | } 46 | defer resp.Body.Close() 47 | 48 | var userInfo map[string]interface{} 49 | if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil { 50 | http.Error(w, err.Error(), http.StatusInternalServerError) 51 | return 52 | } 53 | 54 | email, okEmail := userInfo["email"].(string) 55 | id, okId := userInfo["id"].(string) 56 | if !okEmail || !okId { 57 | http.Error(w, "Unable to retrieve user info", http.StatusInternalServerError) 58 | return 59 | } 60 | uuidStr := utils.GenerateUUID(email, id) 61 | encryptionSecret := utils.GenerateEncryptionSecret(uuidStr, email, id) 62 | 63 | userInfo["uuid"] = uuidStr 64 | userInfo["encryption_secret"] = encryptionSecret 65 | session, _ := a.SessionStore.Get(r, "session-name") 66 | session.Values["user"] = userInfo 67 | if err := session.Save(r, w); err != nil { 68 | http.Error(w, err.Error(), http.StatusInternalServerError) 69 | return 70 | } 71 | 72 | log.Printf("User Info: %v", userInfo) 73 | 74 | frontendOriginDev := os.Getenv("FRONTEND_ORIGIN_DEV") 75 | http.Redirect(w, r, frontendOriginDev+"/home", http.StatusSeeOther) 76 | } 77 | 78 | func (a *App) UserInfoHandler(w http.ResponseWriter, r *http.Request) { 79 | session, _ := a.SessionStore.Get(r, "session-name") 80 | userInfo, ok := session.Values["user"].(map[string]interface{}) 81 | if !ok || userInfo == nil { 82 | http.Error(w, "No user info available", http.StatusUnauthorized) 83 | return 84 | } 85 | 86 | log.Printf("Sending User Info: %v", userInfo) 87 | w.Header().Set("Content-Type", "application/json") 88 | json.NewEncoder(w).Encode(userInfo) 89 | } 90 | 91 | func (a *App) EnableCORS(handler http.Handler) http.Handler { 92 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 93 | allowedOrigin := os.Getenv("FRONTEND_ORIGIN_DEV") // frontend origin 94 | w.Header().Set("Access-Control-Allow-Origin", allowedOrigin) 95 | w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") 96 | w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") 97 | w.Header().Set("Access-Control-Allow-Credentials", "true") // to allow credentials 98 | if r.Method == "OPTIONS" { 99 | w.WriteHeader(http.StatusOK) 100 | return 101 | } 102 | handler.ServeHTTP(w, r) 103 | }) 104 | } 105 | 106 | // logout and delete session 107 | func (a *App) LogoutHandler(w http.ResponseWriter, r *http.Request) { 108 | session, _ := a.SessionStore.Get(r, "session-name") 109 | session.Options.MaxAge = -1 110 | if err := session.Save(r, w); err != nil { 111 | http.Error(w, err.Error(), http.StatusInternalServerError) 112 | return 113 | } 114 | 115 | w.WriteHeader(http.StatusOK) 116 | log.Print("User has logged out") 117 | } 118 | -------------------------------------------------------------------------------- /backend/controllers/complete_task.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "ccsync_backend/models" 5 | "ccsync_backend/utils/tw" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | ) 11 | 12 | func CompleteTaskHandler(w http.ResponseWriter, r *http.Request) { 13 | if r.Method == http.MethodPost { 14 | body, err := io.ReadAll(r.Body) 15 | if err != nil { 16 | http.Error(w, fmt.Sprintf("error reading request body: %v", err), http.StatusBadRequest) 17 | return 18 | } 19 | defer r.Body.Close() 20 | 21 | // fmt.Printf("Raw request body: %s\n", string(body)) 22 | 23 | var requestBody models.CompleteTaskRequestBody 24 | 25 | err = json.Unmarshal(body, &requestBody) 26 | if err != nil { 27 | http.Error(w, fmt.Sprintf("error decoding request body: %v", err), http.StatusBadRequest) 28 | return 29 | } 30 | 31 | email := requestBody.Email 32 | encryptionSecret := requestBody.EncryptionSecret 33 | uuid := requestBody.UUID 34 | taskuuid := requestBody.TaskUUID 35 | 36 | if taskuuid == "" { 37 | http.Error(w, "taskuuid is required", http.StatusBadRequest) 38 | return 39 | } 40 | 41 | // if err := tw.CompleteTaskInTaskwarrior(email, encryptionSecret, uuid, taskuuid); err != nil { 42 | // http.Error(w, err.Error(), http.StatusInternalServerError) 43 | // return 44 | // } 45 | job := Job{ 46 | Name: "Complete Task", 47 | Execute: func() error { 48 | return tw.CompleteTaskInTaskwarrior(email, encryptionSecret, uuid, taskuuid) 49 | }, 50 | } 51 | GlobalJobQueue.AddJob(job) 52 | w.WriteHeader(http.StatusAccepted) 53 | return 54 | } 55 | http.Error(w, "Invalid request method", http.StatusMethodNotAllowed) 56 | } 57 | -------------------------------------------------------------------------------- /backend/controllers/delete_task.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "ccsync_backend/models" 5 | "ccsync_backend/utils/tw" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | ) 11 | 12 | func DeleteTaskHandler(w http.ResponseWriter, r *http.Request) { 13 | if r.Method == http.MethodPost { 14 | body, err := io.ReadAll(r.Body) 15 | if err != nil { 16 | http.Error(w, fmt.Sprintf("error reading request body: %v", err), http.StatusBadRequest) 17 | return 18 | } 19 | defer r.Body.Close() 20 | 21 | var requestBody models.DeleteTaskRequestBody 22 | 23 | err = json.Unmarshal(body, &requestBody) 24 | if err != nil { 25 | http.Error(w, fmt.Sprintf("error decoding request body: %v", err), http.StatusBadRequest) 26 | return 27 | } 28 | 29 | email := requestBody.Email 30 | encryptionSecret := requestBody.EncryptionSecret 31 | uuid := requestBody.UUID 32 | taskuuid := requestBody.TaskUUID 33 | 34 | if taskuuid == "" { 35 | http.Error(w, "taskuuid is required", http.StatusBadRequest) 36 | return 37 | } 38 | 39 | // if err := tw.DeleteTaskInTaskwarrior(email, encryptionSecret, uuid, taskuuid); err != nil { 40 | // http.Error(w, err.Error(), http.StatusInternalServerError) 41 | // return 42 | // } 43 | job := Job{ 44 | Name: "Delete Task", 45 | Execute: func() error { 46 | return tw.DeleteTaskInTaskwarrior(email, encryptionSecret, uuid, taskuuid) 47 | }, 48 | } 49 | GlobalJobQueue.AddJob(job) 50 | w.WriteHeader(http.StatusAccepted) 51 | return 52 | } 53 | http.Error(w, "Invalid request method", http.StatusMethodNotAllowed) 54 | } 55 | -------------------------------------------------------------------------------- /backend/controllers/edit_task.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "ccsync_backend/models" 5 | "ccsync_backend/utils/tw" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | ) 11 | 12 | func EditTaskHandler(w http.ResponseWriter, r *http.Request) { 13 | if r.Method == http.MethodPost { 14 | body, err := io.ReadAll(r.Body) 15 | if err != nil { 16 | http.Error(w, fmt.Sprintf("error reading request body: %v", err), http.StatusBadRequest) 17 | return 18 | } 19 | defer r.Body.Close() 20 | 21 | // fmt.Printf("Raw request body: %s\n", string(body)) 22 | 23 | var requestBody models.EditTaskRequestBody 24 | 25 | err = json.Unmarshal(body, &requestBody) 26 | if err != nil { 27 | http.Error(w, fmt.Sprintf("error decoding request body: %v", err), http.StatusBadRequest) 28 | return 29 | } 30 | 31 | email := requestBody.Email 32 | encryptionSecret := requestBody.EncryptionSecret 33 | uuid := requestBody.UUID 34 | taskID := requestBody.TaskID 35 | description := requestBody.Description 36 | tags := requestBody.Tags 37 | 38 | if taskID == "" { 39 | http.Error(w, "taskID is required", http.StatusBadRequest) 40 | return 41 | } 42 | 43 | // if err := tw.EditTaskInTaskwarrior(uuid, description, email, encryptionSecret, taskID); err != nil { 44 | // http.Error(w, err.Error(), http.StatusInternalServerError) 45 | // return 46 | // } 47 | 48 | job := Job{ 49 | Name: "Edit Task", 50 | Execute: func() error { 51 | return tw.EditTaskInTaskwarrior(uuid, description, email, encryptionSecret, taskID, tags) 52 | }, 53 | } 54 | GlobalJobQueue.AddJob(job) 55 | w.WriteHeader(http.StatusAccepted) 56 | 57 | return 58 | } 59 | http.Error(w, "Invalid request method", http.StatusMethodNotAllowed) 60 | } 61 | -------------------------------------------------------------------------------- /backend/controllers/get_tasks.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "ccsync_backend/utils/tw" 5 | "encoding/json" 6 | "net/http" 7 | "os" 8 | ) 9 | 10 | // helps to fetch tasks using '/tasks' route 11 | func TasksHandler(w http.ResponseWriter, r *http.Request) { 12 | email := r.URL.Query().Get("email") 13 | encryptionSecret := r.URL.Query().Get("encryptionSecret") 14 | UUID := r.URL.Query().Get("UUID") 15 | origin := os.Getenv("CONTAINER_ORIGIN") 16 | if email == "" || encryptionSecret == "" || UUID == "" { 17 | http.Error(w, "Missing required parameters", http.StatusBadRequest) 18 | return 19 | } 20 | 21 | if r.Method == http.MethodGet { 22 | tasks, _ := tw.FetchTasksFromTaskwarrior(email, encryptionSecret, origin, UUID) 23 | if tasks == nil { 24 | http.Error(w, "Failed to fetch tasks at backend", http.StatusInternalServerError) 25 | return 26 | } 27 | w.Header().Set("Content-Type", "application/json") 28 | json.NewEncoder(w).Encode(tasks) 29 | return 30 | } 31 | 32 | http.Error(w, "Invalid request method", http.StatusMethodNotAllowed) 33 | } 34 | -------------------------------------------------------------------------------- /backend/controllers/job_queue.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "sync" 7 | ) 8 | 9 | type Job struct { 10 | Name string 11 | Execute func() error 12 | } 13 | 14 | type JobQueue struct { 15 | jobChannel chan Job 16 | wg sync.WaitGroup 17 | } 18 | 19 | func NewJobQueue() *JobQueue { 20 | queue := &JobQueue{ 21 | jobChannel: make(chan Job, 100), 22 | } 23 | go queue.processJobs() 24 | return queue 25 | } 26 | 27 | func (q *JobQueue) AddJob(job Job) { 28 | q.wg.Add(1) 29 | q.jobChannel <- job 30 | 31 | // notify job queued 32 | go BroadcastJobStatus(JobStatus{ 33 | Job: job.Name, 34 | Status: "queued", 35 | }) 36 | } 37 | 38 | func (q *JobQueue) processJobs() { 39 | for job := range q.jobChannel { 40 | fmt.Printf("Executing job: %s\n", job.Name) 41 | 42 | go BroadcastJobStatus(JobStatus{ 43 | Job: job.Name, 44 | Status: "in-progress", 45 | }) 46 | 47 | if err := job.Execute(); err != nil { 48 | log.Printf("Error executing job %s: %v\n", job.Name, err) 49 | 50 | go BroadcastJobStatus(JobStatus{ 51 | Job: job.Name, 52 | Status: "failure", 53 | }) 54 | } else { 55 | log.Printf("Success in executing job %s\n", job.Name) 56 | 57 | go BroadcastJobStatus(JobStatus{ 58 | Job: job.Name, 59 | Status: "success", 60 | }) 61 | } 62 | 63 | q.wg.Done() 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /backend/controllers/modify_task.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "ccsync_backend/models" 5 | "ccsync_backend/utils/tw" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | ) 11 | 12 | func ModifyTaskHandler(w http.ResponseWriter, r *http.Request) { 13 | if r.Method == http.MethodPost { 14 | body, err := io.ReadAll(r.Body) 15 | if err != nil { 16 | http.Error(w, fmt.Sprintf("error reading request body: %v", err), http.StatusBadRequest) 17 | return 18 | } 19 | defer r.Body.Close() 20 | 21 | // fmt.Printf("Raw request body: %s\n", string(body)) 22 | 23 | var requestBody models.ModifyTaskRequestBody 24 | 25 | err = json.Unmarshal(body, &requestBody) 26 | if err != nil { 27 | http.Error(w, fmt.Sprintf("error decoding request body: %v", err), http.StatusBadRequest) 28 | return 29 | } 30 | email := requestBody.Email 31 | encryptionSecret := requestBody.EncryptionSecret 32 | uuid := requestBody.UUID 33 | taskID := requestBody.TaskID 34 | description := requestBody.Description 35 | project := requestBody.Project 36 | priority := requestBody.Priority 37 | status := requestBody.Status 38 | due := requestBody.Due 39 | tags := requestBody.Tags 40 | 41 | if description == "" { 42 | http.Error(w, "Description is required, and cannot be empty!", http.StatusBadRequest) 43 | return 44 | } 45 | if taskID == "" { 46 | http.Error(w, "taskID is required", http.StatusBadRequest) 47 | return 48 | } 49 | 50 | // if err := tw.ModifyTaskInTaskwarrior(uuid, description, project, priority, status, due, email, encryptionSecret, taskID); err != nil { 51 | // http.Error(w, err.Error(), http.StatusInternalServerError) 52 | // return 53 | // } 54 | 55 | job := Job{ 56 | Name: "Modify Task", 57 | Execute: func() error { 58 | return tw.ModifyTaskInTaskwarrior(uuid, description, project, priority, status, due, email, encryptionSecret, taskID, tags) 59 | }, 60 | } 61 | GlobalJobQueue.AddJob(job) 62 | w.WriteHeader(http.StatusAccepted) 63 | return 64 | } 65 | http.Error(w, "Invalid request method", http.StatusMethodNotAllowed) 66 | } 67 | -------------------------------------------------------------------------------- /backend/controllers/websocket.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | 7 | "github.com/gorilla/websocket" 8 | ) 9 | 10 | type JobStatus struct { 11 | Job string `json:"job"` 12 | Status string `json:"status"` 13 | } 14 | 15 | var upgrader = websocket.Upgrader{ 16 | CheckOrigin: func(r *http.Request) bool { 17 | return true 18 | }, 19 | } 20 | 21 | var clients = make(map[*websocket.Conn]bool) 22 | var broadcast = make(chan JobStatus) 23 | 24 | func WebSocketHandler(w http.ResponseWriter, r *http.Request) { 25 | ws, err := upgrader.Upgrade(w, r, nil) 26 | if err != nil { 27 | log.Println("WebSocket Upgrade Error:", err) 28 | return 29 | } 30 | defer ws.Close() 31 | 32 | clients[ws] = true 33 | log.Println("New WebSocket connection established!") 34 | 35 | for { 36 | _, _, err := ws.ReadMessage() 37 | if err != nil { 38 | delete(clients, ws) 39 | log.Println("WebSocket connection closed:", err) 40 | break 41 | } 42 | } 43 | } 44 | 45 | func BroadcastJobStatus(jobStatus JobStatus) { 46 | log.Printf("Broadcasting: %+v\n", jobStatus) 47 | broadcast <- jobStatus 48 | } 49 | 50 | func JobStatusManager() { 51 | for { 52 | jobStatus := <-broadcast 53 | log.Printf("Sending to clients: %+v\n", jobStatus) 54 | for client := range clients { 55 | err := client.WriteJSON(jobStatus) 56 | if err != nil { 57 | log.Printf("WebSocket Write Error: %v\n", err) 58 | client.Close() 59 | delete(clients, client) 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /backend/go.mod: -------------------------------------------------------------------------------- 1 | module ccsync_backend 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/gorilla/sessions v1.2.2 7 | golang.org/x/oauth2 v0.20.0 8 | ) 9 | 10 | require ( 11 | github.com/davecgh/go-spew v1.1.1 // indirect 12 | github.com/gorilla/securecookie v1.1.2 // indirect 13 | github.com/pmezard/go-difflib v1.0.0 // indirect 14 | gopkg.in/yaml.v3 v3.0.1 // indirect 15 | ) 16 | 17 | require ( 18 | cloud.google.com/go/compute/metadata v0.3.0 // indirect 19 | github.com/google/uuid v1.6.0 20 | github.com/gorilla/websocket v1.5.3 21 | github.com/joho/godotenv v1.5.1 22 | github.com/stretchr/testify v1.9.0 23 | ) 24 | -------------------------------------------------------------------------------- /backend/go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= 2 | cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 6 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 7 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 8 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 9 | github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 10 | github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 11 | github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY= 12 | github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ= 13 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 14 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 15 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 16 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 17 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 18 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 19 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 20 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 21 | golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo= 22 | golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 23 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 24 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 25 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 26 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 27 | -------------------------------------------------------------------------------- /backend/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/gob" 5 | "log" 6 | "net/http" 7 | "os" 8 | "time" 9 | 10 | "github.com/gorilla/sessions" 11 | "github.com/joho/godotenv" 12 | "golang.org/x/oauth2" 13 | "golang.org/x/oauth2/google" 14 | 15 | "ccsync_backend/controllers" 16 | "ccsync_backend/middleware" 17 | ) 18 | 19 | func main() { 20 | if os.Getenv("ENV") != "production" { 21 | _ = godotenv.Load() 22 | log.Println("Loaded") 23 | } else { 24 | log.Println("Continue") 25 | } 26 | 27 | controllers.GlobalJobQueue = controllers.NewJobQueue() 28 | // OAuth2 client credentials 29 | clientID := os.Getenv("CLIENT_ID") 30 | clientSecret := os.Getenv("CLIENT_SEC") 31 | redirectURL := os.Getenv("REDIRECT_URL_DEV") 32 | 33 | // OAuth2 configuration 34 | conf := &oauth2.Config{ 35 | ClientID: clientID, 36 | ClientSecret: clientSecret, 37 | RedirectURL: redirectURL, 38 | Scopes: []string{"email", "profile"}, 39 | Endpoint: google.Endpoint, 40 | } 41 | 42 | // Create a session store 43 | sessionKey := []byte(os.Getenv("SESSION_KEY")) 44 | if len(sessionKey) == 0 { 45 | log.Fatal("SESSION_KEY environment variable is not set or empty") 46 | } 47 | store := sessions.NewCookieStore(sessionKey) 48 | gob.Register(map[string]interface{}{}) 49 | 50 | app := controllers.App{Config: conf, SessionStore: store} 51 | mux := http.NewServeMux() 52 | 53 | //Rate limiter middleware that allows 50 requests per 30 seconds per IP 54 | limiter := middleware.NewRateLimiter(30*time.Second, 50) 55 | rateLimitedHandler := middleware.RateLimitMiddleware(limiter) 56 | 57 | mux.Handle("/auth/oauth", rateLimitedHandler(http.HandlerFunc(app.OAuthHandler))) 58 | mux.Handle("/auth/callback", rateLimitedHandler(http.HandlerFunc(app.OAuthCallbackHandler))) 59 | mux.Handle("/api/user", rateLimitedHandler(http.HandlerFunc(app.UserInfoHandler))) 60 | mux.Handle("/auth/logout", rateLimitedHandler(http.HandlerFunc(app.LogoutHandler))) 61 | mux.Handle("/tasks", rateLimitedHandler(http.HandlerFunc(controllers.TasksHandler))) 62 | mux.Handle("/add-task", rateLimitedHandler(http.HandlerFunc(controllers.AddTaskHandler))) 63 | mux.Handle("/edit-task", rateLimitedHandler(http.HandlerFunc(controllers.EditTaskHandler))) 64 | mux.Handle("/modify-task", rateLimitedHandler(http.HandlerFunc(controllers.ModifyTaskHandler))) 65 | mux.Handle("/complete-task", rateLimitedHandler(http.HandlerFunc(controllers.CompleteTaskHandler))) 66 | mux.Handle("/delete-task", rateLimitedHandler(http.HandlerFunc(controllers.DeleteTaskHandler))) 67 | 68 | mux.HandleFunc("/ws", controllers.WebSocketHandler) 69 | 70 | go controllers.JobStatusManager() 71 | log.Println("Server started at :8000") 72 | if err := http.ListenAndServe(":8000", app.EnableCORS(mux)); err != nil { 73 | log.Fatal(err) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /backend/middleware/ratelimit.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | "sync" 8 | "time" 9 | ) 10 | 11 | type RateLimiter struct { 12 | sync.RWMutex 13 | requests map[string]*FixedWindow 14 | windowSize time.Duration 15 | maxRequests int 16 | cleanupTick time.Duration 17 | } 18 | 19 | type FixedWindow struct { 20 | count int 21 | windowStart time.Time 22 | } 23 | 24 | func NewRateLimiter(windowSize time.Duration, maxRequests int) *RateLimiter { 25 | limiter := &RateLimiter{ 26 | requests: make(map[string]*FixedWindow), 27 | windowSize: windowSize, 28 | maxRequests: maxRequests, 29 | cleanupTick: time.Minute, 30 | } 31 | 32 | go limiter.startCleanup() 33 | return limiter 34 | } 35 | 36 | func (rl *RateLimiter) startCleanup() { 37 | ticker := time.NewTicker(rl.cleanupTick) 38 | defer ticker.Stop() 39 | 40 | for range ticker.C { 41 | rl.cleanup() 42 | } 43 | } 44 | 45 | func (rl *RateLimiter) cleanup() { 46 | rl.Lock() 47 | defer rl.Unlock() 48 | 49 | now := time.Now() 50 | for ip, window := range rl.requests { 51 | if now.Sub(window.windowStart) > rl.windowSize*2 { 52 | delete(rl.requests, ip) 53 | } 54 | } 55 | } 56 | 57 | func (rl *RateLimiter) IsAllowed(ip string) (bool, time.Time) { 58 | rl.Lock() 59 | defer rl.Unlock() 60 | 61 | now := time.Now() 62 | window, exists := rl.requests[ip] 63 | 64 | if !exists { 65 | rl.requests[ip] = &FixedWindow{ 66 | count: 1, 67 | windowStart: now, 68 | } 69 | return true, now.Add(rl.windowSize) 70 | } 71 | 72 | windowEnd := window.windowStart.Add(rl.windowSize) 73 | if now.After(windowEnd) { 74 | window.count = 1 75 | window.windowStart = now 76 | return true, now.Add(rl.windowSize) 77 | } 78 | 79 | if window.count < rl.maxRequests { 80 | window.count++ 81 | return true, windowEnd 82 | } 83 | 84 | return false, windowEnd 85 | } 86 | 87 | func RateLimitMiddleware(limiter *RateLimiter) func(http.Handler) http.Handler { 88 | return func(next http.Handler) http.Handler { 89 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 90 | ip := getRealIP(r) 91 | 92 | allowed, resetTime := limiter.IsAllowed(ip) 93 | if !allowed { 94 | w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", limiter.maxRequests)) 95 | w.Header().Set("X-RateLimit-Reset", resetTime.Format(time.RFC1123)) 96 | w.Header().Set("Retry-After", fmt.Sprintf("%.0f", time.Until(resetTime).Seconds())) 97 | http.Error(w, "Rate limit exceeded. Please try again later.", http.StatusTooManyRequests) 98 | return 99 | } 100 | 101 | next.ServeHTTP(w, r) 102 | }) 103 | } 104 | } 105 | 106 | func getRealIP(r *http.Request) string { 107 | ip := r.Header.Get("X-Real-IP") 108 | if ip != "" { 109 | return ip 110 | } 111 | 112 | ip = r.Header.Get("X-Forwarded-For") 113 | if ip != "" { 114 | ips := strings.Split(ip, ",") 115 | if len(ips) > 0 { 116 | return strings.TrimSpace(ips[0]) 117 | } 118 | } 119 | 120 | ip = r.RemoteAddr 121 | if idx := strings.Index(ip, ":"); idx != -1 { 122 | ip = ip[:idx] 123 | } 124 | 125 | return ip 126 | } 127 | -------------------------------------------------------------------------------- /backend/models/request_body.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // Request body for task related request handlers 4 | type AddTaskRequestBody struct { 5 | Email string `json:"email"` 6 | EncryptionSecret string `json:"encryptionSecret"` 7 | UUID string `json:"UUID"` 8 | Description string `json:"description"` 9 | Project string `json:"project"` 10 | Priority string `json:"priority"` 11 | DueDate string `json:"due"` 12 | Tags []string `json:"tags"` 13 | } 14 | type ModifyTaskRequestBody struct { 15 | Email string `json:"email"` 16 | EncryptionSecret string `json:"encryptionSecret"` 17 | UUID string `json:"UUID"` 18 | TaskID string `json:"taskid"` 19 | Description string `json:"description"` 20 | Project string `json:"project"` 21 | Priority string `json:"priority"` 22 | Status string `json:"status"` 23 | Due string `json:"due"` 24 | Tags []string `json:"tags"` 25 | } 26 | type EditTaskRequestBody struct { 27 | Email string `json:"email"` 28 | EncryptionSecret string `json:"encryptionSecret"` 29 | UUID string `json:"UUID"` 30 | TaskID string `json:"taskid"` 31 | Description string `json:"description"` 32 | Tags []string `json:"tags"` 33 | } 34 | type CompleteTaskRequestBody struct { 35 | Email string `json:"email"` 36 | EncryptionSecret string `json:"encryptionSecret"` 37 | UUID string `json:"UUID"` 38 | TaskUUID string `json:"taskuuid"` 39 | } 40 | type DeleteTaskRequestBody struct { 41 | Email string `json:"email"` 42 | EncryptionSecret string `json:"encryptionSecret"` 43 | UUID string `json:"UUID"` 44 | TaskUUID string `json:"taskuuid"` 45 | } 46 | -------------------------------------------------------------------------------- /backend/models/task.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // Annotation represents a single annotation entry on a Taskwarrior task 4 | type Annotation struct { 5 | Entry string `json:"entry"` 6 | Description string `json:"description"` 7 | } 8 | 9 | // Task represents a Taskwarrior task 10 | type Task struct { 11 | ID int32 `json:"id"` 12 | Description string `json:"description"` 13 | Project string `json:"project"` 14 | Tags []string `json:"tags"` 15 | Status string `json:"status"` 16 | UUID string `json:"uuid"` 17 | Urgency float32 `json:"urgency"` 18 | Priority string `json:"priority"` 19 | Due string `json:"due"` 20 | Start string `json:"start"` 21 | End string `json:"end"` 22 | Entry string `json:"entry"` 23 | Wait string `json:"wait"` 24 | Modified string `json:"modified"` 25 | Depends []string `json:"depends"` 26 | RType string `json:"rtype"` 27 | Recur string `json:"recur"` 28 | Annotations []Annotation `json:"annotations"` 29 | } 30 | -------------------------------------------------------------------------------- /backend/utils/exec_command.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "os/exec" 4 | 5 | func ExecCommandInDir(dir, command string, args ...string) error { 6 | cmd := exec.Command(command, args...) 7 | cmd.Dir = dir 8 | return cmd.Run() 9 | } 10 | 11 | func ExecCommand(command string, args ...string) error { 12 | cmd := exec.Command(command, args...) 13 | return cmd.Run() 14 | } 15 | 16 | func ExecCommandForOutputInDir(dir, command string, args ...string) ([]byte, error) { 17 | cmd := exec.Command(command, args...) 18 | cmd.Dir = dir 19 | return cmd.Output() 20 | } 21 | -------------------------------------------------------------------------------- /backend/utils/generate_encryption_secret.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/hex" 6 | ) 7 | 8 | // logic to generate encryption secret for tw config 9 | func GenerateEncryptionSecret(uuidStr, email, id string) string { 10 | hash := sha256.New() 11 | hash.Write([]byte(uuidStr + email + id)) 12 | return hex.EncodeToString(hash.Sum(nil)) 13 | } 14 | -------------------------------------------------------------------------------- /backend/utils/generate_uuid.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "github.com/google/uuid" 4 | 5 | // logic to generate client ID for tw config 6 | func GenerateUUID(email, id string) string { 7 | namespace := uuid.NewMD5(uuid.NameSpaceOID, []byte(email+id)) 8 | return namespace.String() 9 | } 10 | -------------------------------------------------------------------------------- /backend/utils/tw/add_task.go: -------------------------------------------------------------------------------- 1 | package tw 2 | 3 | import ( 4 | "ccsync_backend/utils" 5 | "fmt" 6 | "os" 7 | "strings" 8 | ) 9 | 10 | // add task to the user's tw client 11 | func AddTaskToTaskwarrior(email, encryptionSecret, uuid, description, project, priority, dueDate string, tags []string) error { 12 | if err := utils.ExecCommand("rm", "-rf", "/root/.task"); err != nil { 13 | return fmt.Errorf("error deleting Taskwarrior data: %v", err) 14 | } 15 | 16 | tempDir, err := os.MkdirTemp("", "taskwarrior-"+email) 17 | if err != nil { 18 | return fmt.Errorf("failed to create temporary directory: %v", err) 19 | } 20 | defer os.RemoveAll(tempDir) 21 | 22 | origin := os.Getenv("CONTAINER_ORIGIN") 23 | if err := SetTaskwarriorConfig(tempDir, encryptionSecret, origin, uuid); err != nil { 24 | return err 25 | } 26 | 27 | if err := SyncTaskwarrior(tempDir); err != nil { 28 | return err 29 | } 30 | 31 | cmdArgs := []string{"add", description} 32 | if project != "" { 33 | cmdArgs = append(cmdArgs, "project:"+project) 34 | } 35 | if priority != "" { 36 | cmdArgs = append(cmdArgs, "priority:"+priority) 37 | } 38 | if dueDate != "" { 39 | cmdArgs = append(cmdArgs, "due:"+dueDate) 40 | } 41 | // Add tags to the task 42 | if len(tags) > 0 { 43 | for _, tag := range tags { 44 | if tag != "" { 45 | // Ensure tag doesn't contain spaces 46 | cleanTag := strings.ReplaceAll(tag, " ", "_") 47 | cmdArgs = append(cmdArgs, "+"+cleanTag) 48 | } 49 | } 50 | } 51 | 52 | if err := utils.ExecCommandInDir(tempDir, "task", cmdArgs...); err != nil { 53 | return fmt.Errorf("failed to add task: %v\n %v", err, cmdArgs) 54 | } 55 | 56 | // Sync Taskwarrior again 57 | if err := SyncTaskwarrior(tempDir); err != nil { 58 | return err 59 | } 60 | 61 | return nil 62 | } 63 | -------------------------------------------------------------------------------- /backend/utils/tw/complete_task.go: -------------------------------------------------------------------------------- 1 | package tw 2 | 3 | import ( 4 | "ccsync_backend/utils" 5 | "fmt" 6 | "os" 7 | ) 8 | 9 | func CompleteTaskInTaskwarrior(email, encryptionSecret, uuid, taskuuid string) error { 10 | if err := utils.ExecCommand("rm", "-rf", "/root/.task"); err != nil { 11 | return fmt.Errorf("error deleting Taskwarrior data: %v", err) 12 | } 13 | tempDir, err := os.MkdirTemp("", "taskwarrior-"+email) 14 | if err != nil { 15 | return fmt.Errorf("failed to create temporary directory: %v", err) 16 | } 17 | defer os.RemoveAll(tempDir) 18 | 19 | origin := os.Getenv("CONTAINER_ORIGIN") 20 | if err := SetTaskwarriorConfig(tempDir, encryptionSecret, origin, uuid); err != nil { 21 | return err 22 | } 23 | 24 | if err := SyncTaskwarrior(tempDir); err != nil { 25 | return err 26 | } 27 | 28 | if err := utils.ExecCommandInDir(tempDir, "task", taskuuid, "done", "rc.confirmation=off"); err != nil { 29 | return fmt.Errorf("failed to mark task as done: %v", err) 30 | } 31 | 32 | // Sync Taskwarrior again 33 | if err := SyncTaskwarrior(tempDir); err != nil { 34 | return err 35 | } 36 | 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /backend/utils/tw/delete_task.go: -------------------------------------------------------------------------------- 1 | package tw 2 | 3 | import ( 4 | "ccsync_backend/utils" 5 | "fmt" 6 | "os" 7 | ) 8 | 9 | func DeleteTaskInTaskwarrior(email, encryptionSecret, uuid, taskuuid string) error { 10 | if err := utils.ExecCommand("rm", "-rf", "/root/.task"); err != nil { 11 | return fmt.Errorf("error deleting Taskwarrior data: %v", err) 12 | } 13 | tempDir, err := os.MkdirTemp("", "taskwarrior-"+email) 14 | if err != nil { 15 | return fmt.Errorf("failed to create temporary directory: %v", err) 16 | } 17 | defer os.RemoveAll(tempDir) 18 | 19 | origin := os.Getenv("CONTAINER_ORIGIN") 20 | if err := SetTaskwarriorConfig(tempDir, encryptionSecret, origin, uuid); err != nil { 21 | return err 22 | } 23 | 24 | if err := SyncTaskwarrior(tempDir); err != nil { 25 | return err 26 | } 27 | 28 | if err := utils.ExecCommandInDir(tempDir, "task", taskuuid, "delete", "rc.confirmation=off"); err != nil { 29 | return fmt.Errorf("failed to mark task as deleted: %v", err) 30 | } 31 | 32 | // Sync Taskwarrior again 33 | if err := SyncTaskwarrior(tempDir); err != nil { 34 | return err 35 | } 36 | 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /backend/utils/tw/edit_task.go: -------------------------------------------------------------------------------- 1 | package tw 2 | 3 | import ( 4 | "ccsync_backend/utils" 5 | "fmt" 6 | "os" 7 | "strings" 8 | ) 9 | 10 | func EditTaskInTaskwarrior(uuid, description, email, encryptionSecret, taskID string, tags []string) error { 11 | if err := utils.ExecCommand("rm", "-rf", "/root/.task"); err != nil { 12 | return fmt.Errorf("error deleting Taskwarrior data: %v", err) 13 | } 14 | tempDir, err := os.MkdirTemp("", "taskwarrior-"+email) 15 | if err != nil { 16 | return fmt.Errorf("failed to create temporary directory: %v", err) 17 | } 18 | defer os.RemoveAll(tempDir) 19 | 20 | origin := os.Getenv("CONTAINER_ORIGIN") 21 | if err := SetTaskwarriorConfig(tempDir, encryptionSecret, origin, uuid); err != nil { 22 | return err 23 | } 24 | 25 | if err := SyncTaskwarrior(tempDir); err != nil { 26 | return err 27 | } 28 | 29 | // Escape the double quotes in the description and format it 30 | if err := utils.ExecCommand("task", taskID, "modify", description); err != nil { 31 | fmt.Println("task " + taskID + " modify " + description) 32 | return fmt.Errorf("failed to edit task: %v", err) 33 | } 34 | 35 | // Handle tags 36 | if len(tags) > 0 { 37 | for _, tag := range tags { 38 | if strings.HasPrefix(tag, "+") { 39 | // Add tag 40 | tagValue := strings.TrimPrefix(tag, "+") 41 | if err := utils.ExecCommand("task", taskID, "modify", "+"+tagValue); err != nil { 42 | return fmt.Errorf("failed to add tag %s: %v", tagValue, err) 43 | } 44 | } else if strings.HasPrefix(tag, "-") { 45 | // Remove tag 46 | tagValue := strings.TrimPrefix(tag, "-") 47 | if err := utils.ExecCommand("task", taskID, "modify", "-"+tagValue); err != nil { 48 | return fmt.Errorf("failed to remove tag %s: %v", tagValue, err) 49 | } 50 | } else { 51 | // Add tag without prefix 52 | if err := utils.ExecCommand("task", taskID, "modify", "+"+tag); err != nil { 53 | return fmt.Errorf("failed to add tag %s: %v", tag, err) 54 | } 55 | } 56 | } 57 | } 58 | 59 | // Sync Taskwarrior again 60 | if err := SyncTaskwarrior(tempDir); err != nil { 61 | return err 62 | } 63 | return nil 64 | } 65 | -------------------------------------------------------------------------------- /backend/utils/tw/export_tasks.go: -------------------------------------------------------------------------------- 1 | package tw 2 | 3 | import ( 4 | "ccsync_backend/models" 5 | "ccsync_backend/utils" 6 | "encoding/json" 7 | "fmt" 8 | ) 9 | 10 | // export the tasks so as to add them to DB 11 | func ExportTasks(tempDir string) ([]models.Task, error) { 12 | output, err := utils.ExecCommandForOutputInDir(tempDir, "task", "export") 13 | if err != nil { 14 | return nil, fmt.Errorf("error executing Taskwarrior export command: %v", err) 15 | } 16 | 17 | // Parse the exported tasks 18 | var tasks []models.Task 19 | if err := json.Unmarshal(output, &tasks); err != nil { 20 | return nil, fmt.Errorf("error parsing tasks: %v", err) 21 | } 22 | 23 | return tasks, nil 24 | } 25 | -------------------------------------------------------------------------------- /backend/utils/tw/fetch_tasks.go: -------------------------------------------------------------------------------- 1 | package tw 2 | 3 | import ( 4 | "ccsync_backend/models" 5 | "ccsync_backend/utils" 6 | "fmt" 7 | "os" 8 | ) 9 | 10 | // complete logic (delete config if any->setup config->sync->get tasks->export) 11 | func FetchTasksFromTaskwarrior(email, encryptionSecret, origin, UUID string) ([]models.Task, error) { 12 | // temporary directory for each user 13 | if err := utils.ExecCommand("rm", "-rf", "/root/.task"); err != nil { 14 | return nil, fmt.Errorf("error deleting Taskwarrior data: %v", err) 15 | } 16 | 17 | tempDir, err := os.MkdirTemp("", "taskwarrior-"+email) 18 | if err != nil { 19 | return nil, fmt.Errorf("failed to create temporary directory: %v", err) 20 | } 21 | defer os.RemoveAll(tempDir) 22 | 23 | if err := SetTaskwarriorConfig(tempDir, encryptionSecret, origin, UUID); err != nil { 24 | return nil, err 25 | } 26 | 27 | if err := SyncTaskwarrior(tempDir); err != nil { 28 | return nil, err 29 | } 30 | 31 | tasks, err := ExportTasks(tempDir) 32 | if err != nil { 33 | return nil, err 34 | } 35 | return tasks, nil 36 | } 37 | -------------------------------------------------------------------------------- /backend/utils/tw/modify_task.go: -------------------------------------------------------------------------------- 1 | package tw 2 | 3 | import ( 4 | "ccsync_backend/utils" 5 | "fmt" 6 | "os" 7 | "strings" 8 | ) 9 | 10 | func ModifyTaskInTaskwarrior(uuid, description, project, priority, status, due, email, encryptionSecret, taskID string, tags []string) error { 11 | if err := utils.ExecCommand("rm", "-rf", "/root/.task"); err != nil { 12 | fmt.Println("1") 13 | return fmt.Errorf("error deleting Taskwarrior data: %v", err) 14 | } 15 | tempDir, err := os.MkdirTemp("", "taskwarrior-"+email) 16 | if err != nil { 17 | fmt.Println("2") 18 | return fmt.Errorf("failed to create temporary directory: %v", err) 19 | } 20 | defer os.RemoveAll(tempDir) 21 | 22 | origin := os.Getenv("CONTAINER_ORIGIN") 23 | if err := SetTaskwarriorConfig(tempDir, encryptionSecret, origin, uuid); err != nil { 24 | fmt.Println("4") 25 | return err 26 | } 27 | 28 | if err := SyncTaskwarrior(tempDir); err != nil { 29 | fmt.Println("5") 30 | return err 31 | } 32 | 33 | escapedDescription := fmt.Sprintf(`description:"%s"`, strings.ReplaceAll(description, `"`, `\"`)) 34 | 35 | if err := utils.ExecCommand("task", taskID, "modify", escapedDescription); err != nil { 36 | fmt.Println("6") 37 | return fmt.Errorf("failed to edit task: %v", err) 38 | } 39 | 40 | escapedProject := fmt.Sprintf(`project:%s`, strings.ReplaceAll(project, `"`, `\"`)) 41 | if err := utils.ExecCommand("task", taskID, "modify", escapedProject); err != nil { 42 | fmt.Println("7") 43 | return fmt.Errorf("failed to edit task project: %v", err) 44 | } 45 | 46 | escapedPriority := fmt.Sprintf(`priority:%s`, strings.ReplaceAll(priority, `"`, `\"`)) 47 | if err := utils.ExecCommand("task", taskID, "modify", escapedPriority); err != nil { 48 | fmt.Println("8") 49 | return fmt.Errorf("failed to edit task priority: %v", err) 50 | } 51 | 52 | escapedDue := fmt.Sprintf(`due:%s`, strings.ReplaceAll(due, `"`, `\"`)) 53 | if err := utils.ExecCommand("task", taskID, "modify", escapedDue); err != nil { 54 | fmt.Println("8") 55 | return fmt.Errorf("failed to edit task due: %v", err) 56 | } 57 | 58 | // escapedStatus := fmt.Sprintf(`status:%s`, strings.ReplaceAll(status, `"`, `\"`)) 59 | if status == "completed" { 60 | utils.ExecCommand("task", taskID, "done", "rc.confirmation=off") 61 | } else if status == "deleted" { 62 | utils.ExecCommand("task", taskID, "delete", "rc.confirmation=off") 63 | } 64 | 65 | // Handle tags 66 | if len(tags) > 0 { 67 | for _, tag := range tags { 68 | if strings.HasPrefix(tag, "+") { 69 | // Add tag 70 | tagValue := strings.TrimPrefix(tag, "+") 71 | if err := utils.ExecCommand("task", taskID, "modify", "+"+tagValue); err != nil { 72 | return fmt.Errorf("failed to add tag %s: %v", tagValue, err) 73 | } 74 | } else if strings.HasPrefix(tag, "-") { 75 | // Remove tag 76 | tagValue := strings.TrimPrefix(tag, "-") 77 | if err := utils.ExecCommand("task", taskID, "modify", "-"+tagValue); err != nil { 78 | return fmt.Errorf("failed to remove tag %s: %v", tagValue, err) 79 | } 80 | } else { 81 | // Add tag without prefix 82 | if err := utils.ExecCommand("task", taskID, "modify", "+"+tag); err != nil { 83 | return fmt.Errorf("failed to add tag %s: %v", tag, err) 84 | } 85 | } 86 | } 87 | } 88 | 89 | if err := SyncTaskwarrior(tempDir); err != nil { 90 | fmt.Println("11") 91 | return err 92 | } 93 | 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /backend/utils/tw/set_config.go: -------------------------------------------------------------------------------- 1 | package tw 2 | 3 | import ( 4 | "ccsync_backend/utils" 5 | "fmt" 6 | ) 7 | 8 | // logic to set tw config on backend 9 | func SetTaskwarriorConfig(tempDir, encryptionSecret, origin, UUID string) error { 10 | configCmds := [][]string{ 11 | {"task", "config", "sync.encryption_secret", encryptionSecret, "rc.confirmation=off"}, 12 | {"task", "config", "sync.server.origin", origin, "rc.confirmation=off"}, 13 | {"task", "config", "sync.server.client_id", UUID, "rc.confirmation=off"}, 14 | } 15 | 16 | for _, args := range configCmds { 17 | if err := utils.ExecCommandInDir(tempDir, args[0], args[1:]...); err != nil { 18 | return fmt.Errorf("error setting Taskwarrior config (%v): %v", args, err) 19 | } 20 | } 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /backend/utils/tw/sync_tasks.go: -------------------------------------------------------------------------------- 1 | package tw 2 | 3 | import ( 4 | "ccsync_backend/utils" 5 | "fmt" 6 | ) 7 | 8 | // sync the user's tasks to all of their TW clients 9 | func SyncTaskwarrior(tempDir string) error { 10 | if err := utils.ExecCommandInDir(tempDir, "task", "sync"); err != nil { 11 | return fmt.Errorf("error syncing Taskwarrior: %v", err) 12 | } 13 | return nil 14 | } 15 | -------------------------------------------------------------------------------- /backend/utils/tw/taskwarrior_test.go: -------------------------------------------------------------------------------- 1 | package tw 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestSetTaskwarriorConfig(t *testing.T) { 9 | err := SetTaskwarriorConfig("./", "encryption_secret", "container_origin", "client_id") 10 | if err != nil { 11 | t.Errorf("SetTaskwarriorConfig() failed: %v", err) 12 | } else { 13 | fmt.Println("SetTaskwarriorConfig test passed") 14 | } 15 | } 16 | func TestSyncTaskwarrior(t *testing.T) { 17 | err := SyncTaskwarrior("./") 18 | if err != nil { 19 | t.Errorf("SyncTaskwarrior failed: %v", err) 20 | } else { 21 | fmt.Println("Sync Dir test passed") 22 | } 23 | } 24 | 25 | func TestEditTaskInATaskwarrior(t *testing.T) { 26 | err := EditTaskInTaskwarrior("uuid", "description", "email", "encryptionSecret", "taskuuid", nil) 27 | if err != nil { 28 | t.Errorf("EditTaskInTaskwarrior() failed: %v", err) 29 | } else { 30 | fmt.Println("Edit test passed") 31 | } 32 | } 33 | 34 | func TestExportTasks(t *testing.T) { 35 | task, err := ExportTasks("./") 36 | if task != nil && err == nil { 37 | fmt.Println("Task export test passed") 38 | } else { 39 | t.Errorf("ExportTasks() failed: %v", err) 40 | } 41 | } 42 | 43 | func TestAddTaskToTaskwarrior(t *testing.T) { 44 | err := AddTaskToTaskwarrior("email", "encryption_secret", "clientId", "description", "", "H", "2025-03-03", nil) 45 | if err != nil { 46 | t.Errorf("AddTaskToTaskwarrior failed: %v", err) 47 | } else { 48 | fmt.Println("Add task passed") 49 | } 50 | } 51 | 52 | func TestCompleteTaskInTaskwarrior(t *testing.T) { 53 | err := CompleteTaskInTaskwarrior("email", "encryptionSecret", "client_id", "taskuuid") 54 | if err != nil { 55 | t.Errorf("CompleteTaskInTaskwarrior failed: %v", err) 56 | } else { 57 | fmt.Println("Complete task passed") 58 | } 59 | } 60 | 61 | func TestAddTaskWithTags(t *testing.T) { 62 | err := AddTaskToTaskwarrior("email", "encryption_secret", "clientId", "description", "", "H", "2025-03-03", []string{"work", "important"}) 63 | if err != nil { 64 | t.Errorf("AddTaskToTaskwarrior with tags failed: %v", err) 65 | } else { 66 | fmt.Println("Add task with tags passed") 67 | } 68 | } 69 | 70 | func TestEditTaskWithTagAddition(t *testing.T) { 71 | err := EditTaskInTaskwarrior("uuid", "description", "email", "encryptionSecret", "taskuuid", []string{"+urgent", "+important"}) 72 | if err != nil { 73 | t.Errorf("EditTaskInTaskwarrior with tag addition failed: %v", err) 74 | } else { 75 | fmt.Println("Edit task with tag addition passed") 76 | } 77 | } 78 | 79 | func TestEditTaskWithTagRemoval(t *testing.T) { 80 | err := EditTaskInTaskwarrior("uuid", "description", "email", "encryptionSecret", "taskuuid", []string{"-work", "-lowpriority"}) 81 | if err != nil { 82 | t.Errorf("EditTaskInTaskwarrior with tag removal failed: %v", err) 83 | } else { 84 | fmt.Println("Edit task with tag removal passed") 85 | } 86 | } 87 | 88 | func TestEditTaskWithMixedTagOperations(t *testing.T) { 89 | err := EditTaskInTaskwarrior("uuid", "description", "email", "encryptionSecret", "taskuuid", []string{"+urgent", "-work", "normal"}) 90 | if err != nil { 91 | t.Errorf("EditTaskInTaskwarrior with mixed tag operations failed: %v", err) 92 | } else { 93 | fmt.Println("Edit task with mixed tag operations passed") 94 | } 95 | } 96 | 97 | func TestModifyTaskWithTags(t *testing.T) { 98 | err := ModifyTaskInTaskwarrior("uuid", "description", "project", "H", "pending", "2025-03-03", "email", "encryptionSecret", "taskuuid", []string{"+urgent", "-work", "normal"}) 99 | if err != nil { 100 | t.Errorf("ModifyTaskInTaskwarrior with tags failed: %v", err) 101 | } else { 102 | fmt.Println("Modify task with tags passed") 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /backend/utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/hex" 6 | "os" 7 | "testing" 8 | 9 | "github.com/google/uuid" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func Test_GenerateUUID(t *testing.T) { 14 | email := "test@example.com" 15 | id := "12345" 16 | expectedUUID := uuid.NewMD5(uuid.NameSpaceOID, []byte(email+id)).String() 17 | 18 | uuidStr := GenerateUUID(email, id) 19 | assert.Equal(t, expectedUUID, uuidStr) 20 | } 21 | 22 | func Test_GenerateEncryptionSecret(t *testing.T) { 23 | uuidStr := "uuid-test" 24 | email := "test@example.com" 25 | id := "12345" 26 | hash := sha256.New() 27 | hash.Write([]byte(uuidStr + email + id)) 28 | expectedSecret := hex.EncodeToString(hash.Sum(nil)) 29 | 30 | encryptionSecret := GenerateEncryptionSecret(uuidStr, email, id) 31 | assert.Equal(t, expectedSecret, encryptionSecret) 32 | } 33 | 34 | func Test_ExecCommandInDir(t *testing.T) { 35 | tempDir, err := os.MkdirTemp("", "testdir") 36 | if err != nil { 37 | t.Fatalf("Failed to create temp directory: %v", err) 38 | } 39 | defer os.RemoveAll(tempDir) 40 | 41 | tempFile := tempDir + "/testfile" 42 | if err := os.WriteFile(tempFile, []byte("hello"), 0644); err != nil { 43 | t.Fatalf("Failed to create temp file: %v", err) 44 | } 45 | 46 | command := "ls" 47 | args := []string{"-l"} 48 | 49 | if err := ExecCommandInDir(tempDir, command, args...); err != nil { 50 | t.Errorf("ExecCommandInDir failed: %v", err) 51 | } 52 | } 53 | 54 | func Test_ExecCommand(t *testing.T) { 55 | command := "echo" 56 | args := []string{"hello world"} 57 | 58 | if err := ExecCommand(command, args...); err != nil { 59 | t.Errorf("ExecCommand failed: %v", err) 60 | } 61 | } 62 | 63 | func Test_ExecCommandForOutputInDir(t *testing.T) { 64 | tempDir, err := os.MkdirTemp("", "testdir") 65 | if err != nil { 66 | t.Fatalf("Failed to create temp directory: %v", err) 67 | } 68 | defer os.RemoveAll(tempDir) 69 | 70 | tempFile := tempDir + "/testfile" 71 | if err := os.WriteFile(tempFile, []byte("hello"), 0644); err != nil { 72 | t.Fatalf("Failed to create temp file: %v", err) 73 | } 74 | 75 | command := "ls" 76 | args := []string{"-1"} 77 | 78 | output, err := ExecCommandForOutputInDir(tempDir, command, args...) 79 | if err != nil { 80 | t.Errorf("ExecCommandForOutputInDir failed: %v", err) 81 | } 82 | 83 | if string(output) == "" { 84 | t.Errorf("Expected output but got empty result") 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | frontend: 3 | build: 4 | context: ./frontend 5 | dockerfile: Dockerfile 6 | ports: 7 | - "80:80" 8 | networks: 9 | - tasknetwork 10 | depends_on: 11 | - backend 12 | env_file: 13 | - ./.frontend.env 14 | healthcheck: 15 | test: ["CMD", "curl", "-f", "http://localhost:80"] 16 | interval: 30s 17 | timeout: 10s 18 | retries: 3 19 | 20 | backend: 21 | build: 22 | context: ./backend 23 | dockerfile: Dockerfile 24 | ports: 25 | - "8000:8000" 26 | networks: 27 | - tasknetwork 28 | depends_on: 29 | - syncserver 30 | env_file: 31 | - ./.backend.env 32 | healthcheck: 33 | test: ["CMD", "curl", "-f", "http://localhost:8000/health"] 34 | interval: 30s 35 | timeout: 10s 36 | retries: 3 37 | volumes: 38 | - ./backend/data:/app/data 39 | 40 | syncserver: 41 | image: ghcr.io/gothenburgbitfactory/taskchampion-sync-server:latest 42 | ports: 43 | - "8080:8080" 44 | networks: 45 | - tasknetwork 46 | healthcheck: 47 | test: ["CMD", "curl", "-f", "http://localhost:8080/health"] 48 | interval: 30s 49 | timeout: 10s 50 | retries: 3 51 | 52 | networks: 53 | tasknetwork: 54 | driver: bridge 55 | -------------------------------------------------------------------------------- /frontend/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | node: true, 5 | extends: [ 6 | 'eslint:recommended', 7 | 'plugin:@typescript-eslint/recommended', 8 | 'plugin:react-hooks/recommended', 9 | ], 10 | ignorePatterns: ['dist', '.eslintrc.cjs'], 11 | parser: '@typescript-eslint/parser', 12 | plugins: ['react-refresh'], 13 | rules: { 14 | 'react-refresh/only-export-components': [ 15 | 'warn', 16 | { allowConstantExport: true }, 17 | ], 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /frontend/.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 | coverage 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 | .env 26 | -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "trailingComma": "es5", 5 | "printWidth": 80, 6 | "tabWidth": 2, 7 | "arrowParens": "always" 8 | } 9 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Use node image for building the frontend 2 | FROM node:16-alpine as build 3 | 4 | # Set working directory 5 | WORKDIR /app 6 | 7 | # Copy package.json and install dependencies 8 | COPY package.json package-lock.json ./ 9 | RUN npm install 10 | 11 | # Copy the rest of the application code and build 12 | COPY . . 13 | RUN npm run build 14 | 15 | # Serve the frontend using nginx 16 | FROM nginx:alpine 17 | COPY --from=build /app/dist /usr/share/nginx/html 18 | 19 | # Copy nginx configuration file 20 | COPY nginx.conf /etc/nginx/conf.d/default.conf 21 | 22 | # Expose port 80 23 | EXPOSE 80 24 | 25 | # Start nginx 26 | CMD ["nginx", "-g", "daemon off;"] 27 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | ## Guide to setup the frontend for development purposes 2 | 3 | - ```bash 4 | npm i 5 | ``` 6 | 7 | - ```bash 8 | npm run dev 9 | ``` 10 | 11 | - Set environment variables in .env as: 12 | 13 | For docker usage: 14 | 15 | ```bash 16 | VITE_BACKEND_URL="http://localhost:8000/" 17 | VITE_FRONTEND_URL="http://localhost:80" 18 | VITE_CONTAINER_ORIGIN="http://localhost:8080/" 19 | ``` 20 | 21 | For normal npm usage: 22 | 23 | ```bash 24 | VITE_BACKEND_URL="http://localhost:8000/" 25 | VITE_FRONTEND_URL="http://localhost:5173" 26 | VITE_CONTAINER_ORIGIN="http://localhost:8080/" 27 | ``` 28 | 29 | - Note: The ports can be changed on demand, and if you want to do so, be sure to change ports of the Dockerfiles as well as the ports in docker-compose.yml 30 | 31 | - Run the frontend container only: 32 | ```bash 33 | docker-compose build frontend 34 | docker-compose up 35 | ``` 36 | -------------------------------------------------------------------------------- /frontend/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { targets: { node: 'current' } }], 4 | '@babel/preset-typescript', 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /frontend/badge.py: -------------------------------------------------------------------------------- 1 | # script for extracting code coverage value for readme 2 | 3 | from bs4 import BeautifulSoup 4 | import json 5 | 6 | def extract_coverage_data(file_path): 7 | with open(file_path, "r", encoding="utf-8") as file: 8 | html_content = file.read() 9 | soup = BeautifulSoup(html_content, "lxml") 10 | div = soup.find("div", class_="fl pad1y space-right2") 11 | percentage = div.find("span", class_="strong").get_text(strip=True) 12 | return {"frontend": percentage} 13 | 14 | def save_to_json(data, output_path): 15 | with open(output_path, "w", encoding="utf-8") as json_file: 16 | json.dump(data, json_file, indent=4) 17 | 18 | 19 | html_file_path = "./coverage/lcov-report/index.html" 20 | json_file_path = "./coverage-report.json" 21 | 22 | coverage_data = extract_coverage_data(html_file_path) 23 | 24 | save_to_json(coverage_data, json_file_path) 25 | 26 | print(f"Coverage data has been saved to {json_file_path}") 27 | -------------------------------------------------------------------------------- /frontend/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/app.css", 9 | "baseColor": "slate", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /frontend/coverage-report.json: -------------------------------------------------------------------------------- 1 | { 2 | "frontend": "80.71%" 3 | } 4 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | CCSync 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /frontend/jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'jsdom', 4 | moduleNameMapper: { 5 | '^@/(.*)$': ['/src/$1', '/lib/$1'], 6 | '\\.(scss|sass|css)$': 'identity-obj-proxy', 7 | }, 8 | transformIgnorePatterns: ['/node_modules/(?!react-toastify)'], 9 | setupFilesAfterEnv: ['/jest.setup.ts'], 10 | collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts'], 11 | testMatch: ['**/__tests__/**/*.{ts,tsx}', '**/?(*.)+(spec|test).{ts,tsx}'], 12 | transform: { 13 | '^.+\\.tsx?$': [ 14 | 'ts-jest', 15 | { 16 | diagnostics: { 17 | ignoreCodes: [1343], 18 | }, 19 | astTransformers: { 20 | before: [ 21 | { 22 | path: 'node_modules/ts-jest-mock-import-meta', 23 | options: { 24 | metaObjectReplacement: { url: 'https://www.url.com' }, 25 | }, 26 | }, 27 | ], 28 | }, 29 | }, 30 | ], 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /frontend/jest.setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | -------------------------------------------------------------------------------- /frontend/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | 4 | location / { 5 | root /usr/share/nginx/html; 6 | try_files $uri /index.html; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /frontend/src/App.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* *=========== Green theme =========== */ 6 | @layer base { 7 | :root { 8 | --background: 0 0% 100%; 9 | --foreground: 240 10% 3.9%; 10 | --card: 0 0% 100%; 11 | --card-foreground: 240 10% 3.9%; 12 | --popover: 0 0% 100%; 13 | --popover-foreground: 240 10% 3.9%; 14 | --primary: 142.1 76.2% 36.3%; 15 | --primary-foreground: 355.7 100% 97.3%; 16 | --secondary: 240 4.8% 95.9%; 17 | --secondary-foreground: 240 5.9% 10%; 18 | --muted: 240 4.8% 95.9%; 19 | --muted-foreground: 240 3.8% 46.1%; 20 | --accent: 240 4.8% 95.9%; 21 | --accent-foreground: 240 5.9% 10%; 22 | --destructive: 0 84.2% 60.2%; 23 | --destructive-foreground: 0 0% 98%; 24 | --border: 240 5.9% 90%; 25 | --input: 240 5.9% 90%; 26 | --ring: 142.1 76.2% 36.3%; 27 | --radius: 0.5rem; 28 | } 29 | 30 | .dark { 31 | --background: 20 14.3% 4.1%; 32 | --foreground: 0 0% 95%; 33 | --card: 24 9.8% 10%; 34 | --card-foreground: 0 0% 95%; 35 | --popover: 0 0% 9%; 36 | --popover-foreground: 0 0% 95%; 37 | --primary: 142.1 70.6% 45.3%; 38 | --primary-foreground: 144.9 80.4% 10%; 39 | --secondary: 240 3.7% 15.9%; 40 | --secondary-foreground: 0 0% 98%; 41 | --muted: 0 0% 15%; 42 | --muted-foreground: 240 5% 64.9%; 43 | --accent: 12 6.5% 15.1%; 44 | --accent-foreground: 0 0% 98%; 45 | --destructive: 0 62.8% 30.6%; 46 | --destructive-foreground: 0 85.7% 97.3%; 47 | --border: 240 3.7% 15.9%; 48 | --input: 240 3.7% 15.9%; 49 | --ring: 142.4 71.8% 29.2%; 50 | } 51 | } 52 | 53 | @layer base { 54 | * { 55 | @apply border-border; 56 | } 57 | body { 58 | @apply bg-background text-foreground; 59 | } 60 | } 61 | 62 | /* width */ 63 | ::-webkit-scrollbar { 64 | height: 2px; 65 | width: 10px; 66 | } 67 | 68 | /* Track */ 69 | ::-webkit-scrollbar-track { 70 | background: #000000; 71 | } 72 | 73 | /* Handle */ 74 | ::-webkit-scrollbar-thumb { 75 | background: #3bbcf0; 76 | border-radius: 0px; 77 | } 78 | 79 | /* Handle on hover */ 80 | ::-webkit-scrollbar-thumb:hover { 81 | background: #3bbcf0; 82 | } 83 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { HomePage } from './components/HomePage'; 2 | import './App.css'; 3 | import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; 4 | import { LandingPage } from './components/LandingPage'; 5 | 6 | function App() { 7 | return ( 8 | 9 | 10 | } /> 11 | } /> 12 | 13 | 14 | ); 15 | } 16 | 17 | export default App; 18 | -------------------------------------------------------------------------------- /frontend/src/assets/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CCExtractor/ccsync/0dea33210306edd07a6b7a4720e934936fd2b3d7/frontend/src/assets/logo.jpg -------------------------------------------------------------------------------- /frontend/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CCExtractor/ccsync/0dea33210306edd07a6b7a4720e934936fd2b3d7/frontend/src/assets/logo.png -------------------------------------------------------------------------------- /frontend/src/assets/logo_header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CCExtractor/ccsync/0dea33210306edd07a6b7a4720e934936fd2b3d7/frontend/src/assets/logo_header.png -------------------------------------------------------------------------------- /frontend/src/assets/logo_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CCExtractor/ccsync/0dea33210306edd07a6b7a4720e934936fd2b3d7/frontend/src/assets/logo_light.png -------------------------------------------------------------------------------- /frontend/src/components/HomeComponents/BottomBar/BottomBar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Select, 4 | SelectContent, 5 | SelectGroup, 6 | SelectItem, 7 | SelectLabel, 8 | SelectTrigger, 9 | SelectValue, 10 | } from '@/components/ui/select'; 11 | import { NavigationMenu } from '@/components/ui/navigation-menu'; 12 | import { buttonVariants } from '@/components/ui/button'; 13 | import { BottomBarProps } from './bottom-bar-utils'; 14 | 15 | const BottomBar: React.FC = ({ 16 | projects, 17 | selectedProject, 18 | setSelectedProject, 19 | status, 20 | selectedStatus, 21 | setSelectedStatus, 22 | }) => { 23 | return ( 24 |
25 | 26 |
27 | 47 |
48 | 67 | 83 |
84 |
85 | ); 86 | }; 87 | 88 | export default BottomBar; 89 | -------------------------------------------------------------------------------- /frontend/src/components/HomeComponents/BottomBar/__tests__/BottomBar.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import BottomBar from '../BottomBar'; 3 | 4 | describe('BottomBar Component', () => { 5 | const mockSetSelectedProject = jest.fn(); 6 | const mockSetSelectedStatus = jest.fn(); 7 | const projects = ['Project A', 'Project B']; 8 | const status = ['Status A', 'Status B']; 9 | 10 | test('renders BottomBar component', () => { 11 | render( 12 | 20 | ); 21 | 22 | expect(screen.getByText('Home')).toBeInTheDocument(); 23 | expect(screen.getByText('Tasks')).toBeInTheDocument(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /frontend/src/components/HomeComponents/BottomBar/__tests__/bottom-bar-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { BottomBarProps, RouteProps, routeList } from '../bottom-bar-utils'; 2 | 3 | describe('RouteProps interface', () => { 4 | it('should have href and label properties', () => { 5 | const exampleRoute: RouteProps = { href: '#', label: 'Example' }; 6 | expect(exampleRoute).toHaveProperty('href'); 7 | expect(exampleRoute).toHaveProperty('label'); 8 | }); 9 | }); 10 | 11 | describe('BottomBarProps interface', () => { 12 | it('should have project and status properties', () => { 13 | const example: BottomBarProps = { 14 | projects: [''], 15 | selectedProject: '', 16 | setSelectedProject: jest.fn(), 17 | status: [''], 18 | selectedStatus: '', 19 | setSelectedStatus: jest.fn(), 20 | }; 21 | expect(example).toHaveProperty('projects'); 22 | expect(example).toHaveProperty('selectedProject'); 23 | expect(example).toHaveProperty('setSelectedProject'); 24 | expect(example).toHaveProperty('status'); 25 | expect(example).toHaveProperty('selectedStatus'); 26 | expect(example).toHaveProperty('setSelectedStatus'); 27 | }); 28 | }); 29 | 30 | describe('routeList array', () => { 31 | it('should be an array', () => { 32 | expect(Array.isArray(routeList)).toBe(true); 33 | }); 34 | 35 | it('should contain objects with href and label properties', () => { 36 | routeList.forEach((route) => { 37 | expect(route).toHaveProperty('href'); 38 | expect(route).toHaveProperty('label'); 39 | }); 40 | }); 41 | 42 | it('should contain correct number of routes', () => { 43 | expect(routeList.length).toBe(2); 44 | }); 45 | 46 | it('should match the predefined routes', () => { 47 | expect(routeList).toEqual([ 48 | { href: '#', label: 'Home' }, 49 | { href: '#tasks', label: 'Tasks' }, 50 | ]); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /frontend/src/components/HomeComponents/BottomBar/bottom-bar-utils.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction } from 'react'; 2 | 3 | export interface RouteProps { 4 | href: string; 5 | label: string; 6 | } 7 | 8 | export interface BottomBarProps { 9 | projects: string[]; 10 | selectedProject: string | null; 11 | setSelectedProject: Dispatch>; 12 | 13 | status: string[]; 14 | selectedStatus: string | null; 15 | setSelectedStatus: Dispatch>; 16 | } 17 | 18 | export const routeList: RouteProps[] = [ 19 | { href: '#', label: 'Home' }, 20 | { href: '#tasks', label: 'Tasks' }, 21 | ]; 22 | -------------------------------------------------------------------------------- /frontend/src/components/HomeComponents/FAQ/FAQ.tsx: -------------------------------------------------------------------------------- 1 | import { Accordion } from '@/components/ui/accordion'; 2 | import { FAQItem } from './FAQItem'; 3 | import { FAQList } from './faq-utils'; 4 | import { BlueHeading } from '@/lib/utils'; 5 | 6 | export const FAQ = () => { 7 | return ( 8 |
9 | 13 | 14 | 15 | {FAQList.map(({ question, answer, value }) => ( 16 | 22 | ))} 23 | 24 | 25 |

26 | Still have questions?{' '} 27 | 32 | Contact us 33 | 34 |

35 |
36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /frontend/src/components/HomeComponents/FAQ/FAQItem.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | AccordionItem, 3 | AccordionTrigger, 4 | AccordionContent, 5 | } from '@/components/ui/accordion'; 6 | 7 | interface FAQProps { 8 | question: string; 9 | answer: string; 10 | value: string; 11 | } 12 | 13 | export const FAQItem = ({ question, answer, value }: FAQProps) => ( 14 | 15 | {question} 16 | {answer} 17 | 18 | ); 19 | -------------------------------------------------------------------------------- /frontend/src/components/HomeComponents/FAQ/__tests__/FAQ.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import { FAQ } from '../FAQ'; 4 | import { FAQList } from '../faq-utils'; 5 | 6 | jest.mock('../faq-utils', () => ({ 7 | FAQList: [ 8 | { 9 | question: 'What is React?', 10 | answer: 'A JavaScript library for building user interfaces.', 11 | value: 'q1', 12 | }, 13 | { 14 | question: 'What is TypeScript?', 15 | answer: 16 | 'A typed superset of JavaScript that compiles to plain JavaScript.', 17 | value: 'q2', 18 | }, 19 | ], 20 | })); 21 | 22 | jest.mock('@/components/ui/accordion', () => ({ 23 | Accordion: ({ children }: { children: React.ReactNode }) => ( 24 |
{children}
25 | ), 26 | })); 27 | 28 | jest.mock('../FAQItem', () => ({ 29 | FAQItem: ({ question, answer }: { question: string; answer: string }) => ( 30 |
31 |

{question}

32 |

{answer}

33 |
34 | ), 35 | })); 36 | 37 | describe('FAQ component', () => { 38 | test('renders without crashing', () => { 39 | render(); 40 | }); 41 | 42 | test('renders the section title correctly', () => { 43 | render(); 44 | const titleElement = screen.getByText(/Frequently Asked/i); 45 | expect(titleElement).toBeInTheDocument(); 46 | }); 47 | 48 | test('renders the FAQ items correctly', () => { 49 | render(); 50 | FAQList.forEach(({ question }) => { 51 | const questionElement = screen.getByText(question); 52 | expect(questionElement).toBeInTheDocument(); 53 | }); 54 | }); 55 | 56 | test('renders the contact link correctly', () => { 57 | render(); 58 | const contactLink = screen.getByText(/Contact us/i); 59 | expect(contactLink).toBeInTheDocument(); 60 | expect(contactLink).toHaveAttribute('href', '#contact'); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /frontend/src/components/HomeComponents/FAQ/__tests__/FAQItem.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, fireEvent } from '@testing-library/react'; 2 | import { FAQItem } from '../FAQItem'; 3 | import { Accordion } from '@/components/ui/accordion'; 4 | 5 | describe('FAQItem', () => { 6 | const question = 'What is your return policy?'; 7 | const answer = 'You can return any item within 30 days of purchase.'; 8 | const value = 'faq-1'; 9 | 10 | test('renders question and answer correctly', () => { 11 | render( 12 | 13 | 19 | 20 | ); 21 | 22 | // check if the question is rendered 23 | expect(screen.getByText(question)).toBeInTheDocument(); 24 | 25 | // clicking the trigger to expand the accordion 26 | fireEvent.click(screen.getByText(question)); 27 | 28 | // check if the answer is visible after clicking 29 | expect(screen.getByText(answer)).toBeVisible(); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /frontend/src/components/HomeComponents/FAQ/__tests__/faq-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { FAQList } from '../faq-utils'; 2 | 3 | describe('FAQList', () => { 4 | test('should contain the correct number of FAQ items', () => { 5 | expect(FAQList).toHaveLength(4); 6 | }); 7 | 8 | test('should contain the correct FAQ items', () => { 9 | expect(FAQList).toEqual([ 10 | { 11 | question: 'What is CCSync?', 12 | answer: 13 | 'CCSync is a service that allows you to synchronize your tasks between your devices using Taskwarrior. It provides a hosted solution for taskchampion-sync-server, eliminating the need to set up and manage your own server.', 14 | value: 'item-1', 15 | }, 16 | { 17 | question: 'What devices can I use with CCSync?', 18 | answer: 19 | 'CCSync works with any device that can run Taskwarrior, including desktops, laptops, smartphones, and tablets.', 20 | value: 'item-2', 21 | }, 22 | { 23 | question: 'How do I initialize sync between my clients?', 24 | answer: 25 | 'The connection process is straightforward. Refer to the setup guide above for step-by-step instructions on configuring Taskwarrior to connect to our server.', 26 | value: 'item-3', 27 | }, 28 | { 29 | question: 'Do you have access to my task content?', 30 | answer: 31 | 'The tasks are stored securely in your Browser based local database. It helps in making the tasks available on interfaces other than the PC, directly on the web!', 32 | value: 'item-4', 33 | }, 34 | ]); 35 | }); 36 | 37 | test('should have valid properties for each FAQ item', () => { 38 | FAQList.forEach((item) => { 39 | expect(item).toHaveProperty('question'); 40 | expect(item).toHaveProperty('answer'); 41 | expect(item).toHaveProperty('value'); 42 | expect(typeof item.question).toBe('string'); 43 | expect(typeof item.answer).toBe('string'); 44 | expect(typeof item.value).toBe('string'); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /frontend/src/components/HomeComponents/FAQ/faq-utils.ts: -------------------------------------------------------------------------------- 1 | export interface FAQProps { 2 | question: string; 3 | answer: string; 4 | value: string; 5 | } 6 | 7 | export const FAQList: FAQProps[] = [ 8 | { 9 | question: 'What is CCSync?', 10 | answer: 11 | 'CCSync is a service that allows you to synchronize your tasks between your devices using Taskwarrior. It provides a hosted solution for taskchampion-sync-server, eliminating the need to set up and manage your own server.', 12 | value: 'item-1', 13 | }, 14 | { 15 | question: 'What devices can I use with CCSync?', 16 | answer: 17 | 'CCSync works with any device that can run Taskwarrior, including desktops, laptops, smartphones, and tablets.', 18 | value: 'item-2', 19 | }, 20 | { 21 | question: 'How do I initialize sync between my clients?', 22 | answer: 23 | 'The connection process is straightforward. Refer to the setup guide above for step-by-step instructions on configuring Taskwarrior to connect to our server.', 24 | value: 'item-3', 25 | }, 26 | { 27 | question: 'Do you have access to my task content?', 28 | answer: 29 | 'The tasks are stored securely in your Browser based local database. It helps in making the tasks available on interfaces other than the PC, directly on the web!', 30 | value: 'item-4', 31 | }, 32 | ]; 33 | -------------------------------------------------------------------------------- /frontend/src/components/HomeComponents/Footer/__tests__/Footer.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import '@testing-library/jest-dom'; 3 | import { Footer } from '../Footer'; 4 | 5 | jest.mock('../../../../assets/logo.png', () => 'logo-path'); 6 | jest.mock('../../../../assets/logo_light.png', () => 'logo-light-path'); 7 | 8 | describe('Footer component', () => { 9 | test('renders without crashing', () => { 10 | render(