├── .github ├── FUNDING.yml ├── workflows │ ├── cleanup-images.yml │ ├── dependency-review.yml │ ├── pytest.yml │ └── docker-image.yml └── dependabot.yml ├── app ├── requirements.txt ├── Dockerfile ├── config.schema.yaml ├── config.example.yaml ├── syncErrorTracker.py ├── configManager.py └── app.py ├── .vscode ├── settings.json └── launch.json ├── tests ├── test_configs │ ├── invalid.missing_option.yaml │ ├── valid.min.yaml │ ├── invalid.wrong_option.yaml │ ├── invalid.assignee_email.yaml │ ├── valid.assignee_email.yaml │ └── valid.full.yaml ├── test_configManager.py └── test_syncErrorTracker.py ├── docker-compose.yml ├── .devcontainer └── devcontainer.json ├── .gitignore ├── README.md └── LICENSE /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: ["https://www.paypal.me/flecmart"] 2 | -------------------------------------------------------------------------------- /app/requirements.txt: -------------------------------------------------------------------------------- 1 | gpsoauth==2.0.0 2 | gkeepapi==0.17.0 3 | todoist-api-python==3.1.0 4 | pyyaml==6.0.3 5 | yamale==6.1.0 6 | schedule==1.2.2 7 | pytest==9.0.2 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.pytestArgs": [ 3 | "tests" 4 | ], 5 | "python.testing.unittestEnabled": false, 6 | "python.testing.pytestEnabled": true 7 | } -------------------------------------------------------------------------------- /tests/test_configs/invalid.missing_option.yaml: -------------------------------------------------------------------------------- 1 | update_interval_s: 60 2 | google_username: yourUsername 3 | google_token: oAuthMasterToken 4 | keep_lists: 5 | - SomeList: 6 | sync_labels: false -------------------------------------------------------------------------------- /tests/test_configs/valid.min.yaml: -------------------------------------------------------------------------------- 1 | update_interval_s: 60 2 | google_username: yourUsername 3 | google_token: oAuthMasterToken 4 | todoist_api_token: todoistApiKey 5 | keep_lists: 6 | - SomeList: 7 | sync_labels: false -------------------------------------------------------------------------------- /tests/test_configs/invalid.wrong_option.yaml: -------------------------------------------------------------------------------- 1 | update_interval_s: 60 2 | google_username: yourUsername 3 | google_token: oAuthMasterToken 4 | todoist_api_token: todoistApiKey 5 | wrong_option: foo 6 | keep_lists: 7 | - SomeList: 8 | sync_labels: false -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | keep2todoist: 4 | container_name: keep2todoist 5 | build: app 6 | restart: always 7 | volumes: 8 | - type: bind 9 | source: ./app/config.yaml 10 | target: /app/config.yaml -------------------------------------------------------------------------------- /tests/test_configs/invalid.assignee_email.yaml: -------------------------------------------------------------------------------- 1 | update_interval_s: 60 2 | google_username: yourUsername 3 | google_token: oAuthMasterToken 4 | todoist_api_token: todoistApiKey 5 | keep_lists: 6 | - SomeList: 7 | sync_labels: false 8 | assignee_email: 'name@domain.tld' -------------------------------------------------------------------------------- /tests/test_configs/valid.assignee_email.yaml: -------------------------------------------------------------------------------- 1 | update_interval_s: 60 2 | google_username: yourUsername 3 | google_token: oAuthMasterToken 4 | todoist_api_token: todoistApiKey 5 | keep_lists: 6 | - SomeList: 7 | sync_labels: false 8 | assignee_email: 'name@domain.tld' 9 | todoist_project: 'shared' -------------------------------------------------------------------------------- /app/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | # Create a venv using the larger base image (which contains gcc). 4 | FROM python:3.13-bullseye AS builder 5 | RUN python3 -m venv /venv 6 | COPY requirements.txt /requirements.txt 7 | RUN /venv/bin/pip3 install --no-cache-dir -r /requirements.txt 8 | 9 | # Copy the venv to a fresh "slim" image. 10 | FROM python:3.13-slim-bullseye 11 | COPY --from=builder /venv /venv 12 | WORKDIR /app 13 | COPY . . 14 | CMD ["/venv/bin/python3", "app.py"] 15 | -------------------------------------------------------------------------------- /.github/workflows/cleanup-images.yml: -------------------------------------------------------------------------------- 1 | name: Cleanup docker images 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | 8 | cleanup: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Delete untagged ghcr 14 | uses: Chizkiyahu/delete-untagged-ghcr-action@v3 15 | with: 16 | token: ${{ secrets.PACKAGES_TOKEN }} 17 | repository: ${{ github.repository }} 18 | package_name: keep2todoist 19 | untagged_only: true 20 | owner_type: user 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python: Current File", 9 | "type": "python", 10 | "request": "launch", 11 | "program": "${file}", 12 | "console": "integratedTerminal", 13 | "justMyCode": true, 14 | "cwd": "${fileDirname}" 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /tests/test_configs/valid.full.yaml: -------------------------------------------------------------------------------- 1 | update_interval_s: 60 2 | google_username: yourUsername 3 | google_token: oAuthMasterToken 4 | todoist_api_token: todoistApiKey 5 | healthcheck: 6 | url: https://hc-ping.com/someuuid 7 | period_min: 30 8 | untitled_notes: # optional: move all untitled notes to todoist inbox 9 | add_label: 'Sync' # required: add label to todoist note 10 | due_str_en: 'today' # optional: you can set a due date in english here 11 | keep_lists: 12 | - Todo: 13 | sync_labels: false 14 | due_str_en: 'today' 15 | - Shared: 16 | sync_labels: true 17 | assignee_email: 'name@domain.tld' 18 | todoist_project: 'Chores' 19 | - Shopping: 20 | sync_labels: false 21 | todoist_project: 'Shopping' 22 | - Test: 23 | sync_labels: false -------------------------------------------------------------------------------- /app/config.schema.yaml: -------------------------------------------------------------------------------- 1 | update_interval_s: int(min=1, required=True) 2 | google_username: str(required=True) 3 | google_token: str(required=True) 4 | todoist_api_token: str(required=True) 5 | healthcheck: include('healthcheck_attributes', required=False) 6 | untitled_notes: include('untitled_notes_attributes', required=False) 7 | keep_lists: list(include('list_mapping')) 8 | --- 9 | list_mapping: map(include('list_mapping_attributes')) 10 | --- 11 | list_mapping_attributes: 12 | sync_labels: bool(required=True) 13 | due_str_en: str(required=False) 14 | todoist_project: str(required=False) 15 | assignee_email: str(required=False) 16 | --- 17 | healthcheck_attributes: 18 | url: str(required=True) 19 | period_min: int(required=True) 20 | --- 21 | untitled_notes_attributes: 22 | add_label: str(required=True) 23 | due_str_en: str(required=False) 24 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | # Dependency Review Action 2 | # 3 | # This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging. 4 | # 5 | # Source repository: https://github.com/actions/dependency-review-action 6 | # Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement 7 | name: 'Dependency Review' 8 | on: [pull_request] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | dependency-review: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: 'Checkout Repository' 18 | uses: actions/checkout@v4 19 | - name: 'Dependency Review' 20 | uses: actions/dependency-review-action@v4 21 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/debian 3 | { 4 | "name": "Debian", 5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 6 | "image": "mcr.microsoft.com/devcontainers/base:bullseye", 7 | "features": { 8 | "ghcr.io/devcontainers/features/python:1": { 9 | "installTools": true, 10 | "version": "3.13" 11 | } 12 | }, 13 | 14 | // Configure tool-specific properties. 15 | "customizations": { 16 | // Configure properties specific to VS Code. 17 | "vscode": { 18 | 19 | // Add the IDs of extensions you want installed when the container is created. 20 | "extensions": [ 21 | "eamodio.gitlens", 22 | "ms-python.python", 23 | "ms-python.vscode-pylance", 24 | "njpwerner.autodocstring", 25 | "VisualStudioExptTeam.intellicode-api-usage-examples", 26 | "VisualStudioExptTeam.vscodeintellicode" 27 | ] 28 | } 29 | }, 30 | 31 | "postCreateCommand": "pip install -r /workspaces/keep2todoist/app/requirements.txt" 32 | } 33 | -------------------------------------------------------------------------------- /app/config.example.yaml: -------------------------------------------------------------------------------- 1 | update_interval_s: 60 2 | google_username: yourUsername # gmail account 3 | google_token: oauthMasterToken 4 | todoist_api_token: todoistApiKey 5 | healthcheck: # optional: configure some kind of healtcheck endpoint providing service monitoring, e.g. https://healthchecks.io/ 6 | url: https://hc-ping.com/someuuid 7 | period_min: 30 8 | untitled_notes: # optional: move all untitled notes to todoist inbox 9 | add_label: 'Sync' # required: add label to todoist note 10 | due_str_en: 'today' # optional: you can set a due date in english here 11 | keep_lists: # list your keep lists on this level 12 | - Todo: 13 | sync_labels: false # required: transfer labels from gkeep lists to todoist items 14 | due_str_en: 'today' # optional: you can set a due date in english here 15 | # if todoist_project is not set your task will go into the todoist inbox 16 | - Shared: 17 | sync_labels: true 18 | assignee_email: 'name@domain.tld' # optional: the email of the person to be assigned, requires todoist_project to be a shared project. 19 | todoist_project: 'Chores' # not optional in this case, should be a shared project 20 | - Shopping: 21 | sync_labels: false 22 | todoist_project: 'Shopping' # optional: you can choose a project for todoist here 23 | - Test: 24 | sync_labels: false 25 | -------------------------------------------------------------------------------- /.github/workflows/pytest.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Pytest 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | build: 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Set up Python 3.10 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: "3.10" 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install flake8 pytest 30 | if [ -f app/requirements.txt ]; then pip install -r app/requirements.txt; fi 31 | - name: Lint with flake8 32 | run: | 33 | # stop the build if there are Python syntax errors or undefined names 34 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 35 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 36 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 37 | - name: Test with pytest 38 | run: | 39 | pytest 40 | -------------------------------------------------------------------------------- /tests/test_configManager.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import os 3 | import sys 4 | sys.path.insert(0, os.path.abspath('.')) 5 | 6 | from app.configManager import ConfigManager, ConfigValidationError 7 | 8 | schema = 'app/config.schema.yaml' 9 | test_config_root_folder = 'tests/test_configs' 10 | 11 | def test_example_config(): 12 | cm = ConfigManager(schema, 'app/config.example.yaml') 13 | 14 | def test_valid_full_config(): 15 | cm = ConfigManager(schema, f'{test_config_root_folder}/valid.full.yaml') 16 | 17 | def test_valid_min_config(): 18 | cm = ConfigManager(schema, f'{test_config_root_folder}/valid.min.yaml') 19 | 20 | def test_valid_assignee_email_config(): 21 | cm = ConfigManager(schema, f'{test_config_root_folder}/valid.assignee_email.yaml') 22 | 23 | def test_invalid_assignee_email_config(): 24 | with pytest.raises(ConfigValidationError): 25 | cm = ConfigManager(schema, f'{test_config_root_folder}/invalid.assignee_email.yaml') 26 | 27 | def test_invalid_missing_option_config(): 28 | with pytest.raises(ConfigValidationError): 29 | cm = ConfigManager(schema, f'{test_config_root_folder}/invalid.missing_option.yaml') 30 | 31 | def test_invalid_wrong_option_config(): 32 | with pytest.raises(ConfigValidationError): 33 | cm = ConfigManager(schema, f'{test_config_root_folder}/invalid.wrong_option.yaml') 34 | -------------------------------------------------------------------------------- /tests/test_syncErrorTracker.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | sys.path.insert(0, os.path.abspath('.')) 4 | 5 | from app.syncErrorTracker import SyncErrorTracker 6 | 7 | list_name = 'mockedList' 8 | list_item = 'mockedItem' 9 | exception = Exception('mockedException') 10 | 11 | def test_initial_health_state(): 12 | tracker = SyncErrorTracker() 13 | assert tracker.healthy == True 14 | 15 | def test_unhealthy_after_same_errors(): 16 | tracker = SyncErrorTracker(unhealthy_after=2) 17 | tracker.record_error(list_name, list_item, exception) 18 | assert tracker.healthy == True 19 | 20 | tracker.record_error(list_name, list_item, exception) 21 | assert tracker.healthy == False 22 | 23 | def test_healthy_after_different_errors(): 24 | tracker = SyncErrorTracker(unhealthy_after=2) 25 | tracker.record_error(list_name, list_item, exception) 26 | assert tracker.healthy == True 27 | 28 | tracker.record_error(list_name, 'anotherItem', exception) 29 | assert tracker.healthy == True 30 | 31 | def test_healthy_after_errors_resolved(): 32 | tracker = SyncErrorTracker(unhealthy_after=2) 33 | tracker.record_error(list_name, list_item, exception) 34 | assert tracker.healthy == True 35 | 36 | tracker.record_error(list_name, list_item, exception) 37 | assert tracker.healthy == False 38 | 39 | tracker.successful_sync(list_name, list_item) 40 | assert tracker.healthy == True 41 | -------------------------------------------------------------------------------- /app/syncErrorTracker.py: -------------------------------------------------------------------------------- 1 | import logging 2 | log = logging.getLogger(__name__) 3 | 4 | class SyncErrorTracker(): 5 | def __init__(self, unhealthy_after: int = 5): 6 | """Tracks sync errors during runtime in memory 7 | 8 | Args: 9 | unhealthy_after (int, optional): Don't ping healthcheck if the same sync error happens n times. Defaults to 5. 10 | """ 11 | self._errors = {} 12 | self._healthy = True 13 | self._unhealthy_after = unhealthy_after 14 | 15 | @property 16 | def healthy(self): 17 | """Is application healhty 18 | 19 | Returns: 20 | bool: health state 21 | """ 22 | return self._healthy 23 | 24 | def _get_error_key(self, keep_list_name: str, item_name: str) -> str: 25 | return f'{keep_list_name}_{item_name}' 26 | 27 | def record_error(self, keep_list_name: str, item_name: str, exception: Exception): 28 | """Record sync error 29 | 30 | Args: 31 | keep_list_name (str): name of keep list to sync 32 | item_name (str): name of item to sync 33 | exception (Exception): ocurred exception 34 | """ 35 | log.error(f'could not sync {item_name} from {keep_list_name}: {exception}') 36 | 37 | error_key = self._get_error_key(keep_list_name, item_name) 38 | if error_key not in self._errors: 39 | self._errors[error_key] = {'count': 1, 'exceptions': [exception]} 40 | else: 41 | self._errors[error_key]['count'] += 1 42 | self._errors[error_key]['exceptions'].append(exception) 43 | 44 | if self._errors[error_key]['count'] >= self._unhealthy_after and self._healthy: 45 | self._healthy = False 46 | log.error('Unhealthy sync state') 47 | 48 | def successful_sync(self, keep_list_name: str, item_name: str): 49 | error_key = self._get_error_key(keep_list_name, item_name) 50 | if error_key in self._errors: 51 | del self._errors[error_key] 52 | 53 | if all(error['count'] < self._unhealthy_after for error in self._errors.values()) and not self._healthy: 54 | self._healthy = True 55 | log.info('Sync state is healthy again') 56 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Build docker images 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | pull_request: 8 | branches: 9 | - 'main' 10 | workflow_dispatch: 11 | 12 | # permissions are needed if pushing to ghcr.io 13 | permissions: 14 | packages: write 15 | 16 | jobs: 17 | 18 | test: 19 | 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Build the Docker image 25 | run: | 26 | cd app 27 | docker build . --file Dockerfile --tag keep2todoist:$(date +%s) 28 | 29 | 30 | build: 31 | needs: test 32 | 33 | runs-on: ubuntu-latest 34 | 35 | steps: 36 | - name: Checkout 37 | uses: actions/checkout@v4 38 | # https://github.com/docker/setup-qemu-action 39 | - name: Set up QEMU 40 | uses: docker/setup-qemu-action@v3 41 | # https://github.com/docker/setup-buildx-action 42 | - name: Set up Docker Buildx 43 | id: buildx 44 | uses: docker/setup-buildx-action@v3 45 | - name: Login to GHCR 46 | if: github.event_name != 'pull_request' 47 | uses: docker/login-action@v3 48 | with: 49 | registry: ghcr.io 50 | username: ${{ github.repository_owner }} 51 | password: ${{ secrets.GITHUB_TOKEN }} 52 | - name: Docker meta 53 | id: metadata 54 | uses: docker/metadata-action@v5 55 | with: 56 | # list of Docker images to use as base name for tags 57 | images: | 58 | ghcr.io/flecmart/keep2todoist 59 | # Docker tags based on the following events/attributes 60 | tags: | 61 | type=schedule 62 | type=ref,event=branch 63 | type=ref,event=pr 64 | type=semver,pattern={{version}} 65 | type=semver,pattern={{major}}.{{minor}} 66 | type=semver,pattern={{major}} 67 | type=sha 68 | - name: Build and push 69 | uses: docker/build-push-action@v6 70 | with: 71 | context: app 72 | platforms: linux/amd64,linux/arm64,linux/arm/v7 73 | push: ${{ github.event_name != 'pull_request' }} 74 | tags: ${{ steps.metadata.outputs.tags }} 75 | labels: ${{ steps.metadata.outputs.labels }} 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # config 132 | app/config.yaml 133 | app/config.my.yaml 134 | app/config.*.yaml 135 | !app/config.schema.yaml 136 | !app/config.example.yaml 137 | 138 | gkeepapi_token -------------------------------------------------------------------------------- /app/configManager.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import yaml 3 | import os 4 | import yamale 5 | 6 | log = logging.getLogger(__name__) 7 | 8 | class ConfigManager(): 9 | def __init__(self, path_to_schema: str, path_to_config: str): 10 | """Initialize config 11 | 12 | Args: 13 | path_to_schema (str): path to config.schema.yaml 14 | path_to_config (str): path to config.yaml 15 | """ 16 | self.path_to_schema = path_to_schema 17 | self.path_to_config = path_to_config 18 | self.schema = yamale.make_schema(path_to_schema) 19 | self._cached_st_mtime = os.stat(path_to_config).st_mtime 20 | self._config = dict() 21 | self.update_configuration() 22 | 23 | def needs_update(self) -> bool: 24 | """Check if config changed 25 | 26 | Returns: 27 | bool: return True if config change detected 28 | """ 29 | needs_update = False 30 | st_mtime = os.stat(self.path_to_config).st_mtime 31 | if st_mtime != self._cached_st_mtime: 32 | log.info('config change detected') 33 | self._cached_st_mtime = st_mtime 34 | needs_update = True 35 | return needs_update 36 | 37 | def update_configuration(self): 38 | """Reload config.yaml 39 | """ 40 | self.validate_configuration() 41 | try: 42 | with open(self.path_to_config, 'r') as yamlfile: 43 | self._config = yaml.safe_load(yamlfile) 44 | log.info(f'updated config: {self.path_to_config}') 45 | except Exception as ex: 46 | log.error(f"could not load configuration '{ex}'") 47 | 48 | def validate_schema(self): 49 | """Validate against config.schema.yaml 50 | https://github.com/23andMe/Yamale#examples 51 | 52 | Raises: 53 | Exception: Raised in case of YamaleError 54 | """ 55 | data = yamale.make_data(self.path_to_config) 56 | try: 57 | log.info(f"validating configuration with schema '{self.path_to_schema}'") 58 | yamale.validate(self.schema, data) 59 | log.info('schema validation passed 👍') 60 | except yamale.YamaleError as ex: 61 | for result in ex.results: 62 | log.error(f"Error validating data '{result.data}' with '{result.schema}'") 63 | for error in result.errors: 64 | log.error(f" -> {error}") 65 | raise ConfigValidationError('schema validation failed ❌') 66 | 67 | def validate_configuration(self): 68 | """Validate config.yaml 69 | Raises exception and exits if validation fails 70 | """ 71 | self.validate_schema() 72 | with open(self.path_to_config, 'r') as yamlfile: 73 | config = yaml.safe_load(yamlfile) 74 | self.validate_assignee_email(config_validate=config) 75 | log.info('configuration validation passed 👍') 76 | 77 | def validate_assignee_email(self, config_validate: dict): 78 | """For each defined list in the config, validate if todoist_project is defined when assignee_email is present 79 | 80 | Args: 81 | config_validate (dict): config to validate 82 | 83 | Raises: 84 | Exception: Raised if validation failed 85 | """ 86 | for keep_list in config_validate['keep_lists']: 87 | keep_list_name = list(keep_list.keys())[0] 88 | keep_list_options = list(keep_list.values())[0] 89 | if self.parse_key(keep_list_options, 'assignee_email') and not self.parse_key(keep_list_options, 'todoist_project'): 90 | log.error(f'Validation failed for "{keep_list_name}" ❌') 91 | raise ConfigValidationError(f'Validation failed for "{keep_list_name}": lists with "assignee_email" have to define a shared "todoist_project"') 92 | 93 | @property 94 | def config(self): 95 | """Get config 96 | 97 | Returns: 98 | dict: deserialized config.yaml 99 | """ 100 | return self._config 101 | 102 | @staticmethod 103 | def parse_key(keep_list: dict, key: str): 104 | return keep_list[key] if key in keep_list else None 105 | 106 | class ConfigValidationError(Exception): 107 | pass -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # keep2todoist 2 | 3 | Transfer items from google keep lists to todoist. 4 | 5 | My use case is having an intuitive google assistant integration for todoist: 6 | 7 | 1. Sync your google notes and lists with google keep (setting in google assistant) 8 | 2. Let this tool move items from google keep lists to todoist lists. 9 | 10 | It is not a real sync but just a **one way** keep->todoist. 11 | 12 | - Moved items will be deleted from keep's list 13 | - Labels on google keep lists will be attached to their corresponding todo tasks 14 | 15 | ## Configuration 16 | 17 | Create a `config.yaml` from `config.example.yaml`: 18 | 19 | ```yaml 20 | update_interval_s: 60 21 | google_username: yourUsername 22 | google_token: oauthMasterToken 23 | todoist_api_token: todoistApiKey 24 | healthcheck: # optional: configure some kind of healtcheck endpoint providing service monitoring, e.g. https://healthchecks.io/ 25 | url: https://hc-ping.com/someuuid 26 | period_min: 30 27 | untitled_notes: # optional: move all untitled notes to todoist inbox 28 | add_label: 'Sync' # required: add label to todoist note 29 | due_str_en: 'today' # optional: you can set a due date in english here 30 | keep_lists: # list your keep lists on this level 31 | - Todo: 32 | sync_labels: false # required: transfer labels from gkeep lists to todoist items 33 | due_str_en: 'today' # optional: you can set a due date in english here 34 | # if todoist_project is not set your task will go into the todoist inbox 35 | - Shared: 36 | sync_labels: true 37 | assignee_email: 'name@domain.tld' # optional: the email of the person to be assigned, requires todoist_project to be a shared project. 38 | todoist_project: 'Chores' # not optional in this case, should be a shared project 39 | - Shopping: 40 | sync_labels: false 41 | todoist_project: 'Shopping' # optional: you can choose a project for todoist here 42 | - Test: 43 | sync_labels: false 44 | ``` 45 | 46 | - For obtaining the google_token follow [Get master token](#get-master-token) 47 | - Your todoist token can be found in todoist settings->integrations. 48 | - Changes in `config.yaml` will be detected automatically and the updated config will be reflected if the yaml is valid. 49 | - optionally, for setting up a healthcheck to ensure that your service is running you can use a service like https://healthchecks.io/: 50 | 51 | ![image](https://user-images.githubusercontent.com/10167243/192765584-80b1866d-7483-4693-9912-5fa769cbe0c4.png) 52 | 53 | If configured it will provide you with an url and the app will ping this url every `period_min` minutes. On the healtcheck's service side you configure a matching period & grace time. You can then get notified if a ping is missed, e.g. via mail. 54 | 55 | ### Get master token 56 | 57 | 1. Go to https://accounts.google.com/EmbeddedSetup 58 | 2. Press F12 to open debugger in browser and open Application tab 59 | 3. Log in with your google account and continue until you click on agree and the browser window keeps loading 60 | 4. Obtain the oauth token from the debugger 61 | 5. Run 62 | 63 | ```bash 64 | docker run --rm -it --entrypoint /bin/sh python:3 -c 'pip install gpsoauth; python3 -c '\''print(__import__("gpsoauth").exchange_token(input("Email: "), input("OAuth Token: "), input("Android ID: ")))'\' 65 | ``` 66 | 67 | - Put in your google email, the obtained token and leave Android ID blank 68 | - Copy out the token from the response 69 | - Don't leak that token, it is a master token that can be used for authenticating your google account! 70 | 71 | For more details on how to obtain the token read through https://github.com/rukins/gpsoauth-java/blob/b74ebca999d0f5bd38a2eafe3c0d50be552f6385/README.md#receiving-an-authentication-token 72 | 73 | ## Start 74 | 75 | ### docker 76 | 77 | You can use docker/docker-compose to start the service: 78 | 79 | ```bash 80 | docker-compose up -d 81 | ``` 82 | 83 | This has the advantage that the service will be restarted automatically on reboot or error. 84 | 85 | ### Pre-built docker image 86 | 87 | Latest docker image is also available at `ghcr.io/flecmart/keep2todoist:latest 88 | ` 89 | 90 | ```bash 91 | docker run -v ./config.yaml:/app/config.yaml --restart always ghcr.io/flecmart/keep2todoist:latest 92 | ``` 93 | 94 | ### Local python installation 95 | 96 | Tested this only with `python >= 3.9` 97 | 98 | ```bash 99 | cd app 100 | pip install -r requirements.txt 101 | python3 app.py 102 | ``` 103 | 104 | ## Related projects 105 | 106 | - Tool to improve the alexa integration for todoist: https://github.com/ChristianKuehnel/todoistautomation 107 | 108 | ## Thanks 109 | 110 | This tool relies heavily on and would not be possible without: 111 | 112 | - https://github.com/simon-weber/gpsoauth 113 | - https://github.com/kiwiz/gkeepapi 114 | - https://developer.todoist.com/guides/#developing-with-todoist 115 | -------------------------------------------------------------------------------- /app/app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import schedule 3 | import time 4 | import gkeepapi 5 | import sys 6 | import os 7 | from todoist_api_python.api import TodoistAPI 8 | from configManager import ConfigManager 9 | from syncErrorTracker import SyncErrorTracker 10 | parse_key = ConfigManager.parse_key 11 | 12 | log = logging.getLogger('app') 13 | sync_errors = SyncErrorTracker() 14 | 15 | def restart(): 16 | log.info('restarting...') 17 | os.execv(sys.executable, ['python'] + sys.argv) 18 | 19 | def google_login(keep: gkeepapi.Keep, user: str, master_token: str): 20 | """Authenticate gkeepapi with master token 21 | 22 | Args: 23 | keep (gkeepapi.Keep): Keep object from gkeepapi 24 | user (str): google username 25 | master_token (str): google master token 26 | """ 27 | log.info("authenticating gkeepapi") 28 | 29 | try: 30 | keep.authenticate(user, master_token) 31 | except Exception as ex: 32 | log.fatal(f'failed to authenticate ❌ {ex}') 33 | sys.exit(1) 34 | 35 | log.info("authenticated successfully 👍") 36 | 37 | def ping_healthcheck(healthcheck_url: str): 38 | """Ping some kind of healthcheck url providing a possibility to monitor this service 39 | """ 40 | import socket 41 | import urllib.request 42 | 43 | if sync_errors.healthy: 44 | try: 45 | log.info(f'ping {healthcheck_url}') 46 | urllib.request.urlopen(healthcheck_url, timeout=10) 47 | except socket.error as ex: 48 | log.warning(f'failed to ping {healthcheck_url}: {ex}') 49 | 50 | def get_todoist_project_id(api: TodoistAPI, name): 51 | """Get todoist project id by name 52 | 53 | Args: 54 | api (TodoistAPI): api 55 | name (str): project name 56 | 57 | Returns: 58 | int: project id 59 | """ 60 | try: 61 | for project_list_page in api.get_projects(): 62 | for project in project_list_page: 63 | if project.name == name: 64 | return project.id 65 | except Exception as ex: 66 | log.error(f'failed to get projects from todoist api: {ex}') 67 | return None 68 | 69 | def get_labels_from_todoist(api: TodoistAPI): 70 | """Get existing labels from todoist 71 | 72 | Args: 73 | api (TodoistAPI): api 74 | 75 | Returns: 76 | list