├── timetree_exporter ├── api │ ├── __init__.py │ ├── const.py │ ├── auth.py │ └── calendar.py ├── __init__.py ├── event.py ├── utils.py ├── __main__.py └── formatter.py ├── .release-please-manifest.json ├── .gitignore ├── .github ├── dependabot.yml └── workflows │ ├── release-please.yml │ ├── bump-homebrew-formula.yml │ ├── lint-and-test.yml │ └── python-publish.yml ├── .pre-commit-config.yaml ├── LICENSE ├── release-please-config.json ├── pyproject.toml ├── tests ├── test_event.py ├── test_utils.py ├── conftest.py └── test_formatter.py ├── README.md └── CHANGELOG.md /timetree_exporter/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "0.6.2" 3 | } -------------------------------------------------------------------------------- /timetree_exporter/api/const.py: -------------------------------------------------------------------------------- 1 | """ 2 | Constants for timetree_exporter 3 | """ 4 | 5 | API_BASEURI = "https://timetreeapp.com/api/v1" 6 | API_USER_AGENT = "web/2.1.0/en" 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Mac OS 2 | .DS_Store 3 | 4 | # Python temporary files 5 | *.pyc 6 | 7 | # Python environment 8 | .venv 9 | 10 | # Python distribution 11 | dist 12 | *.egg-info 13 | 14 | # Visual Studio Code 15 | .vscode/ 16 | 17 | # TimeTree 18 | timetree.ics 19 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "uv" 4 | directory: "/" # Location of package manifests 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" # Location of package manifests 9 | schedule: 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | 6 | permissions: 7 | contents: write 8 | issues: write 9 | pull-requests: write 10 | 11 | name: release-please 12 | 13 | jobs: 14 | release-please: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: googleapis/release-please-action@v4 18 | with: 19 | token: ${{ secrets.MY_RELEASE_PLEASE_TOKEN }} 20 | -------------------------------------------------------------------------------- /.github/workflows/bump-homebrew-formula.yml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: [published] 4 | workflow_dispatch: 5 | 6 | name: Bump Homebrew Formula 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | bump-homebrew-formula: 13 | name: Bump Homebrew Formula 14 | runs-on: macos-latest 15 | steps: 16 | - uses: dawidd6/action-homebrew-bump-formula@v7 17 | with: 18 | formula: timetree-exporter 19 | tap: eoleedi/tap 20 | no_fork: true 21 | tag: ${{github.ref}} 22 | token: ${{ secrets.HOMEBREW_FORMULA_TOKEN }} 23 | -------------------------------------------------------------------------------- /timetree_exporter/__init__.py: -------------------------------------------------------------------------------- 1 | """Init file for timetree_exporter package.""" 2 | 3 | from importlib.metadata import version, PackageNotFoundError 4 | import logging 5 | from timetree_exporter.event import TimeTreeEvent 6 | from timetree_exporter.formatter import ICalEventFormatter 7 | 8 | logger = logging.getLogger(__name__) 9 | logging.basicConfig( 10 | format="%(asctime)s [%(levelname)s] %(message)s ", 11 | datefmt="%Y-%m-%d %H:%M:%S", 12 | level=logging.INFO, 13 | ) 14 | 15 | try: 16 | __version__ = version("timetree_exporter") 17 | except PackageNotFoundError: 18 | __version__ = "unknown" 19 | -------------------------------------------------------------------------------- /.github/workflows/lint-and-test.yml: -------------------------------------------------------------------------------- 1 | name: Linting and Testing 2 | 3 | on: [push] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | build: 10 | runs-on: ${{ matrix.os }} 11 | strategy: 12 | matrix: 13 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] 14 | os: [ubuntu-latest, windows-latest] 15 | steps: 16 | - uses: actions/checkout@v6 17 | - name: Install uv and set the Python version 18 | uses: astral-sh/setup-uv@v7 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | enable-cache: true 22 | - name: Install the project 23 | run: uv sync --locked --all-extras 24 | - name: Analyse the code with pylint 25 | run: uv run pylint $(git ls-files '*.py') 26 | - name: Run tests with coverage 27 | run: uv run pytest tests --cov=timetree_exporter --cov-report=term-missing 28 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v5.0.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-yaml 10 | - repo: local 11 | hooks: 12 | - id: uv-lock 13 | name: uv-lock 14 | entry: uv lock 15 | language: system 16 | files: ^(pyproject\.toml|uv\.lock)$ 17 | pass_filenames: false 18 | - id: black 19 | name: black 20 | entry: uv run black 21 | language: system 22 | types: [python] 23 | args: ["--quiet"] 24 | - id: pylint 25 | name: pylint 26 | entry: uv run pylint 27 | language: system 28 | types: [python] 29 | args: [ 30 | "-rn", # Only display messages 31 | "-sn", # Don't display the score 32 | ] 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 蔡鳳駿 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 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | jobs: 16 | deploy: 17 | name: upload release to PyPI 18 | runs-on: ubuntu-latest 19 | environment: release 20 | permissions: 21 | id-token: write 22 | contents: read 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v6 26 | - name: Install uv 27 | uses: astral-sh/setup-uv@v7 28 | with: 29 | enable-cache: true 30 | - name: Install Python 3.13 31 | run: uv python install 3.13 32 | - name: Build 33 | run: uv build 34 | - name: Publish 35 | run: uv publish 36 | -------------------------------------------------------------------------------- /timetree_exporter/api/auth.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module contains the User class, which is responsible for handling user-related operations. 3 | """ 4 | 5 | import uuid 6 | import logging 7 | from typing import Union 8 | import requests 9 | from timetree_exporter.api.const import API_BASEURI, API_USER_AGENT 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def login(email, password) -> Union[str, None]: 15 | """ 16 | Log in to the TimeTree app and return the session ID. 17 | """ 18 | url = f"{API_BASEURI}/auth/email/signin" 19 | payload = { 20 | "uid": email, 21 | "password": password, 22 | "uuid": str(uuid.uuid4()).replace("-", ""), 23 | } 24 | headers = { 25 | "Content-Type": "application/json", 26 | "X-Timetreea": API_USER_AGENT, 27 | } 28 | 29 | response = requests.put(url, json=payload, headers=headers, timeout=10) 30 | 31 | if response.status_code != 200: 32 | logger.error("Login failed: %s", response.text) 33 | raise AuthenticationError("Login failed") 34 | 35 | try: 36 | session_id = response.cookies["_session_id"] 37 | return session_id 38 | except KeyError: 39 | return None 40 | 41 | 42 | class AuthenticationError(Exception): 43 | """ 44 | Exception raised when the user is not authorized to access the resource. 45 | """ 46 | -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": { 3 | ".": { 4 | "changelog-path": "CHANGELOG.md", 5 | "release-type": "python", 6 | "bump-minor-pre-major": false, 7 | "bump-patch-for-minor-pre-major": false, 8 | "draft": false, 9 | "extra-files": [ 10 | { 11 | "type": "toml", 12 | "path": "uv.lock", 13 | "jsonpath": "$.package[?(@.name.value=='timetree-exporter')].version" 14 | } 15 | ], 16 | "changelog-sections": [ 17 | {"type": "feat", "section": "Features"}, 18 | {"type": "feature", "section": "Features"}, 19 | {"type": "fix", "section": "Bug Fixes"}, 20 | {"type": "perf", "section": "Performance Improvements"}, 21 | {"type": "revert", "section": "Reverts"}, 22 | {"type": "docs", "section": "Documentation"}, 23 | {"type": "chore", "section": "Miscellaneous Chores"}, 24 | {"type": "refactor", "section": "Code Refactoring"}, 25 | {"type": "build", "section": "Build System"}, 26 | {"type": "style", "section": "Styles", "hidden": true}, 27 | {"type": "test", "section": "Tests", "hidden": true}, 28 | {"type": "ci", "section": "Continuous Integration", "hidden": true} 29 | ], 30 | "prerelease": false 31 | } 32 | }, 33 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json" 34 | } 35 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "timetree-exporter" 3 | version = "0.6.2" 4 | description = "A Tool for Exporting TimeTree Calendar and Convert to iCal format(.ics)" 5 | authors = [{ name = "Fong-Chun Tsai", email = "eoleedimin@gmail.com" }] 6 | license = "MIT" 7 | license-files = ["LICENSE"] 8 | readme = "README.md" 9 | keywords = ["timetree", "exporter", "icalendar", "ics"] 10 | classifiers = [ 11 | # Supported Python versions 12 | "Programming Language :: Python :: 3", 13 | "Programming Language :: Python :: 3.9", 14 | "Programming Language :: Python :: 3.10", 15 | "Programming Language :: Python :: 3.11", 16 | "Programming Language :: Python :: 3.12", 17 | "Programming Language :: Python :: 3.13", 18 | "Programming Language :: Python :: 3.14", 19 | # License 20 | "License :: OSI Approved :: MIT License", 21 | # OS 22 | "Operating System :: OS Independent", 23 | ] 24 | requires-python = "<4.0,>=3.9" 25 | dynamic = [] 26 | dependencies = [ 27 | "icalendar<7.0.0,>=6.1.0", 28 | "tzdata<2026.0,>=2024.2", 29 | "requests<3.0.0,>=2.32.5", 30 | "pwinput-eoleedi>=1.0.3.post1,<2.0.0.0 ; python_full_version < '3.14'", 31 | ] 32 | 33 | [project.urls] 34 | Homepage = "https://github.com/eoleedi/TimeTree-Exporter" 35 | Repository = "https://github.com/eoleedi/TimeTree-Exporter" 36 | Issues = "https://github.com/eoleedi/TimeTree-Exporter/issues" 37 | Changelog = "https://github.com/eoleedi/TimeTree-exporter/blob/main/CHANGELOG.md" 38 | 39 | [project.scripts] 40 | timetree-exporter = "timetree_exporter.__main__:main" 41 | 42 | [build-system] 43 | requires = ["uv_build>=0.9.17,<0.10.0"] 44 | build-backend = "uv_build" 45 | 46 | [tool.uv.build-backend] 47 | module-name = "timetree_exporter" 48 | module-root = "" 49 | 50 | [tool.pytest.ini_options] 51 | testpaths = ["tests"] 52 | python_files = "test_*.py" 53 | python_functions = "test_*" 54 | 55 | [dependency-groups] 56 | dev = [ 57 | "pylint<4.0.0,>=3.3.9", 58 | "pre-commit<5.0.0,>=4.3.0", 59 | "black<26.0,>=24.10", 60 | "pytest<9.0.0,>=8.4.2", 61 | "pytest-cov<8,>=5", 62 | ] 63 | -------------------------------------------------------------------------------- /timetree_exporter/event.py: -------------------------------------------------------------------------------- 1 | """This module provides the TimeTreeEvent class for representing TimeTree events.""" 2 | 3 | from dataclasses import dataclass 4 | 5 | 6 | @dataclass 7 | class TimeTreeEvent: 8 | """TimeTree event class""" 9 | 10 | # pylint: disable=too-many-instance-attributes 11 | 12 | uuid: str 13 | title: str 14 | created_at: int 15 | updated_at: int 16 | recurrences: list 17 | alerts: list 18 | url: str 19 | note: str 20 | start_at: int 21 | end_at: int 22 | all_day: bool 23 | start_timezone: str 24 | end_timezone: str 25 | location_lat: str 26 | location_lon: str 27 | location: str 28 | parent_id: str 29 | event_type: int 30 | category: int 31 | 32 | @classmethod 33 | def from_dict(cls, event_data: dict): 34 | """Create TimeTreeEvent object from JSON data""" 35 | return cls( 36 | uuid=event_data.get("uuid"), 37 | title=event_data.get("title"), 38 | created_at=event_data.get("created_at"), 39 | updated_at=event_data.get("updated_at"), 40 | note=event_data.get("note"), 41 | location=event_data.get("location"), 42 | location_lat=event_data.get("location_lat"), 43 | location_lon=event_data.get("location_lon"), 44 | url=event_data.get("url"), 45 | start_at=event_data.get("start_at"), 46 | start_timezone=event_data.get("start_timezone"), 47 | end_at=event_data.get("end_at"), 48 | end_timezone=event_data.get("end_timezone"), 49 | all_day=event_data.get("all_day"), 50 | alerts=event_data.get("alerts"), 51 | recurrences=event_data.get("recurrences"), 52 | parent_id=event_data.get("parent_id"), 53 | event_type=event_data.get("type"), 54 | category=event_data.get("category"), 55 | ) 56 | 57 | def __str__(self): 58 | return self.title 59 | 60 | 61 | @dataclass 62 | class TimeTreeEventType(enumerate): 63 | """TimeTree event type enumeration""" 64 | 65 | NORMAL = 0 66 | BIRTHDAY = 1 67 | 68 | 69 | @dataclass 70 | class TimeTreeEventCategory(enumerate): 71 | """TimeTree event category enumeration""" 72 | 73 | NORMAL = 1 74 | MEMO = 2 75 | -------------------------------------------------------------------------------- /timetree_exporter/api/calendar.py: -------------------------------------------------------------------------------- 1 | """ 2 | Timetree calendar API 3 | """ 4 | 5 | import json 6 | import logging 7 | 8 | import requests 9 | from requests.exceptions import HTTPError 10 | 11 | from timetree_exporter.api.const import API_BASEURI, API_USER_AGENT 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class TimeTreeCalendar: 17 | """ 18 | Timetree calendar API 19 | """ 20 | 21 | def __init__(self, session_id: str): 22 | self.session = requests.Session() 23 | self.session.cookies.set("_session_id", session_id) 24 | 25 | def get_metadata(self): 26 | """ 27 | Get calendar metadata. 28 | """ 29 | url = f"{API_BASEURI}/calendars?since=0" 30 | response = self.session.get( 31 | url, 32 | headers={ 33 | "Content-Type": "application/json", 34 | "X-Timetreea": API_USER_AGENT, 35 | }, 36 | ) 37 | if response.status_code != 200: 38 | logger.error(response.text) 39 | raise HTTPError("Failed to get calendar metadata") 40 | return response.json()["calendars"] 41 | 42 | def get_events_recur(self, calendar_id: int, since: int): 43 | """ 44 | Get events from the calendar.(Recursively) 45 | """ 46 | url = f"{API_BASEURI}/calendar/{calendar_id}/events/sync?since={since}" 47 | response = self.session.get( 48 | url, 49 | headers={ 50 | "Content-Type": "application/json", 51 | "X-Timetreea": API_USER_AGENT, 52 | }, 53 | ) 54 | 55 | r_json = response.json() 56 | 57 | events = r_json["events"] 58 | logger.info("Fetched %d events", len(events)) 59 | if r_json["chunk"] is True: 60 | events.extend(self.get_events_recur(calendar_id, r_json["since"])) 61 | return events 62 | 63 | def get_events(self, calendar_id: int, calendar_name: str = None): 64 | """ 65 | Get events from the calendar. 66 | """ 67 | url = f"{API_BASEURI}/calendar/{calendar_id}/events/sync" 68 | response = self.session.get( 69 | url, 70 | headers={"Content-Type": "application/json", "X-Timetreea": API_USER_AGENT}, 71 | ) 72 | if response.status_code != 200: 73 | if calendar_name is not None: 74 | logger.error("Failed to get events of the calendar '%s'", calendar_name) 75 | else: 76 | logger.error("Failed to get events of the calendar") 77 | logger.error(response.text) 78 | 79 | r_json = response.json() 80 | events = r_json["events"] 81 | logger.info("Fetched %d events", len(events)) 82 | if r_json["chunk"] is True: 83 | events.extend(self.get_events_recur(calendar_id, r_json["since"])) 84 | 85 | logger.debug( 86 | "Top 5 fetched events: \n %s", 87 | json.dumps(events[:5], indent=2, ensure_ascii=False), 88 | ) 89 | 90 | return events 91 | -------------------------------------------------------------------------------- /timetree_exporter/utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions for Timetree Exporter""" 2 | 3 | import json 4 | import os 5 | import logging 6 | import inspect 7 | import getpass 8 | from datetime import datetime, timedelta 9 | from zoneinfo import ZoneInfo 10 | 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def get_events_from_file(file_path) -> list: 16 | """Fetch events from Timetree response file""" 17 | try: 18 | with open(file_path, "r", encoding="UTF-8") as response_file: 19 | response_data = json.load(response_file) 20 | if "events" in response_data: 21 | return response_data["events"] 22 | if "public_events" in response_data: # Partal support for public events 23 | return response_data["public_events"] 24 | logger.error( 25 | "Invalid response file: %s. \n No 'events' or 'public_events' column in the file", 26 | file_path, 27 | ) 28 | return None 29 | except FileNotFoundError: 30 | logger.error("File not found: %s", file_path) 31 | return None 32 | 33 | 34 | def paths_to_filelist(paths: list) -> list: 35 | """Converts a list of paths to a list of files""" 36 | filenames = [] 37 | for path in paths: 38 | if os.path.isdir(path): 39 | filenames += [os.path.join(path, file) for file in os.listdir(path)] 40 | elif os.path.isfile(path): 41 | filenames.append(path) 42 | else: 43 | logger.error("Invalid path: %s", path) 44 | return filenames 45 | 46 | 47 | def convert_timestamp_to_datetime(timestamp, tzinfo=ZoneInfo("UTC")): 48 | """ 49 | Convert timestamp to datetime for both positive and negative timestamps on different platforms. 50 | """ 51 | if timestamp >= 0: 52 | return datetime.fromtimestamp(timestamp, tzinfo) 53 | return datetime.fromtimestamp(0, tzinfo) + timedelta(seconds=int(timestamp)) 54 | 55 | 56 | def safe_getpass(prompt="Password: ", echo_char=None): 57 | """Safely get a password from the user, supporting echo_char for Python 3.14+. 58 | If echo_char is not supported, it falls back to the pwinput module if echo_char is needed. 59 | """ 60 | sig = inspect.signature(getpass.getpass) 61 | if "echo_char" in sig.parameters: 62 | # Python 3.14+ supports echo_char 63 | return getpass.getpass( # pylint: disable=E1123 64 | prompt=prompt, echo_char=echo_char 65 | ) 66 | if echo_char is not None: 67 | # Use pwinput for echo_char support in older versions 68 | try: 69 | from pwinput import pwinput # pylint: disable=C0415 70 | 71 | return pwinput(prompt=prompt, mask=echo_char) 72 | except ImportError as exc: 73 | logger.error("pwinput module is required for echo_char support.") 74 | raise ImportError( 75 | "Please install pwinput to use echo_char functionality." 76 | ) from exc 77 | else: 78 | # Fallback for older versions 79 | return getpass.getpass(prompt=prompt) 80 | -------------------------------------------------------------------------------- /tests/test_event.py: -------------------------------------------------------------------------------- 1 | """Tests for the event module.""" 2 | 3 | from timetree_exporter.event import ( 4 | TimeTreeEvent, 5 | TimeTreeEventType, 6 | TimeTreeEventCategory, 7 | ) 8 | 9 | 10 | def test_event_creation(normal_event_data): 11 | """Test creating a TimeTreeEvent.""" 12 | event = TimeTreeEvent( 13 | uuid=normal_event_data["uuid"], 14 | title=normal_event_data["title"], 15 | created_at=normal_event_data["created_at"], 16 | updated_at=normal_event_data["updated_at"], 17 | note=normal_event_data["note"], 18 | location=normal_event_data["location"], 19 | location_lat=normal_event_data["location_lat"], 20 | location_lon=normal_event_data["location_lon"], 21 | url=normal_event_data["url"], 22 | start_at=normal_event_data["start_at"], 23 | start_timezone=normal_event_data["start_timezone"], 24 | end_at=normal_event_data["end_at"], 25 | end_timezone=normal_event_data["end_timezone"], 26 | all_day=normal_event_data["all_day"], 27 | alerts=normal_event_data["alerts"], 28 | recurrences=normal_event_data["recurrences"], 29 | parent_id=normal_event_data["parent_id"], 30 | event_type=normal_event_data["type"], 31 | category=normal_event_data["category"], 32 | ) 33 | 34 | assert event.uuid == normal_event_data["uuid"] 35 | assert event.title == normal_event_data["title"] 36 | assert event.created_at == normal_event_data["created_at"] 37 | assert event.updated_at == normal_event_data["updated_at"] 38 | assert event.note == normal_event_data["note"] 39 | assert event.location == normal_event_data["location"] 40 | assert event.location_lat == normal_event_data["location_lat"] 41 | assert event.location_lon == normal_event_data["location_lon"] 42 | assert event.url == normal_event_data["url"] 43 | assert event.start_at == normal_event_data["start_at"] 44 | assert event.start_timezone == normal_event_data["start_timezone"] 45 | assert event.end_at == normal_event_data["end_at"] 46 | assert event.end_timezone == normal_event_data["end_timezone"] 47 | assert event.all_day == normal_event_data["all_day"] 48 | assert event.alerts == normal_event_data["alerts"] 49 | assert event.recurrences == normal_event_data["recurrences"] 50 | assert event.parent_id == normal_event_data["parent_id"] 51 | assert event.event_type == normal_event_data["type"] 52 | assert event.category == normal_event_data["category"] 53 | 54 | 55 | def test_from_dict(normal_event_data): 56 | """Test creating a TimeTreeEvent from a dictionary.""" 57 | event = TimeTreeEvent.from_dict(normal_event_data) 58 | 59 | assert event.uuid == normal_event_data["uuid"] 60 | assert event.title == normal_event_data["title"] 61 | assert event.created_at == normal_event_data["created_at"] 62 | assert event.updated_at == normal_event_data["updated_at"] 63 | assert event.note == normal_event_data["note"] 64 | assert event.location == normal_event_data["location"] 65 | assert event.location_lat == normal_event_data["location_lat"] 66 | assert event.location_lon == normal_event_data["location_lon"] 67 | assert event.url == normal_event_data["url"] 68 | assert event.start_at == normal_event_data["start_at"] 69 | assert event.start_timezone == normal_event_data["start_timezone"] 70 | assert event.end_at == normal_event_data["end_at"] 71 | assert event.end_timezone == normal_event_data["end_timezone"] 72 | assert event.all_day == normal_event_data["all_day"] 73 | assert event.alerts == normal_event_data["alerts"] 74 | assert event.recurrences == normal_event_data["recurrences"] 75 | assert event.parent_id == normal_event_data["parent_id"] 76 | assert event.event_type == normal_event_data["type"] 77 | assert event.category == normal_event_data["category"] 78 | 79 | 80 | def test_str_representation(normal_event_data): 81 | """Test the string representation of a TimeTreeEvent.""" 82 | event = TimeTreeEvent.from_dict(normal_event_data) 83 | assert str(event) == normal_event_data["title"] 84 | 85 | 86 | def test_event_types(): 87 | """Test the TimeTreeEventType enumeration.""" 88 | assert TimeTreeEventType.NORMAL == 0 89 | assert TimeTreeEventType.BIRTHDAY == 1 90 | 91 | 92 | def test_event_categories(): 93 | """Test the TimeTreeEventCategory enumeration.""" 94 | assert TimeTreeEventCategory.NORMAL == 1 95 | assert TimeTreeEventCategory.MEMO == 2 96 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TimeTree Exporter 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/timetree-exporter.svg)](https://pypi.org/project/timetree-exporter/) 4 | [![Python 3.x](https://img.shields.io/pypi/pyversions/timetree-exporter.svg?logo=python&logoColor=white)](https://pypi.org/project/timetree-exporter/) 5 | [![License](https://img.shields.io/github/license/eoleedi/TimeTree-Exporter)](https://github.com/eoleedi/TimeTree-Exporter/blob/main/LICENSE) 6 | [![Downloads](https://img.shields.io/pypi/dm/timetree-exporter)](https://pypistats.org/packages/timetree-exporter) 7 | [![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-Donate-orange.svg?logo=buymeacoffee&logoColor=white)](https://www.buymeacoffee.com/eoleedi) 8 | 9 | A Tool for Exporting TimeTree Calendar and Convert to iCal format(.ics) \ 10 | This script works by scraping the TimeTree web app and converting the data to iCal format. 11 | (The .ics file can then be imported into other calendar apps such as Google Calendar, Apple Calendar, Outlook Calendar, etc.) 12 | 13 | ## Installation 14 | 15 | If you are on mac, you can install it using brew: 16 | 17 | ```bash 18 | brew install eoleedi/tap/timetree-exporter 19 | ``` 20 | 21 | You can also install it using pip or pipx: 22 | 23 | ```bash 24 | pip install timetree-exporter 25 | ``` 26 | 27 | Timetree Exporter requires Python 3.9 or later. 28 | 29 | ## Usage 30 | 31 | ```bash 32 | timetree-exporter -o path/to/output.ics 33 | ``` 34 | 35 | This will prompt you to enter your TimeTree email and password and select the calendar you want to export. 36 | 37 | Then, you can import the ics file to your calendar app. 38 | 39 | 💡 Note: You are advised to import the ICS file into a separate calendar (e.g., Google Calendar) so that if anything goes wrong, you can simply delete the calendar and reimport it. 40 | 41 | ### Advanced Usage 42 | 43 | - You can specify your email address using the `-e` option. 44 | 45 | ```bash 46 | timetree-exporter -e email@example.com 47 | ``` 48 | 49 | - You can specify the calendar code using the `-c` or `--calendar_code` option. 50 | 51 | ```bash 52 | timetree-exporter -c calendar_code 53 | ``` 54 | 55 | Note: Find the calendar code in the URL of the calendar page or when running the script without the `-c` option. 56 | 57 | - You can pass your email address and password with environment variables. (usually for automation purposes) 58 | 59 | ```bash 60 | export TIMETREE_EMAIL=email@example.com 61 | export TIMETREE_PASSWORD=password 62 | ``` 63 | 64 | ## Limitations 65 | 66 | Alarms(Alerts) can't be imported to Google Calendar through iCal format due to Google's bug. 67 | 68 | ## Development 69 | 70 | This project uses [uv](https://docs.astral.sh/uv/) for dependency management. 71 | 72 | ### Setup 73 | 74 | 1. Install uv: 75 | 76 | ```bash 77 | curl -LsSf https://astral.sh/uv/install.sh | sh 78 | ``` 79 | 80 | 2. Clone the repository and install dependencies: 81 | 82 | ```bash 83 | git clone https://github.com/eoleedi/TimeTree-Exporter.git 84 | cd TimeTree-Exporter 85 | uv sync 86 | ``` 87 | 88 | 3. Install pre-commit hooks: 89 | 90 | ```bash 91 | uv run pre-commit install 92 | ``` 93 | 94 | ### Running Tests 95 | 96 | ```bash 97 | uv run pytest tests 98 | ``` 99 | 100 | ### Running Linter 101 | 102 | ```bash 103 | uv run pylint timetree_exporter 104 | ``` 105 | 106 | ## Support 107 | 108 | If you think it's helpful, kindly support me! 109 | 110 | [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/eoleedi) 111 | 112 | ## Roadmap of the properties mapping to iCal 113 | 114 | - [ ] **ID** 115 | - [ ] **Primary ID** 116 | - [ ] **Calendar ID** 117 | - [x] **UUID** 118 | - [x] **Category** 119 | - [x] **Type** 120 | - [ ] **Author ID** 121 | - [ ] **Author Type** 122 | - [x] **Title** 123 | - [x] **All Day** 124 | - [x] **Start At** 125 | - [x] **Start Timezone** 126 | - [x] **End At** 127 | - [x] **End Timezone** 128 | - [ ] **Label ID** 129 | - [x] **Location** 130 | - [x] **Location Latitude** 131 | - [x] **Location Longitude** 132 | - [x] **URL** 133 | - [x] **Note** 134 | - [ ] **Lunar** 135 | - [ ] **Attendees** 136 | - [x] **Recurrences** 137 | - [ ] **Recurring UUID** 138 | - [x] **Alerts** 139 | - [x] **Parent ID** 140 | - [ ] **Link Object ID** 141 | - [ ] **Link Object ID String** 142 | - [ ] ~~**Row Order**~~ (Ignore since it's a property for timetree notes) 143 | - [ ] **Attachment** 144 | - [ ] **Like Count** 145 | - [ ] **Files** 146 | - [ ] **Deactivated At** 147 | - [ ] **Pinned At** 148 | - [x] **Updated At** 149 | - [x] **Created At** 150 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | """Tests for the utils module.""" 2 | 3 | import os 4 | import tempfile 5 | from datetime import datetime 6 | from zoneinfo import ZoneInfo 7 | 8 | from timetree_exporter.utils import ( 9 | get_events_from_file, 10 | paths_to_filelist, 11 | convert_timestamp_to_datetime, 12 | ) 13 | 14 | 15 | def test_get_events_from_file(temp_event_file): 16 | """Test getting events from a JSON file.""" 17 | events = get_events_from_file(temp_event_file) 18 | 19 | assert len(events) == 2 20 | assert events[0]["uuid"] == "test-uuid-1" 21 | assert events[0]["title"] == "測試活動 1" 22 | assert events[1]["uuid"] == "test-uuid-2" 23 | assert events[1]["title"] == "測試活動 2" 24 | 25 | 26 | def test_get_public_events_from_file(temp_public_event_file): 27 | """Test getting public events from a JSON file.""" 28 | events = get_events_from_file(temp_public_event_file) 29 | 30 | assert len(events) == 2 31 | assert events[0]["uuid"] == "test-uuid-1" 32 | assert events[0]["title"] == "公開測試活動 1" 33 | assert events[1]["uuid"] == "test-uuid-2" 34 | assert events[1]["title"] == "公開測試活動 2" 35 | 36 | 37 | def test_get_events_from_invalid_file(temp_invalid_file): 38 | """Test getting events from a file with invalid format.""" 39 | events = get_events_from_file(temp_invalid_file) 40 | assert events is None 41 | 42 | 43 | def test_get_events_from_nonexistent_file(): 44 | """Test getting events from a nonexistent file.""" 45 | events = get_events_from_file("nonexistent_file.json") 46 | assert events is None 47 | 48 | 49 | def test_paths_to_filelist(temp_directory): 50 | """Test converting paths to a file list.""" 51 | # 創建一個獨立的文件用於測試 52 | with tempfile.NamedTemporaryFile(delete=False) as temp_file: 53 | temp_file_path = temp_file.name 54 | 55 | try: 56 | # 測試混合目錄和文件的路徑 57 | paths = [temp_directory, temp_file_path] 58 | file_list = paths_to_filelist(paths) 59 | 60 | # 驗證結果 61 | assert len(file_list) == 4 # 3 files in directory + 1 temp file 62 | assert temp_file_path in file_list 63 | 64 | # 檢查目錄中的文件是否包含在列表中 65 | for i in range(3): 66 | expected_file = os.path.join(temp_directory, f"file_{i}.txt") 67 | assert expected_file in file_list 68 | 69 | # 測試無效路徑 70 | invalid_paths = ["/nonexistent/path"] 71 | invalid_file_list = paths_to_filelist(invalid_paths) 72 | assert len(invalid_file_list) == 0 73 | finally: 74 | # 清理 75 | os.unlink(temp_file_path) 76 | 77 | 78 | def test_convert_timestamp_to_datetime(): 79 | """Test converting timestamps to datetime objects.""" 80 | # 測試正時間戳(1970年之後) 81 | positive_timestamp = 1713110000 # 2024年4月14日左右 82 | positive_dt = convert_timestamp_to_datetime(positive_timestamp, ZoneInfo("UTC")) 83 | 84 | assert isinstance(positive_dt, datetime) 85 | assert positive_dt.year == 2024 86 | assert positive_dt.tzinfo == ZoneInfo("UTC") 87 | 88 | # 測試帶有不同時區的時間戳 89 | taipei_dt = convert_timestamp_to_datetime( 90 | positive_timestamp, ZoneInfo("Asia/Taipei") 91 | ) 92 | assert taipei_dt.tzinfo == ZoneInfo("Asia/Taipei") 93 | 94 | # 測試時區轉換是否正確(檢查時區差異) 95 | new_york_dt = convert_timestamp_to_datetime( 96 | positive_timestamp, ZoneInfo("America/New_York") 97 | ) 98 | assert new_york_dt.tzinfo == ZoneInfo("America/New_York") 99 | 100 | # 檢查不同時區間的時間差異 101 | # UTC 與台北時區差 8 小時 102 | utc_taipei_diff = ( 103 | taipei_dt.utcoffset() - positive_dt.utcoffset() 104 | ).total_seconds() / 3600 105 | assert utc_taipei_diff == 8.0 106 | 107 | # 計算 UTC 與紐約的時差(根據季節可能是 -4 或 -5 小時) 108 | utc_ny_diff = ( 109 | new_york_dt.utcoffset() - positive_dt.utcoffset() 110 | ).total_seconds() / 3600 111 | # 2024年4月是夏令時間,所以時差應該是 -4 小時 112 | assert utc_ny_diff == -4.0 113 | 114 | # 測試負時間戳(1970年之前) 115 | negative_timestamp = -10000000 # 約1969年12月 116 | negative_dt = convert_timestamp_to_datetime(negative_timestamp, ZoneInfo("UTC")) 117 | 118 | assert isinstance(negative_dt, datetime) 119 | assert negative_dt.year == 1969 120 | assert negative_dt.tzinfo == ZoneInfo("UTC") 121 | 122 | # 測試在特定時間戳的本地時間是否正確 123 | # 創建一個已知的時間戳並檢查對應的本地時間 124 | timestamp_2023_01_01 = 1672531200 # 2023-01-01 00:00:00 UTC 125 | london_dt = convert_timestamp_to_datetime( 126 | timestamp_2023_01_01, ZoneInfo("Europe/London") 127 | ) 128 | assert london_dt.year == 2023 129 | assert london_dt.month == 1 130 | assert london_dt.day == 1 131 | assert london_dt.hour == 0 # 冬令時間,倫敦與UTC相同 132 | 133 | tokyo_dt = convert_timestamp_to_datetime( 134 | timestamp_2023_01_01, ZoneInfo("Asia/Tokyo") 135 | ) 136 | assert tokyo_dt.year == 2023 137 | assert tokyo_dt.month == 1 138 | assert tokyo_dt.day == 1 139 | assert tokyo_dt.hour == 9 # 東京比UTC快9小時 140 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Test configuration for pytest.""" 2 | 3 | import json 4 | import os 5 | import tempfile 6 | import pytest 7 | from timetree_exporter.event import TimeTreeEventType, TimeTreeEventCategory 8 | 9 | 10 | @pytest.fixture 11 | def normal_event_data(): 12 | """Fixture for normal event data.""" 13 | return { 14 | "uuid": "test-uuid-normal", 15 | "title": "測試一般活動", 16 | "created_at": 1713110000000, # Unix timestamp in milliseconds 17 | "updated_at": 1713110100000, # Unix timestamp in milliseconds 18 | "note": "測試備註", 19 | "location": "測試地點", 20 | "location_lat": "25.0335", 21 | "location_lon": "121.5645", 22 | "url": "https://example.com", 23 | "start_at": 1713120000000, # Unix timestamp in milliseconds 24 | "start_timezone": "Asia/Taipei", 25 | "end_at": 1713123600000, # Unix timestamp in milliseconds 26 | "end_timezone": "Asia/Taipei", 27 | "all_day": False, 28 | "alerts": [15, 60], # 15 mins and 60 mins before event 29 | "recurrences": ["RRULE:FREQ=WEEKLY;COUNT=5"], 30 | "parent_id": "parent-uuid", 31 | "type": TimeTreeEventType.NORMAL, 32 | "category": TimeTreeEventCategory.NORMAL, 33 | } 34 | 35 | 36 | @pytest.fixture 37 | def birthday_event_data(): 38 | """Fixture for birthday event data.""" 39 | return { 40 | "uuid": "test-uuid-birthday", 41 | "title": "測試生日活動", 42 | "created_at": 1713110000000, 43 | "updated_at": 1713110100000, 44 | "note": "", 45 | "location": "", 46 | "location_lat": None, 47 | "location_lon": None, 48 | "url": "", 49 | "start_at": 1713120000000, 50 | "start_timezone": "Asia/Taipei", 51 | "end_at": 1713206400000, # Next day for all-day event 52 | "end_timezone": "Asia/Taipei", 53 | "all_day": True, 54 | "alerts": [15 * 60], # 15 hours before event 55 | "recurrences": ["RRULE:FREQ=YEARLY"], 56 | "parent_id": "", 57 | "type": TimeTreeEventType.BIRTHDAY, 58 | "category": TimeTreeEventCategory.NORMAL, 59 | } 60 | 61 | 62 | @pytest.fixture 63 | def memo_event_data(): 64 | """Fixture for memo event data.""" 65 | return { 66 | "uuid": "test-uuid-memo", 67 | "title": "測試備忘錄", 68 | "created_at": 1713110000000, 69 | "updated_at": 1713110100000, 70 | "note": "備忘內容", 71 | "location": "", 72 | "location_lat": None, 73 | "location_lon": None, 74 | "url": "", 75 | "start_at": 1713120000000, 76 | "start_timezone": "Asia/Taipei", 77 | "end_at": 1713123600000, 78 | "end_timezone": "Asia/Taipei", 79 | "all_day": False, 80 | "alerts": None, 81 | "recurrences": None, 82 | "parent_id": "", 83 | "type": TimeTreeEventType.NORMAL, 84 | "category": TimeTreeEventCategory.MEMO, 85 | } 86 | 87 | 88 | @pytest.fixture 89 | def temp_event_file(): 90 | """Create a temporary file with event data for testing.""" 91 | event_data = { 92 | "events": [ 93 | { 94 | "uuid": "test-uuid-1", 95 | "title": "測試活動 1", 96 | }, 97 | { 98 | "uuid": "test-uuid-2", 99 | "title": "測試活動 2", 100 | }, 101 | ] 102 | } 103 | 104 | with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f: 105 | json.dump(event_data, f) 106 | temp_file_path = f.name 107 | 108 | yield temp_file_path 109 | 110 | # 測試後清理 111 | os.unlink(temp_file_path) 112 | 113 | 114 | @pytest.fixture 115 | def temp_public_event_file(): 116 | """Create a temporary file with public event data for testing.""" 117 | event_data = { 118 | "public_events": [ 119 | { 120 | "uuid": "test-uuid-1", 121 | "title": "公開測試活動 1", 122 | }, 123 | { 124 | "uuid": "test-uuid-2", 125 | "title": "公開測試活動 2", 126 | }, 127 | ] 128 | } 129 | 130 | with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f: 131 | json.dump(event_data, f) 132 | temp_file_path = f.name 133 | 134 | yield temp_file_path 135 | 136 | # 測試後清理 137 | os.unlink(temp_file_path) 138 | 139 | 140 | @pytest.fixture 141 | def temp_invalid_file(): 142 | """Create a temporary file with invalid data for testing.""" 143 | invalid_data = {"some_other_key": []} 144 | 145 | with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f: 146 | json.dump(invalid_data, f) 147 | temp_file_path = f.name 148 | 149 | yield temp_file_path 150 | 151 | # 測試後清理 152 | os.unlink(temp_file_path) 153 | 154 | 155 | @pytest.fixture 156 | def temp_directory(): 157 | """Create a temporary directory with multiple files for testing.""" 158 | with tempfile.TemporaryDirectory() as temp_dir: 159 | # 在臨時目錄中創建一些文件 160 | for i in range(3): 161 | with open( 162 | os.path.join(temp_dir, f"file_{i}.txt"), "w", encoding="utf-8" 163 | ) as f: 164 | f.write(f"Content of file {i}") 165 | 166 | yield temp_dir 167 | -------------------------------------------------------------------------------- /timetree_exporter/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module login in to TimeTree and converts Timetree events to iCal format. 3 | """ 4 | 5 | import argparse 6 | import logging 7 | import os 8 | from importlib.metadata import version 9 | from icalendar import Calendar 10 | from timetree_exporter import TimeTreeEvent, ICalEventFormatter, __version__ 11 | from timetree_exporter.api.auth import login 12 | from timetree_exporter.api.calendar import TimeTreeCalendar 13 | from timetree_exporter.utils import safe_getpass 14 | 15 | logger = logging.getLogger(__name__) 16 | package_logger = logging.getLogger(__package__) 17 | 18 | 19 | def get_events(email: str, password: str, calendar_code: str): 20 | """Get events from the Timetree API.""" 21 | use_code = bool(calendar_code) 22 | session_id = login(email, password) 23 | calendar = TimeTreeCalendar(session_id) 24 | metadatas = calendar.get_metadata() 25 | 26 | # Filter out deactivated calendars 27 | metadatas = [ 28 | metadata for metadata in metadatas if metadata["deactivated_at"] is None 29 | ] 30 | 31 | if len(metadatas) == 0: 32 | logger.error("No active calendars found") 33 | raise ValueError 34 | 35 | if calendar_code: 36 | # Filter calendars by code 37 | filtered_metadatas = [ 38 | metadata 39 | for metadata in metadatas 40 | if metadata["alias_code"] == calendar_code 41 | ] 42 | 43 | if len(filtered_metadatas) == 0: 44 | logger.error("No calendars found with the specified codes") 45 | use_code = False 46 | else: 47 | metadata = filtered_metadatas[0] 48 | print( 49 | f"Using calendar: {metadata['name']} (code: {metadata['alias_code']})" 50 | ) 51 | use_code = True 52 | 53 | if not use_code: 54 | # Print out the list of calendars for the user to choose from 55 | for i, metadata in enumerate(metadatas): 56 | print( 57 | f"{i+1}. {metadata['name'] if metadata['name'] else 'Unnamed'} " 58 | f"(code: {metadata['alias_code']})" 59 | ) 60 | 61 | # Ask the user to choose a calendar 62 | calendar_num = ( 63 | input("Which Calendar(s) do you want to export? (Default to 1): ") or "1" 64 | ) 65 | if not calendar_num.isdigit() or not 1 <= int(calendar_num) <= len(metadatas): 66 | raise ValueError( 67 | f"Invalid Calendar Number. Must be a number between 1 and {len(metadatas)}" 68 | ) 69 | idx = int(calendar_num) - 1 70 | metadata = metadatas[idx] 71 | 72 | # Get events from the selected calendar 73 | calendar_id = metadata["id"] 74 | calendar_name = metadata["name"] 75 | 76 | return calendar.get_events(calendar_id, calendar_name) 77 | 78 | 79 | def main(): 80 | """Main function for the Timetree Exporter.""" 81 | # Parse arguments 82 | parser = argparse.ArgumentParser( 83 | description="Convert Timetree events to iCal format", 84 | prog="timetree_exporter", 85 | ) 86 | parser.add_argument( 87 | "-o", 88 | "--output", 89 | type=str, 90 | help="Path to the output iCal file", 91 | default=os.path.join(os.getcwd(), "timetree.ics"), 92 | ) 93 | parser.add_argument( 94 | "-v", 95 | "--verbose", 96 | help="Increase output verbosity", 97 | action="store_true", 98 | ) 99 | parser.add_argument( 100 | "-e", 101 | "--email", 102 | type=str, 103 | help="Email address", 104 | default=None, 105 | ) 106 | parser.add_argument( 107 | "-c", 108 | "--calendar_code", 109 | type=str, 110 | help="The Calendar Code you want to export", 111 | default=None, 112 | ) 113 | parser.add_argument( 114 | "--version", 115 | action="version", 116 | version=f"%(prog)s {__version__}", 117 | ) 118 | args = parser.parse_args() 119 | 120 | if args.email: 121 | email = args.email 122 | elif os.environ.get("TIMETREE_EMAIL"): 123 | email = os.environ.get("TIMETREE_EMAIL") 124 | else: 125 | email = input("Enter your email address: ") 126 | 127 | if os.environ.get("TIMETREE_PASSWORD"): 128 | password = os.environ.get("TIMETREE_PASSWORD") 129 | else: 130 | password = safe_getpass(prompt="Enter your password: ", echo_char="*") 131 | 132 | # Set logging level 133 | if args.verbose: 134 | package_logger.setLevel(logging.DEBUG) 135 | 136 | # Set up calendar 137 | cal = Calendar() 138 | cal.add("prodid", f"-//TimeTree Exporter {version('timetree_exporter')}//EN") 139 | cal.add("version", "2.0") 140 | 141 | events = get_events(email, password, args.calendar_code) 142 | 143 | logger.info("Found %d events", len(events)) 144 | 145 | # Add events to calendar 146 | for event in events: 147 | time_tree_event = TimeTreeEvent.from_dict(event) 148 | formatter = ICalEventFormatter(time_tree_event) 149 | ical_event = formatter.to_ical() 150 | if ical_event is None: 151 | continue 152 | cal.add_component(ical_event) 153 | 154 | logger.info( 155 | "A total of %d/%d events are added to the calendar", 156 | len(cal.subcomponents), 157 | len(events), 158 | ) 159 | 160 | # Add the required timezone information 161 | cal.add_missing_timezones() 162 | 163 | # Write calendar to file 164 | with open(args.output, "wb") as f: # Path Traversal Vulnerability if on a server 165 | f.write(cal.to_ical()) 166 | logger.info( 167 | "The .ics calendar file is saved to %s", os.path.abspath(args.output) 168 | ) 169 | 170 | 171 | if __name__ == "__main__": 172 | main() 173 | -------------------------------------------------------------------------------- /timetree_exporter/formatter.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module provides the ICalEventFormatter class 3 | for formatting TimeTree events into iCalendar format. 4 | """ 5 | 6 | import logging 7 | from datetime import datetime, timedelta 8 | from zoneinfo import ZoneInfo 9 | from icalendar import Event, vRecur, vDate, vDatetime, vGeo, Alarm 10 | from icalendar.prop import vDDDLists 11 | from icalendar.parser import Contentline 12 | from timetree_exporter.event import ( 13 | TimeTreeEvent, 14 | TimeTreeEventType, 15 | TimeTreeEventCategory, 16 | ) 17 | from timetree_exporter.utils import convert_timestamp_to_datetime 18 | 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | class ICalEventFormatter: 24 | """ 25 | Class for formatting TimeTree events into iCalendar format. 26 | """ 27 | 28 | def __init__(self, time_tree_event: TimeTreeEvent): 29 | self.time_tree_event = time_tree_event 30 | 31 | @property 32 | def uid(self): 33 | """Return the UUID of the event.""" 34 | return self.time_tree_event.uuid 35 | 36 | @property 37 | def summary(self): 38 | """Return the title of the event.""" 39 | return self.time_tree_event.title 40 | 41 | @property 42 | def created(self): 43 | """Return the creation time of the event.""" 44 | return vDatetime( 45 | convert_timestamp_to_datetime( 46 | self.time_tree_event.created_at / 1000, ZoneInfo("UTC") 47 | ) 48 | ) 49 | 50 | @property 51 | def last_modified(self): 52 | """Return the last modification time of the event.""" 53 | return vDatetime( 54 | convert_timestamp_to_datetime( 55 | self.time_tree_event.updated_at / 1000, ZoneInfo("UTC") 56 | ) 57 | ) 58 | 59 | @property 60 | def description(self): 61 | """Return the note of the event.""" 62 | return self.time_tree_event.note if self.time_tree_event.note != "" else None 63 | 64 | @property 65 | def location(self): 66 | """Return the location of the event.""" 67 | return ( 68 | self.time_tree_event.location 69 | if self.time_tree_event.location != "" 70 | else None 71 | ) 72 | 73 | @property 74 | def geo(self): 75 | """Return the geolocation of the event.""" 76 | if ( 77 | self.time_tree_event.location_lat is None 78 | or self.time_tree_event.location_lon is None 79 | ): 80 | return None 81 | return vGeo( 82 | (self.time_tree_event.location_lat, self.time_tree_event.location_lon) 83 | ) 84 | 85 | @property 86 | def url(self): 87 | """Return the URL of the event.""" 88 | return self.time_tree_event.url if self.time_tree_event.url != "" else None 89 | 90 | @property 91 | def related_to(self): 92 | """Return the parent ID of the event.""" 93 | return self.time_tree_event.parent_id 94 | 95 | def get_datetime(self, is_start_time): 96 | """Return the start or end time of the event.""" 97 | if is_start_time: 98 | time = self.time_tree_event.start_at 99 | timezone = self.time_tree_event.start_timezone 100 | else: 101 | time = self.time_tree_event.end_at 102 | timezone = self.time_tree_event.end_timezone 103 | 104 | if self.time_tree_event.all_day: 105 | return vDate( 106 | convert_timestamp_to_datetime( 107 | time / 1000, 108 | ZoneInfo(timezone), 109 | ) 110 | ) 111 | return vDatetime( 112 | convert_timestamp_to_datetime( 113 | time / 1000, 114 | ZoneInfo(timezone), 115 | ), 116 | params={"TZID": timezone} if timezone != "UTC" else {}, 117 | ) 118 | 119 | @property 120 | def dtstart(self): 121 | """Return the start time of the event.""" 122 | return self.get_datetime(is_start_time=True) 123 | 124 | @property 125 | def dtend(self): 126 | """Return the end time of the event.""" 127 | return self.get_datetime(is_start_time=False) 128 | 129 | @property 130 | def alarms(self): 131 | """Return the alarms of the event.""" 132 | if self.time_tree_event.alerts is None: 133 | return [] 134 | alarms = [] 135 | for alert in self.time_tree_event.alerts: 136 | alarm = Alarm() 137 | alarm.add("action", "DISPLAY") 138 | alarm.add("description", "Reminder") 139 | alarm.add("trigger", timedelta(minutes=-alert)) 140 | alarms.append(alarm) 141 | return alarms 142 | 143 | def add_recurrences(self, event): 144 | """Add recurrences to iCal event""" 145 | if self.time_tree_event.recurrences is None: 146 | return 147 | for recurrence in self.time_tree_event.recurrences: 148 | contentline = Contentline(recurrence) 149 | name, parameters, value = contentline.parts() 150 | if name.lower() == "rrule": 151 | event.add(name, vRecur.from_ical(value), parameters) 152 | elif name.lower() == "exdate" or name.lower() == "rdate": 153 | event.add(name, vDDDLists.from_ical(value), parameters) 154 | else: 155 | logger.error("Unknown recurrence type: %s", name) 156 | raise ValueError(f"Unknown recurrence type: {name}") 157 | 158 | def to_ical(self) -> Event: 159 | """Return the iCal event.""" 160 | if ( 161 | self.time_tree_event.event_type == TimeTreeEventType.BIRTHDAY 162 | ): # Skip if event is a birthday 163 | logger.debug( 164 | "Skipping birthday event\n \ 165 | uid: %s \n \ 166 | summary: '%s' \n \ 167 | time: %s ~ %s \n \ 168 | ", 169 | self.uid, 170 | self.summary, 171 | self.dtstart.dt.strftime("%Y-%m-%d %H:%M:%S"), 172 | self.dtend.dt.strftime("%Y-%m-%d %H:%M:%S"), 173 | ) 174 | 175 | return None 176 | if self.time_tree_event.category == TimeTreeEventCategory.MEMO: 177 | # Skip if event is a memo 178 | logger.debug( 179 | "Skipping memo event\n \ 180 | uid: %s \n \ 181 | summary: '%s' \n \ 182 | time: %s ~ %s \n \ 183 | ", 184 | self.uid, 185 | self.summary, 186 | self.dtstart.dt.strftime("%Y-%m-%d %H:%M:%S"), 187 | self.dtend.dt.strftime("%Y-%m-%d %H:%M:%S"), 188 | ) 189 | 190 | return None 191 | 192 | event = Event() 193 | 194 | event.add("uid", self.uid) 195 | event.add("summary", self.summary) 196 | event.add("dtstamp", datetime.now(ZoneInfo("UTC"))) 197 | event.add("created", self.created) 198 | event.add("last-modified", self.last_modified) 199 | event.add("dtstart", self.dtstart) 200 | event.add("dtend", self.dtend) 201 | 202 | if self.location: 203 | event.add("location", self.location) 204 | if self.geo: 205 | event.add("geo", self.geo) 206 | if self.url: 207 | event.add("url", self.url) 208 | if self.description: 209 | event.add("description", self.description) 210 | if self.related_to: 211 | event.add("related-to", self.related_to) 212 | 213 | for alarm in self.alarms: 214 | event.add_component(alarm) 215 | 216 | self.add_recurrences(event) 217 | 218 | return event 219 | -------------------------------------------------------------------------------- /tests/test_formatter.py: -------------------------------------------------------------------------------- 1 | """Tests for the formatter module.""" 2 | 3 | from datetime import datetime, timedelta 4 | from zoneinfo import ZoneInfo 5 | 6 | 7 | from icalendar import Event 8 | from icalendar.prop import vDuration 9 | from timetree_exporter.event import ( 10 | TimeTreeEvent, 11 | TimeTreeEventType, 12 | ) 13 | from timetree_exporter.formatter import ICalEventFormatter 14 | from timetree_exporter.utils import convert_timestamp_to_datetime 15 | 16 | 17 | def test_formatter_properties(normal_event_data): 18 | """Test the properties of the ICalEventFormatter.""" 19 | event = TimeTreeEvent.from_dict(normal_event_data) 20 | formatter = ICalEventFormatter(event) 21 | 22 | assert formatter.uid == normal_event_data["uuid"] 23 | assert formatter.summary == normal_event_data["title"] 24 | assert formatter.description == normal_event_data["note"] 25 | assert formatter.location == normal_event_data["location"] 26 | assert formatter.url == normal_event_data["url"] 27 | assert formatter.related_to == normal_event_data["parent_id"] 28 | 29 | # Test created time 30 | created_dt = convert_timestamp_to_datetime( 31 | normal_event_data["created_at"] / 1000, ZoneInfo("UTC") 32 | ) 33 | assert formatter.created.dt == created_dt 34 | 35 | # Test last modified time 36 | modified_dt = convert_timestamp_to_datetime( 37 | normal_event_data["updated_at"] / 1000, ZoneInfo("UTC") 38 | ) 39 | assert formatter.last_modified.dt == modified_dt 40 | 41 | # Test geo 42 | assert formatter.geo.latitude == float(normal_event_data["location_lat"]) 43 | assert formatter.geo.longitude == float(normal_event_data["location_lon"]) 44 | 45 | # Test datetime properties 46 | start_dt = convert_timestamp_to_datetime( 47 | normal_event_data["start_at"] / 1000, 48 | ZoneInfo(normal_event_data["start_timezone"]), 49 | ) 50 | end_dt = convert_timestamp_to_datetime( 51 | normal_event_data["end_at"] / 1000, ZoneInfo(normal_event_data["end_timezone"]) 52 | ) 53 | assert formatter.dtstart.dt == start_dt 54 | assert formatter.dtend.dt == end_dt 55 | 56 | # Test alarms 57 | alarms = formatter.alarms 58 | assert len(alarms) == 2 59 | assert alarms[0]["action"] == "DISPLAY" 60 | assert alarms[0]["description"] == "Reminder" 61 | assert alarms[0]["trigger"] == vDuration(timedelta(minutes=-15)) 62 | assert alarms[1]["action"] == "DISPLAY" 63 | assert alarms[1]["description"] == "Reminder" 64 | assert alarms[1]["trigger"] == vDuration(timedelta(minutes=-60)) 65 | 66 | 67 | def test_to_ical_normal_event(normal_event_data): 68 | """Test converting a normal TimeTreeEvent to an iCal event.""" 69 | event = TimeTreeEvent.from_dict(normal_event_data) 70 | formatter = ICalEventFormatter(event) 71 | ical_event = formatter.to_ical() 72 | 73 | assert isinstance(ical_event, Event) 74 | assert ical_event["uid"] == normal_event_data["uuid"] 75 | assert ical_event["summary"] == normal_event_data["title"] 76 | assert ical_event["description"] == normal_event_data["note"] 77 | assert ical_event["location"] == normal_event_data["location"] 78 | assert ical_event["url"] == normal_event_data["url"] 79 | assert ical_event["related-to"] == normal_event_data["parent_id"] 80 | 81 | # Verify that the event has alarms 82 | components = list(ical_event.subcomponents) 83 | assert len(components) == 2 # Two alarms 84 | assert all(component.name == "VALARM" for component in components) 85 | 86 | # Verify recurrence rule 87 | assert "RRULE" in ical_event 88 | assert ical_event["RRULE"]["FREQ"] == ["WEEKLY"] 89 | assert ical_event["RRULE"]["COUNT"] == [5] 90 | 91 | 92 | def test_to_ical_birthday_event(birthday_event_data): 93 | """Test converting a birthday TimeTreeEvent to an iCal event.""" 94 | event = TimeTreeEvent.from_dict(birthday_event_data) 95 | formatter = ICalEventFormatter(event) 96 | ical_event = formatter.to_ical() 97 | 98 | # Birthday events should be skipped 99 | assert ical_event is None 100 | 101 | 102 | def test_to_ical_memo_event(memo_event_data): 103 | """Test converting a memo TimeTreeEvent to an iCal event.""" 104 | event = TimeTreeEvent.from_dict(memo_event_data) 105 | formatter = ICalEventFormatter(event) 106 | ical_event = formatter.to_ical() 107 | 108 | # Memo events should be skipped 109 | assert ical_event is None 110 | 111 | 112 | def test_all_day_event(birthday_event_data): 113 | """Test formatting an all-day event.""" 114 | # Modify to a normal event that's all-day (not a birthday) 115 | all_day_data = birthday_event_data.copy() 116 | all_day_data["type"] = TimeTreeEventType.NORMAL 117 | 118 | event = TimeTreeEvent.from_dict(all_day_data) 119 | formatter = ICalEventFormatter(event) 120 | 121 | # Test that dtstart and dtend use vDate instead of vDatetime for all-day events 122 | 123 | assert isinstance(formatter.dtstart.dt, datetime) 124 | assert ( 125 | formatter.dtstart.dt.date() 126 | == convert_timestamp_to_datetime( 127 | all_day_data["start_at"] / 1000, ZoneInfo(all_day_data["start_timezone"]) 128 | ).date() 129 | ) 130 | 131 | # Check to_ical produces a valid event 132 | ical_event = formatter.to_ical() 133 | assert isinstance(ical_event, Event) 134 | assert ical_event["summary"] == all_day_data["title"] 135 | 136 | 137 | def test_no_alarms_location_url(normal_event_data): 138 | """Test event formatting without optional fields.""" 139 | # Create an event without alarms, location, and URL 140 | data = normal_event_data.copy() 141 | data["alerts"] = None 142 | data["location"] = "" 143 | data["location_lat"] = None 144 | data["location_lon"] = None 145 | data["url"] = "" 146 | 147 | event = TimeTreeEvent.from_dict(data) 148 | formatter = ICalEventFormatter(event) 149 | 150 | assert not formatter.alarms 151 | assert formatter.location is None 152 | assert formatter.geo is None 153 | assert formatter.url is None 154 | 155 | # Check the iCal event 156 | ical_event = formatter.to_ical() 157 | assert "location" not in ical_event 158 | assert "geo" not in ical_event 159 | assert "url" not in ical_event 160 | 161 | # Verify no alarms were added 162 | components = list(ical_event.subcomponents) 163 | assert len(components) == 0 164 | 165 | 166 | def test_different_timezones(normal_event_data): 167 | """Test event with different start and end timezones.""" 168 | # Create an event with different start and end timezones 169 | data = normal_event_data.copy() 170 | data["start_timezone"] = "America/New_York" # EDT/EST 171 | data["end_timezone"] = "Asia/Tokyo" # JST 172 | 173 | # Set specific timestamps 174 | ny_time = datetime(2023, 6, 15, 10, 0, 0, tzinfo=ZoneInfo("America/New_York")) 175 | data["start_at"] = int(ny_time.timestamp() * 1000) 176 | 177 | tokyo_time = datetime(2023, 6, 16, 8, 0, 0, tzinfo=ZoneInfo("Asia/Tokyo")) 178 | data["end_at"] = int(tokyo_time.timestamp() * 1000) 179 | 180 | event = TimeTreeEvent.from_dict(data) 181 | formatter = ICalEventFormatter(event) 182 | 183 | # Test timezone properties are correctly set 184 | assert formatter.dtstart.dt.tzinfo.key == "America/New_York" 185 | assert formatter.dtend.dt.tzinfo.key == "Asia/Tokyo" 186 | 187 | # Verify the actual datetime values 188 | assert formatter.dtstart.dt == ny_time 189 | assert formatter.dtend.dt == tokyo_time 190 | 191 | # Convert both to UTC for comparison of the actual time (not just representation) 192 | start_utc = formatter.dtstart.dt.astimezone(ZoneInfo("UTC")) 193 | end_utc = formatter.dtend.dt.astimezone(ZoneInfo("UTC")) 194 | 195 | # Verify the time difference is preserved 196 | # Tokyo time is 13 hours ahead of New York during EDT 197 | expected_hours_diff = ( 198 | tokyo_time.astimezone(ZoneInfo("UTC")) - ny_time.astimezone(ZoneInfo("UTC")) 199 | ).total_seconds() / 3600 200 | actual_hours_diff = (end_utc - start_utc).total_seconds() / 3600 201 | assert actual_hours_diff == expected_hours_diff 202 | 203 | # Check iCal event preserves timezone information 204 | ical_event = formatter.to_ical() 205 | assert ical_event["dtstart"].dt.tzinfo.key == "America/New_York" 206 | assert ical_event["dtend"].dt.tzinfo.key == "Asia/Tokyo" 207 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.6.2](https://github.com/eoleedi/TimeTree-Exporter/compare/v0.6.1...v0.6.2) (2025-12-15) 4 | 5 | 6 | ### Documentation 7 | 8 | * remove deprecated sync --dev option ([3e65c7a](https://github.com/eoleedi/TimeTree-Exporter/commit/3e65c7aa25c9cf5bf6dd563f5b0390d660e69ded)) 9 | 10 | 11 | ### Miscellaneous Chores 12 | 13 | * bootstrap releases for path: . ([#139](https://github.com/eoleedi/TimeTree-Exporter/issues/139)) ([b4aef03](https://github.com/eoleedi/TimeTree-Exporter/commit/b4aef03681f7c82e59c5bf3ae2ab1968134a466b)) 14 | 15 | 16 | ### Code Refactoring 17 | 18 | * make TimeTreeEvent to dataclass ([f74217f](https://github.com/eoleedi/TimeTree-Exporter/commit/f74217f6a4be5e091ad973db89ac2ab15198817d)) 19 | 20 | 21 | ### Build System 22 | 23 | * **deps-dev:** bump black from 25.1.0 to 25.9.0 ([5b2de05](https://github.com/eoleedi/TimeTree-Exporter/commit/5b2de05f8e5a0405ac54a404b11f3fef91fca7f2)) 24 | * **deps-dev:** bump pre-commit from 4.2.0 to 4.3.0 ([b70cfd1](https://github.com/eoleedi/TimeTree-Exporter/commit/b70cfd14cb054a340e35b5e9dbeabf8bbc4a2d29)) 25 | * **deps-dev:** bump pylint from 3.3.7 to 3.3.8 ([#117](https://github.com/eoleedi/TimeTree-Exporter/issues/117)) ([03efbcd](https://github.com/eoleedi/TimeTree-Exporter/commit/03efbcdc0c42b018240ea78580f28ed753460d0e)) 26 | * **deps-dev:** bump pylint from 3.3.8 to 3.3.9 ([82122f4](https://github.com/eoleedi/TimeTree-Exporter/commit/82122f4869b1c19e6354a91ca8abe4b00222612f)) 27 | * **deps-dev:** bump pytest from 8.4.1 to 8.4.2 ([b8cbb72](https://github.com/eoleedi/TimeTree-Exporter/commit/b8cbb721328d65888a0fd128c5b8ad651c671fe4)) 28 | * **deps-dev:** bump pytest-cov from 6.2.1 to 7.0.0 ([f4ffae1](https://github.com/eoleedi/TimeTree-Exporter/commit/f4ffae1604c2a162f455bdfba8dbd987b345df80)) 29 | * **deps:** bump actions/checkout from 4 to 5 ([#118](https://github.com/eoleedi/TimeTree-Exporter/issues/118)) ([a2b8abc](https://github.com/eoleedi/TimeTree-Exporter/commit/a2b8abc09d05019c5aed3f88ac3b40f81ec31721)) 30 | * **deps:** bump actions/checkout from 5 to 6 ([2947185](https://github.com/eoleedi/TimeTree-Exporter/commit/2947185ebfef6bb91d91277bd2f5a5b85c997d4e)) 31 | * **deps:** bump actions/setup-python from 5 to 6 ([97a064a](https://github.com/eoleedi/TimeTree-Exporter/commit/97a064ab5b6ca76a21058a4e572957ec1099bc2b)) 32 | * **deps:** bump dawidd6/action-homebrew-bump-formula from 5 to 7 ([7b574eb](https://github.com/eoleedi/TimeTree-Exporter/commit/7b574ebb4c84dd8843e1edc74aaa41fb2712b75c)) 33 | * **deps:** bump pypa/gh-action-pypi-publish ([#121](https://github.com/eoleedi/TimeTree-Exporter/issues/121)) ([443e2bd](https://github.com/eoleedi/TimeTree-Exporter/commit/443e2bdee23d3e44ce4c16a0f238c19057c6da94)) 34 | * **deps:** bump python testing and linting version 3.14 from beta to stable ([c293e33](https://github.com/eoleedi/TimeTree-Exporter/commit/c293e333355b975389a63c2699599a911ce2870d)) 35 | * **deps:** bump requests from 2.32.4 to 2.32.5 ([#119](https://github.com/eoleedi/TimeTree-Exporter/issues/119)) ([ef1ecde](https://github.com/eoleedi/TimeTree-Exporter/commit/ef1ecde2a041dec95480e3eccd639324a26505ef)) 36 | * migrate to uv as package management ([#136](https://github.com/eoleedi/TimeTree-Exporter/issues/136)) ([596c3c5](https://github.com/eoleedi/TimeTree-Exporter/commit/596c3c5cedc79dbb77242d1631f24f01ef91c906)) 37 | * use PEP639 format for license ([1129ccf](https://github.com/eoleedi/TimeTree-Exporter/commit/1129ccff594973b8de498bd314aec87da709f12b)) 38 | 39 | ## [0.6.1](https://github.com/eoleedi/TimeTree-Exporter/compare/v0.6.0...v0.6.1) (2025-07-03) 40 | 41 | 42 | ### Bug Fixes 43 | 44 | * add missing VTimeZone cal component & vDatetime TZID param ([#113](https://github.com/eoleedi/TimeTree-Exporter/issues/113)) ([6ea8293](https://github.com/eoleedi/TimeTree-Exporter/commit/6ea8293a1d4f4317da0d52870ad15f300c76bfe5)) 45 | * add the missing required prodid and version property ([#112](https://github.com/eoleedi/TimeTree-Exporter/issues/112)) ([7d43e0a](https://github.com/eoleedi/TimeTree-Exporter/commit/7d43e0a978176c9873f85ced059b22ae67be5a36)) 46 | 47 | ## [0.6.0](https://github.com/eoleedi/TimeTree-Exporter/compare/v0.5.1...v0.6.0) (2025-06-29) 48 | 49 | 50 | ### Features 51 | 52 | * password with echo ([f3c6841](https://github.com/eoleedi/TimeTree-Exporter/commit/f3c6841b4362a24f8bfcda349a60bd4db7560be2)) 53 | 54 | 55 | ### Bug Fixes 56 | 57 | * run coverage with poetry ([8d7753d](https://github.com/eoleedi/TimeTree-Exporter/commit/8d7753d75cd60a86ce5f653ad5635a24235cf171)) 58 | * use 3.14.0-beta.3 on lint and test ([7a1a31c](https://github.com/eoleedi/TimeTree-Exporter/commit/7a1a31cf7a05eb5060aeb82d5c41740c8886257e)) 59 | 60 | ## [0.5.1](https://github.com/eoleedi/TimeTree-Exporter/compare/v0.5.0...v0.5.1) (2025-04-10) 61 | 62 | 63 | ### Miscellaneous Chores 64 | 65 | * release 0.5.1 (timestamp hotfix) ([d2222eb](https://github.com/eoleedi/TimeTree-Exporter/commit/d2222eb48397275f08e3dc1182933933638b7c22)) 66 | 67 | ## [0.5.0](https://github.com/eoleedi/TimeTree-Exporter/compare/v0.4.1...v0.5.0) (2025-04-10) 68 | 69 | 70 | ### Features 71 | 72 | * support calendar code input (for automations) ([#83](https://github.com/eoleedi/TimeTree-Exporter/issues/83)) ([163be70](https://github.com/eoleedi/TimeTree-Exporter/commit/163be70d2b109cb3b9754d09738a847e1f8c65b3)) 73 | * support passing credential with environment variables (for automations) ([#82](https://github.com/eoleedi/TimeTree-Exporter/issues/82)) ([bb701f4](https://github.com/eoleedi/TimeTree-Exporter/commit/bb701f46179c01c728b2c51e82e2bae1b9143ba0)) 74 | 75 | 76 | ### Bug Fixes 77 | 78 | * migrate from eoleedi/timetree-exporter to eoleedi/tap ([#78](https://github.com/eoleedi/TimeTree-Exporter/issues/78)) ([ea5a0a8](https://github.com/eoleedi/TimeTree-Exporter/commit/ea5a0a8986a5072ae2bb2c9b09341336110488de)) 79 | * poetry --without dev ([#84](https://github.com/eoleedi/TimeTree-Exporter/issues/84)) ([d7e7d05](https://github.com/eoleedi/TimeTree-Exporter/commit/d7e7d05dca55f54c5be8b1dca8a66413832337de)) 80 | 81 | 82 | ### Documentation 83 | 84 | * add homebrew installation method and improve clarity ([49a85ef](https://github.com/eoleedi/TimeTree-Exporter/commit/49a85ef86b202c1dbd735eca73b4ec658f07b3b8)) 85 | * add informative badges ([d7d0875](https://github.com/eoleedi/TimeTree-Exporter/commit/d7d0875d512b122a9bb5cd0b3add3f83608b9ef3)) 86 | * ignore row order as it's a property for timetree notes ([3fdb415](https://github.com/eoleedi/TimeTree-Exporter/commit/3fdb4157e9e4cf5f307476fd0d18ad99ed9fdc29)) 87 | * improve readability ([fdf7281](https://github.com/eoleedi/TimeTree-Exporter/commit/fdf72817529bdbbf25578cb5175dad3aef824dbf)) 88 | * more badges ([d0c90cf](https://github.com/eoleedi/TimeTree-Exporter/commit/d0c90cfd955b00f0efec4cbd49b04437949043d7)) 89 | 90 | ## [0.4.1](https://github.com/eoleedi/TimeTree-Exporter/compare/v0.4.0...v0.4.1) (2024-12-01) 91 | 92 | 93 | ### Bug Fixes 94 | 95 | * add abbr option -e for --email ([344b959](https://github.com/eoleedi/TimeTree-Exporter/commit/344b959c351ae8c2cb0cc922b80330be80ab4145)) 96 | 97 | ## [0.4.0](https://github.com/eoleedi/TimeTree-Exporter/compare/v0.3.1...v0.4.0) (2024-12-01) 98 | 99 | 100 | ### Features 101 | 102 | * automate login and fetch ([8c076e4](https://github.com/eoleedi/TimeTree-Exporter/commit/8c076e4426cf419a0ffb71d1bce41542cbfa695e)) 103 | * pass email by param ([cd549d2](https://github.com/eoleedi/TimeTree-Exporter/commit/cd549d2f947e0eb1818c2bad6ca3078a792d2c5d)) 104 | 105 | 106 | ### Bug Fixes 107 | 108 | * check alarms and recurrences none & add support for public calendar ([65bbcfc](https://github.com/eoleedi/TimeTree-Exporter/commit/65bbcfc2a3668a29b26825c4eb4fd29ae2a7ef1c)) 109 | * default saving path to current working directory ([b909a0c](https://github.com/eoleedi/TimeTree-Exporter/commit/b909a0c5a7f34f58fdd5ca7a5f66388381069925)) 110 | * filter out deactivated calendars ([c7bdf90](https://github.com/eoleedi/TimeTree-Exporter/commit/c7bdf90b71c6bcfc189ba9b95fe00dd08acc2b5b)) 111 | * improve login stability ([bf978bc](https://github.com/eoleedi/TimeTree-Exporter/commit/bf978bc1575236f4a903682b5f524d6931b2f801)) 112 | * remove irrelavent info of cal id ([618b233](https://github.com/eoleedi/TimeTree-Exporter/commit/618b233e78167c983b88c6ac21e71f3a90d7732a)) 113 | * return None if events can't be found ([b34b136](https://github.com/eoleedi/TimeTree-Exporter/commit/b34b1362f1010c578491816cd027f02f1d012d43)) 114 | * typo, last-modify should be last-modified ([2f77e92](https://github.com/eoleedi/TimeTree-Exporter/commit/2f77e925ea7b755b7be17534263c5bfbd6058ee9)) 115 | * use Union for python 3.9 compatability ([fa37615](https://github.com/eoleedi/TimeTree-Exporter/commit/fa37615a1d15cc50be9841a7a9e86912e3398d95)) 116 | 117 | 118 | ### Reverts 119 | 120 | * support public calendar events ([c9b75ba](https://github.com/eoleedi/TimeTree-Exporter/commit/c9b75bad8b25d9e958b3705445689177c7bee144)) 121 | 122 | 123 | ### Documentation 124 | 125 | * remove requirements as it is specified in requirements.txt ([507922e](https://github.com/eoleedi/TimeTree-Exporter/commit/507922eb6226c4fbb2e109b949e8c9503dc3546b)) 126 | * update README ([feaba4d](https://github.com/eoleedi/TimeTree-Exporter/commit/feaba4d1925aa4cc8883f54df8cc2829f41cb678)) 127 | 128 | ## [0.3.1](https://github.com/eoleedi/TimeTree-Exporter/compare/v0.3.0...v0.3.1) (2024-07-17) 129 | 130 | 131 | ### Bug Fixes 132 | 133 | * alerts are not properly implemented (fix [#39](https://github.com/eoleedi/TimeTree-Exporter/issues/39)) ([#41](https://github.com/eoleedi/TimeTree-Exporter/issues/41)) ([20859de](https://github.com/eoleedi/TimeTree-Exporter/commit/20859dec779bd397799ad3b7ff27667d94aa4836)) 134 | * use zoneinfo instead of dateutil.tz to solve TZID=CEST not recognized by google cal issue ([fbae55e](https://github.com/eoleedi/TimeTree-Exporter/commit/fbae55ea49f1f4889afa04f0fbbd35c794017996)) 135 | 136 | 137 | ### Documentation 138 | 139 | * formatting ([f53a1ae](https://github.com/eoleedi/TimeTree-Exporter/commit/f53a1ae421ef620bbfcbee361fa34062f9945a68)) 140 | 141 | ## [0.3.0](https://github.com/eoleedi/TimeTree-Exporter/compare/v0.2.3...v0.3.0) (2024-04-20) 142 | 143 | 144 | ### Features 145 | 146 | * parse category and skip memo ([#33](https://github.com/eoleedi/TimeTree-Exporter/issues/33)) ([0166f6f](https://github.com/eoleedi/TimeTree-Exporter/commit/0166f6f53284927b89a9a830e830f9d8318877e9)) 147 | 148 | 149 | ### Bug Fixes 150 | 151 | * add __version__ attribute in the package ([d3ed38f](https://github.com/eoleedi/TimeTree-Exporter/commit/d3ed38f67cf73c9f15025f2078d5454b4c372132)) 152 | 153 | 154 | ### Reverts 155 | 156 | * add version in attribute in package ([ec0960b](https://github.com/eoleedi/TimeTree-Exporter/commit/ec0960b686b8e290209f89427a4d815911ac139b)) 157 | 158 | ## [0.2.3](https://github.com/eoleedi/TimeTree-exporter/compare/v0.2.2...v0.2.3) (2024-04-10) 159 | 160 | 161 | ### Documentation 162 | 163 | * move assets into docs folder ([2931fb2](https://github.com/eoleedi/TimeTree-exporter/commit/2931fb212f2e78f89ba849ee6510b237c5372db3)) 164 | 165 | ## [0.2.2](https://github.com/eoleedi/TimeTree-exporter/compare/v0.2.1...v0.2.2) (2024-04-09) 166 | 167 | 168 | ### Bug Fixes 169 | 170 | * define main function in __main__.py to match the script ([#28](https://github.com/eoleedi/TimeTree-exporter/issues/28)) ([24cceba](https://github.com/eoleedi/TimeTree-exporter/commit/24ccebafee8198f8acb0862b722c0c63182bd845)) 171 | 172 | 173 | ### Documentation 174 | 175 | * update Changelog's URL ([05e9d58](https://github.com/eoleedi/TimeTree-exporter/commit/05e9d58282cd9657d749aaea542dc3b13554f401)) 176 | 177 | ## [0.2.1](https://github.com/eoleedi/TimeTree-exporter/compare/v0.2.0...v0.2.1) (2024-04-09) 178 | 179 | 180 | ### Bug Fixes 181 | 182 | * use now as dtstamp ([#24](https://github.com/eoleedi/TimeTree-exporter/issues/24)) ([36c2d23](https://github.com/eoleedi/TimeTree-exporter/commit/36c2d2392bf964de9c8823b23b24f8802162923b)) 183 | 184 | ## [0.2.0](https://github.com/eoleedi/TimeTree-exporter/compare/v0.1.0...v0.2.0) (2024-04-09) 185 | 186 | 187 | ### Features 188 | 189 | * map parent_id to iCal's RELATED-TO ([#15](https://github.com/eoleedi/TimeTree-exporter/issues/15)) ([6780cbe](https://github.com/eoleedi/TimeTree-exporter/commit/6780cbea0d907135605a30363ccdf5b7ea467b47)) 190 | 191 | 192 | ### Bug Fixes 193 | 194 | * accept negative timestamp on all platform ([#23](https://github.com/eoleedi/TimeTree-exporter/issues/23)) ([f2bf2f7](https://github.com/eoleedi/TimeTree-exporter/commit/f2bf2f7c342275f3beb3a3af3406c063929efab2)) 195 | * Discard TimeTree's Birthday Event ([#17](https://github.com/eoleedi/TimeTree-exporter/issues/17)) ([aa407ba](https://github.com/eoleedi/TimeTree-exporter/commit/aa407ba468e8f1396fd75373094aec3535ffbeb5)) 196 | 197 | ## 0.1.0 (2024-04-06) 198 | 199 | 200 | ### Features 201 | 202 | * argparse ([#12](https://github.com/eoleedi/TimeTree-exporter/issues/12)) ([1eae588](https://github.com/eoleedi/TimeTree-exporter/commit/1eae588f96e462dc12f9c5998c88b5582c25e0d5)) 203 | * created & last-modify ([fab8d76](https://github.com/eoleedi/TimeTree-exporter/commit/fab8d76c380c175cc4b7e8cba6fbc740bafe31f6)) 204 | * first commit - basic title, time, and alert function ([bac0eca](https://github.com/eoleedi/TimeTree-exporter/commit/bac0ecab5f9d778f9e5113c988cbbcf024367600)) 205 | * geo (location lat & lon) ([3547100](https://github.com/eoleedi/TimeTree-exporter/commit/3547100430ab817aea98937e6e8ab4e3cc33fea3)) 206 | * note and url ([61adb88](https://github.com/eoleedi/TimeTree-exporter/commit/61adb887f35d1d456b610a6ac19bcf35b5b96438)) 207 | * recurrences ([5c37911](https://github.com/eoleedi/TimeTree-exporter/commit/5c37911b584ba022f2114340612ee5572d8ec265)) 208 | 209 | 210 | ### Bug Fixes 211 | 212 | * full day event & timezone issue ([41467ab](https://github.com/eoleedi/TimeTree-exporter/commit/41467ab0942c8a5ded425bbe73ca44de62481d56)) 213 | * write to file after parsing all the files ([bc0fd7f](https://github.com/eoleedi/TimeTree-exporter/commit/bc0fd7f20c12410cf2e548b4c419f89a775a5845)) 214 | 215 | 216 | ### Documentation 217 | 218 | * add detailed description & remove the API limitation ([edeea3a](https://github.com/eoleedi/TimeTree-exporter/commit/edeea3aacfa64acaf5479912350c219941845702)) 219 | * add detailed instructions and info ([70c1e89](https://github.com/eoleedi/TimeTree-exporter/commit/70c1e89ec8a6b7172919f02c37ca54964953f911)) 220 | * add newline ([cc5275a](https://github.com/eoleedi/TimeTree-exporter/commit/cc5275a33a01bf1c67db22ed01b5e7402fcf17c2)) 221 | * add recommendation and remove in-development warning ([776d5f2](https://github.com/eoleedi/TimeTree-exporter/commit/776d5f271b8127c724f8d3be03e54e1ab41e52b1)) 222 | * bump icalendar from 5.0.11 to 5.0.12 ([a934c7b](https://github.com/eoleedi/TimeTree-exporter/commit/a934c7bdc53b8206ef7e37af7af3a0585a5d0abc)) 223 | * finish all day ([e23021e](https://github.com/eoleedi/TimeTree-exporter/commit/e23021e24cc9f038bdc070eea530f331bb3e1fde)) 224 | * fix typo ([248b669](https://github.com/eoleedi/TimeTree-exporter/commit/248b669c7027f37035778385d38902ec569ddf70)) 225 | * update document for pip installation method ([83cfaea](https://github.com/eoleedi/TimeTree-exporter/commit/83cfaea4ec55ad38836e9cd7c11896343b1915f9)) 226 | * use absolute path for images ([0bf9e33](https://github.com/eoleedi/TimeTree-exporter/commit/0bf9e33da0e2afe8ae84b085e07357b47ade1080)) 227 | --------------------------------------------------------------------------------