Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,67 @@ def memo_event_data():
}


@pytest.fixture
def labeled_event_data():
"""Fixture for event data with a label_id."""
return {
"uuid": "test-uuid-labeled",
"title": "測試有標籤活動",
"created_at": 1713110000000,
"updated_at": 1713110100000,
"note": "",
"location": "",
"location_lat": None,
"location_lon": None,
"url": "",
"start_at": 1713120000000,
"start_timezone": "Asia/Taipei",
"end_at": 1713123600000,
"end_timezone": "Asia/Taipei",
"all_day": False,
"alerts": None,
"recurrences": None,
"parent_id": "",
"type": TimeTreeEventType.NORMAL,
"category": TimeTreeEventCategory.NORMAL,
"label_id": 3,
}


@pytest.fixture
def relationship_label_event_data():
"""Fixture for event data with label in relationships format."""
return {
"uuid": "test-uuid-rel-label",
"title": "測試關係標籤活動",
"created_at": 1713110000000,
"updated_at": 1713110100000,
"note": "",
"location": "",
"location_lat": None,
"location_lon": None,
"url": "",
"start_at": 1713120000000,
"start_timezone": "Asia/Taipei",
"end_at": 1713123600000,
"end_timezone": "Asia/Taipei",
"all_day": False,
"alerts": None,
"recurrences": None,
"parent_id": "",
"type": TimeTreeEventType.NORMAL,
"category": TimeTreeEventCategory.NORMAL,
"relationships": {
"label": {
"data": {
"id": "12345,7",
"type": "label",
}
}
},
}


@pytest.fixture
def temp_event_file():
"""Create a temporary file with event data for testing."""
Expand Down
18 changes: 18 additions & 0 deletions tests/test_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,21 @@ def test_event_categories():
"""Test the TimeTreeEventCategory enumeration."""
assert TimeTreeEventCategory.NORMAL == 1
assert TimeTreeEventCategory.MEMO == 2


def test_from_dict_with_label_id(labeled_event_data):
"""Test creating a TimeTreeEvent with a direct label_id."""
event = TimeTreeEvent.from_dict(labeled_event_data)
assert event.label_id == 3


def test_from_dict_with_relationship_label(relationship_label_event_data):
"""Test creating a TimeTreeEvent with label in relationships format."""
event = TimeTreeEvent.from_dict(relationship_label_event_data)
assert event.label_id == 7


def test_from_dict_without_label(normal_event_data):
"""Test that label_id is None when no label data is present."""
event = TimeTreeEvent.from_dict(normal_event_data)
assert event.label_id is None
21 changes: 21 additions & 0 deletions tests/test_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,27 @@ def test_no_alarms_location_url(normal_event_data):
assert len(components) == 0


def test_categories_with_label_name(normal_event_data):
"""Test that CATEGORIES is set when label_name is provided."""
event = TimeTreeEvent.from_dict(normal_event_data)
formatter = ICalEventFormatter(event, label_name="Work")
assert formatter.categories == "Work"

ical_event = formatter.to_ical()
assert "CATEGORIES" in ical_event
assert ical_event["CATEGORIES"].cats == ["Work"]


def test_categories_without_label_name(normal_event_data):
"""Test that CATEGORIES is not set when label_name is None."""
event = TimeTreeEvent.from_dict(normal_event_data)
formatter = ICalEventFormatter(event)
assert formatter.categories is None

ical_event = formatter.to_ical()
assert "CATEGORIES" not in ical_event


def test_different_timezones(normal_event_data):
"""Test event with different start and end timezones."""
# Create an event with different start and end timezones
Expand Down
161 changes: 131 additions & 30 deletions timetree_exporter/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import argparse
import logging
import os
import re
from collections import defaultdict
from importlib.metadata import version
from icalendar import Calendar
from timetree_exporter import TimeTreeEvent, ICalEventFormatter, __version__
Expand All @@ -16,8 +18,8 @@
package_logger = logging.getLogger(__package__)


def get_events(email: str, password: str, calendar_code: str):
"""Get events from the Timetree API."""
def select_calendar(email: str, password: str, calendar_code: str):
"""Authenticate and select a calendar. Returns (calendar_api, calendar_id, calendar_name)."""
use_code = bool(calendar_code)
session_id = login(email, password)
calendar = TimeTreeCalendar(session_id)
Expand Down Expand Up @@ -69,13 +71,41 @@ def get_events(email: str, password: str, calendar_code: str):
idx = int(calendar_num) - 1
metadata = metadatas[idx]

# Get events from the selected calendar
calendar_id = metadata["id"]
calendar_name = metadata["name"]
return calendar, metadata["id"], metadata["name"]


def get_events(email: str, password: str, calendar_code: str):
"""Get events from the Timetree API."""
calendar, calendar_id, calendar_name = select_calendar(
email, password, calendar_code
)
return calendar.get_events(calendar_id, calendar_name)


