Split a combined university calendar (ICS) into per-course feeds with enriched summaries and descriptions, and generate additional feeds — study blocks, training, meal prep — from declarative specs that schedule around whatever is already in the calendar.
Built for KTH calendars but works with any ICS source where events contain course codes.
- Fetch — downloads the upstream ICS (or reads a local file), skipping only when both the feed and the course/spec configs are unchanged (ETag/SHA256 caching)
- Parse — extracts events and detects course codes from summaries/descriptions
- Classify — matches events to configured event types using pattern + strategy rules
- Rewrite — applies summary/description templates with lecture titles, modules, and Canvas links
- Generate — expands
specs/*.jsoninto events, using the upstream events as a busy set - Write — outputs one
.icsfeed per course and per generated spec, with tokenized filenames
Course feeds are reshaped from upstream. Study blocks, workouts and meal prep have no upstream
event, so they are authored from a spec. Specs live in the feeds repository beside the course
configs — events/ holds one JSON per course, specs/ one per generated feed:
{
"feed": "GYM",
"name": "Training",
"timezone": "Europe/Stockholm",
"priority": 30,
"color": "#1a6467",
"rules": [
{ "kind": "recurring", "summary": "Gym — {rotate}",
"from": "2026-08-24", "until": "2026-10-16",
"days": ["mon", "tue", "wed", "thu", "fri", "sat"],
"window": ["17:00", "21:00"], "duration_min": 75, "per_week": 3,
"rotate": ["Day A (upper push)", "Day B (lower + core)"] },
{ "kind": "fixed", "summary": "1MA462 exam",
"start": "2026-10-17T12:00", "end": "2026-10-17T17:00" }
]
}fixed — an event at an exact time. Needs start and end (ISO, naive times take the feed's
timezone). Use for exams, deadlines and registration windows.
recurring — a rule the scheduler places for you. Needs from, until, days, and takes
window, duration_min, per_week, rotate. For each week it walks days in order and takes the
first free slot inside window, stepping in 15-minute increments, until per_week are placed.
Each candidate start inside the window is scored and the best one wins, rather than simply the
first that fits. The key is (hour bucket by preference, clearance, exact offset): prefer
(early by default, or late) decides which end of the window is favoured and dominates;
within the same hour the slot with the most room around it wins, which is what stops sessions
stacking back to back.
| Field | Effect |
|---|---|
min_gap_min |
Breathing room required either side of anything already scheduled |
tags |
What a session is, e.g. ["lift", "lower"] |
min_hours_after |
Hours that must pass after a session carrying a given tag |
max_per_day |
Cap on sessions from this feed in a day; 0 means no cap |
Recovery tags cross feeds — a lower day is a lower day whether it came from GYM.json or
anywhere else — while max_per_day counts only its own feed, so a morning lift does not consume
the mobility allowance.
A spec can declare day_start and day_end. Every recurring rule window is clamped to them at
load time, so nothing is scheduled while you are asleep and the bound lives in one place rather
than in every rule. Fixed events are never clamped: a 05:00 flight is a real commitment, not
something the tool gets to move.
Tagging is per rule, so a rule whose rotate mixes session types cannot be tagged honestly —
split it. Upper and lower lifts are two rules for exactly this reason, which lets intervals
declare {"lower": 24} and find their own day instead of being pinned to one by hand.
A protected rest day needs no feature: leave the day out of days.
avoid_conflicts (default true) keeps a rule off anything already scheduled: upstream lectures,
events from earlier specs, and earlier rules within the same spec. It is first-fit, not an
optimiser — it will not reshuffle existing placements to fit one more in. When a week cannot be
filled it logs a warning naming the rule and the week rather than failing.
priority (default 50) decides which spec claims slots first, lowest first. Without it the order
would be alphabetical, so renaming a file would silently reshuffle the schedule.
color (optional, CSS name or #rrggbb) is emitted as both X-APPLE-CALENDAR-COLOR and
X-OUTLOOK-COLOR, so subscribers get a distinct colour per feed.
An event that never reaches a feed is the failure hardest to notice, so every one is recorded on
PipelineResult.dropped as (summary, reason). Events filtered by a course config log at INFO;
events with no detectable course code log at DEBUG, since a personal calendar is full of them.
Each course is a JSON file (e.g. courses/IS1200.json):
{
"course_code": "IS1200",
"course_name": "Computer Hardware Engineering",
"canvas_url": "https://canvas.kth.se/courses/56261",
"detection": {
"require_code_in_summary": true,
"course_code_pattern": "\\bIS1200\\b"
},
"templates": {
"summary": "{kind} {n} - {title} - {course}",
"description": "{module}\nCanvas: {canvas}\n\n{original}"
},
"event_types": [
{
"type": "lecture",
"display_name": "Lecture",
"patterns": ["\\bLecture\\s*(\\d+)\\b"],
"items": [
{ "number": 1, "title": "Course Introduction", "module": "Module 1" }
]
}
]
}Summary: {kind}, {n}, {title}, {course}
Description: {module}, {canvas}, {original}
Items can use match rules to filter events by time, location, description, or URL:
{
"number": 1,
"title": "Intro",
"match": [
{ "strategy": "time", "priority": 1, "day": "monday",
"start_time": "13:00", "end_time": "15:00", "timezone": "Europe/Stockholm" }
]
}Available strategies: time, description, location, url, all, any.
A feed nobody writes any more still sits on the site and still appears in the README as
something to subscribe to. --prune-feeds removes any whose file git has not seen change for
longer than PRUNE_AFTER_DAYS (152, five months), and drops its token so the README stops
listing it.
Age is the signal rather than "absent from the last run", because a transient upstream failure
must never be able to delete anything. A checkout resets mtimes, so the question is when git
last recorded a change — which means the feeds repo needs fetch-depth: 0.
FEEDS_DIR=_feeds/docs/feeds TOKEN_MAP_PATH=_feeds/token_map.json \
FEEDS_REPO_DIR=_feeds PRUNE_DRY_RUN=true \
python -m calendar_splitter --prune-feedsThe Prune finished course feeds workflow runs monthly and defaults to a dry run when started
by hand.
| Variable | Required | Description |
|---|---|---|
FEEDS_DIR |
yes | Output directory for generated .ics feeds |
TOKEN_MAP_PATH |
yes | Path to the token mapping JSON |
SOURCE_ICS_URL |
no | Upstream calendar URL (falls back to local file) |
LOCAL_UPSTREAM_ICS |
no | Local ICS fallback path (default: personal.ics) |
COURSES_DIR |
no | Directory of course config JSONs (default: courses) |
SPECS_DIR |
no | Directory of generated-feed specs (default: specs) |
CAMPUS_TRAVEL_MIN |
no | Minutes each way to campus, reserved around upstream events |
BUSY_EXCLUDE |
no | Course codes whose events should not reserve time |
PRUNE_AFTER_DAYS |
no | Age at which a feed is pruned (default: 152) |
PRUNE_DRY_RUN |
no | Report what would be pruned without removing it |
FEEDS_REPO_DIR |
no | Feeds repo root, needed for prune to read git history |
FORCE_REBUILD |
no | Rebuild even when nothing changed (or pass --force) |
| UPSTREAM_STATE_PATH | no | Cache state file (default: _feeds/upstream_state.json) |
| LOG_LEVEL | no | Logging level (default: INFO) |
# Install
pip install -e .
# Run
FEEDS_DIR=_feeds/feeds TOKEN_MAP_PATH=_feeds/tokens.json python -m calendar_splitter# Install with dev dependencies
pip install -e ".[dev]"
# Run tests
pytest
# Lint and type check
ruff check calendar_splitter/
mypy calendar_splitter/calendar_splitter/
├── __main__.py # python -m entry point
├── cli.py # CLI setup from env vars
├── config/ # Course config loading + validation
├── core/
│ ├── models.py # Dataclasses (Event, CourseConfig, etc.)
│ ├── parser.py # ICS parsing + course code detection
│ ├── rewriter.py # Template-based event rewriting
│ └── writer.py # ICS output generation
├── exceptions.py # Custom exception hierarchy
├── fetch.py # HTTP + local fetch with caching
├── logging.py # Log redaction (tokens, UUIDs, query strings)
├── pipeline.py # Orchestration: fetch → parse → classify → rewrite → write
├── strategies/ # Strategy evaluation engine
└── tokens.py # Per-course feed URL token store