def create_calendar():
"""Create a new iCalendar object with standard properties."""
cal = Calendar()
cal.add("prodid", f"-//TimeTree Exporter {version('timetree_exporter')}//EN")
cal.add("version", "2.0")
return cal


def write_calendar(cal, output_path):
"""Write a calendar to an .ics file."""
cal.add_missing_timezones()
with open(output_path, "wb") as f:
f.write(cal.to_ical())
logger.info(
"The .ics calendar file is saved to %s", os.path.abspath(output_path)
)


def sanitize_filename(name):
"""Sanitize a string for use as a filename component."""
# Replace any non-alphanumeric characters (except hyphen/underscore) with underscore
return re.sub(r"[^\w\-]", "_", name).strip("_")


def main():
"""Main function for the Timetree Exporter."""
# Parse arguments
Expand Down Expand Up @@ -115,6 +145,16 @@ def main():
action="version",
version=f"%(prog)s {__version__}",
)
parser.add_argument(
"--list-labels",
help="List labels for the selected calendar and exit",
action="store_true",
)
parser.add_argument(
"--split-by-label",
help="Export events into separate .ics files grouped by label",
action="store_true",
)
args = parser.parse_args()

if args.email:
Expand All @@ -133,39 +173,100 @@ def main():
if args.verbose:
package_logger.setLevel(logging.DEBUG)

# Set up calendar
cal = Calendar()
cal.add("prodid", f"-//TimeTree Exporter {version('timetree_exporter')}//EN")
cal.add("version", "2.0")
calendar_api, calendar_id, calendar_name = select_calendar(
email, password, args.calendar_code
)

events = get_events(email, password, args.calendar_code)
# --list-labels: print labels and exit
if args.list_labels:
labels = calendar_api.get_labels(calendar_id)
if not labels:
print("No labels found (the API response format may differ — use -v to debug)")
else:
for i, (_label_id, label_info) in enumerate(labels.items(), 1):
print(f"{i}. {label_info['name']} ({label_info['color']})")
return

events = calendar_api.get_events(calendar_id, calendar_name)
logger.info("Found %d events", len(events))

# Add events to calendar
for event in events:
time_tree_event = TimeTreeEvent.from_dict(event)
formatter = ICalEventFormatter(time_tree_event)
ical_event = formatter.to_ical()
if ical_event is None:
continue
cal.add_component(ical_event)

logger.info(
"A total of %d/%d events are added to the calendar",
len(cal.subcomponents),
len(events),
)
# Fetch labels if splitting by label
labels = {}
if args.split_by_label:
labels = calendar_api.get_labels(calendar_id)
logger.info("Found %d labels", len(labels))

# Add the required timezone information
cal.add_missing_timezones()
if args.split_by_label:
# Group events by label_id
grouped = defaultdict(list)
for event in events:
time_tree_event = TimeTreeEvent.from_dict(event)
formatter_label_name = None
group_key = None

# Write calendar to file
with open(args.output, "wb") as f: # Path Traversal Vulnerability if on a server
f.write(cal.to_ical())
if time_tree_event.label_id is not None and time_tree_event.label_id in labels:
label_info = labels[time_tree_event.label_id]
formatter_label_name = label_info["name"]
group_key = time_tree_event.label_id
else:
group_key = None # unlabeled

formatter = ICalEventFormatter(time_tree_event, label_name=formatter_label_name)
ical_event = formatter.to_ical()
if ical_event is not None:
grouped[group_key].append(ical_event)

# Write each group to a separate file
output_stem, output_ext = os.path.splitext(args.output)
if not output_ext:
output_ext = ".ics"

for group_key, ical_events in grouped.items():
if group_key is None:
label_suffix = "unlabeled"
else:
label_suffix = sanitize_filename(labels[group_key]["name"])

cal = create_calendar()
for ical_event in ical_events:
cal.add_component(ical_event)

output_path = f"{output_stem}_{label_suffix}{output_ext}"
logger.info(
"%d events for label '%s'", len(ical_events), label_suffix
)
write_calendar(cal, output_path)

total = sum(len(evts) for evts in grouped.values())
logger.info(
"The .ics calendar file is saved to %s", os.path.abspath(args.output)
"A total of %d/%d events split into %d files",
total, len(events), len(grouped),
)
else:
# Standard single-file export
cal = create_calendar()

# Build label lookup for CATEGORIES
label_lookup = {}
if labels:
label_lookup = {lid: info["name"] for lid, info in labels.items()}

for event in events:
time_tree_event = TimeTreeEvent.from_dict(event)
label_name = label_lookup.get(time_tree_event.label_id)
formatter = ICalEventFormatter(time_tree_event, label_name=label_name)
ical_event = formatter.to_ical()
if ical_event is None:
continue
cal.add_component(ical_event)

logger.info(
"A total of %d/%d events are added to the calendar",
len(cal.subcomponents),
len(events),
)

write_calendar(cal, args.output)


if __name__ == "__main__":
Expand Down
Loading