Uni Calendar Coloring
Turn your university timetable into a color-coded Google Calendar.
The university calendar is read-only and every event in it has the same color.
unical copies it into a Google Calendar you own and colors each event:
- Exams: red if you are enrolled, grey if you are not. Once you enroll in one session of an exam, its other sessions turn grey.
- Lectures: one color per course, with the boilerplate stripped from the title.
- Deadlines: one color per deadline.
What counts as an exam, a lecture or a deadline is decided by the
rules of your profile, so the tool adapts to the format of
any university. The built-in profile handles the Politecnico di Milano format
(Esame: …, Lezione: Didattica - …, Scadenza: …).
Your choices are saved in a plain JSON file, profile.json, so every later sync
uses the same colors. You can run unical from a terminal UI, from plain
command-line flags, or on a weekly schedule with GitHub Actions.
Contents
- Quick start
- How it works
- Setup
- Usage
- Profile and rules
- Coloring rules
- Configuration
- Run it in the cloud with GitHub Actions
- Troubleshooting
- Upgrading
- Development
- Architecture
Quick start
Run without manual virtual environments using uvx:
uvx uni-calendar-coloring
# or directly from GitHub:
uvx --from git+https://github.com/Bert0ns/uni-calendar-coloring.git unical
Or run standalone pre-built binaries (no Python required):
# Download binary from GitHub Releases for your OS, make executable, and run:
./unical
The terminal UI opens and guides you through the first run and credentials setup. Nothing is written to Google Calendar until you apply the changes.
How it works
Source calendar ──read──▶ discover ──▶ preferences ──▶ plan ──▶ apply ──write──▶ your colored calendar
(Google or iCal) courses, saved colors, diff of batched
exams, rules, or inserts/ Google API
deadlines your edits updates/ calls
deletes
- Discover: read the source events and list the courses, exam sessions and deadlines they contain.
- Preferences: load your saved colors and subscriptions. Anything you
haven't chosen yet is filled in by the coloring rules, or
you choose it yourself in the TUI or with the
-iprompts. - Plan: compare the source with the target calendar and work out the events to insert, update and delete. Unchanged events cost no API calls.
- Apply: send the changes through the Google batch API, retrying transient failures.
The sync is safe to repeat:
- Only events the tool created are updated or deleted. They're tagged with a private property. Events you add to the target calendar yourself are left alone.
- Events removed from the source are removed from the target.
- An event that the current run doesn't cover (for example, a lecture during
unical exams) is ignored: its copy in the target calendar is never inserted, updated or deleted. Events removed from the source are only deleted by a run that covers everything. - The target calendar is created on the first sync.
Setup
You need a Google account (and Python 3.12+ if installing via uv, pipx, or source; standalone pre-built binaries require no Python runtime). The tool runs under your own Google Cloud OAuth client, so nobody else ever sees your calendar.
1. Create Google API credentials
- Open the Google Cloud Console and create a new project.
- Go to APIs & Services → Library and enable the Google Calendar API.
- Go to APIs & Services → OAuth consent screen:
- Choose the External user type and fill in the required fields.
- Under Test users, add your own Google address. If you skip this, login
fails with
403 access_denied.
- Go to APIs & Services → Credentials → Create credentials → OAuth client ID, choose Desktop app, then download the JSON file.
- Import it directly into your user configuration directory:
unical auth import /path/to/downloaded-credentials.json
(Alternatively, save it ascredentials.jsonin your working directory, or simply launchunicaland let the interactive setup wizard prompt you for the file).
2. Install
Choose the installation method that best suits your environment:
Standalone Executable (Recommended - No Python Required)
Pre-built single-file executables are automatically generated and attached to each GitHub Release:
- Linux (x86_64):
unical-linux-x86_64(orunical-linux-x86_64.tar.gz) - macOS (Universal - Apple Silicon & Intel):
unical-macos-universal(orunical-macos-universal.tar.gz) - Windows (x86_64):
unical-windows-x86_64.exe(orunical-windows-x86_64.zip)
On Linux / macOS:
curl -LO https://github.com/Bert0ns/uni-calendar-coloring/releases/latest/download/unical-linux-x86_64
chmod +x unical-linux-x86_64
mkdir -p ~/.local/bin
mv unical-linux-x86_64 ~/.local/bin/unical
On Windows:
Download unical-windows-x86_64.exe (or unzip unical-windows-x86_64.zip), rename to unical.exe, and run it from PowerShell or Command Prompt.
Modern CLI Tool Managers (uv / pipx)
Installs unical in an isolated environment and makes the command available system-wide:
Using uv (fastest):
# Install from PyPI:
uv tool install uni-calendar-coloring
# Or install directly from the Git repository:
uv tool install git+https://github.com/Bert0ns/uni-calendar-coloring.git
# Or run ephemerally with uvx:
uvx uni-calendar-coloring
Using pipx:
# Install from PyPI:
pipx install uni-calendar-coloring
# Or install directly from the Git repository:
pipx install git+https://github.com/Bert0ns/uni-calendar-coloring.git
Homebrew (macOS / Linux)
brew install Bert0ns/tap/unical
From Source (Git clone / Virtualenv)
For contributors and developers:
git clone https://github.com/Bert0ns/uni-calendar-coloring.git
cd uni-calendar-coloring
pip install .
This installs the unical command. python -m unical works too.
3. First run
unical
Your browser opens so you can log in with Google, and the login is saved to
token.json. Without a browser (WSL, SSH), the login URL is printed for you to
open by hand.
When there is no profile.json yet, the terminal UI opens a
short guide:
- The calendar with your timetable: pick it from your Google calendars.
- The calendar to write to: pick one, or keep the suggested new name. A new calendar is only created when you apply the changes.
- Recognizing your events: see how the built-in Politecnico di Milano rules classify your events, then keep them, adjust them, or start from scratch in the Rules tab.
The guide ends on a preview of the changes. Nothing is written to Google
Calendar until you apply them. Your choices are saved in profile.json, and the
guide only runs while that file doesn't exist. Press Escape on the welcome
screen to skip it.
Without the terminal UI, create profile.json yourself with the two calendars
(the rules and colors start from the built-in profile), then try a dry run:
{
"calendars": {
"source": "My University",
"target": "Colored Calendar"
}
}
unical --dry-run
If you'd rather not subscribe to the source calendar in Google, give the tool your iCal URL instead. All settings are listed under Configuration.
Usage
Authentication
Manage credentials and check login status with unical auth:
unical auth import /path/to/downloaded-credentials.json # copy credentials to user config directory
unical auth import /path/to/credentials.json -m # move instead of copy
unical auth status # inspect credentials, token cache, and profile status
unical auth login # authenticate with Google and cache token in advance
Sync
unical # open the terminal UI (the default)
unical --no-tui # plain sync: exams, lectures and deadlines
unical exams --no-tui # only (re)color exams
unical lectures --no-tui
unical deadlines --no-tui
Run from a terminal, unical opens the terminal UI. It falls
back to a plain sync when there is no terminal (cron, CI, a pipe) or when you pass
-i, -n/--dry-run or --no-tui. The plain sync is non-interactive: anything new gets a color from the
coloring rules, and that color is saved for future runs.
Useful flags:
unical --dry-run # plain sync, show the changes, write nothing
unical -v # explain the decision for every event
unical -q # only warnings and errors
unical --prune-before 2026-02-01 # also delete synced events before this date
--prune-before is handy for dropping past semesters. Flags can be combined,
e.g. unical lectures --dry-run -v. The command exits with a non-zero status if
any calendar operation fails.
Terminal UI
unical
unical exams # only the Exams and Sync tabs
--tui forces it even when the shell doesn't look interactive. It opens on the
Setup tab, with one
more tab per kind of event:
| Tab | What you can do |
|---|---|
| Setup | The calendar to read from, the one to write to, and the time zone of a new target calendar. Pick the calendars from your Google calendars, or type a new name for the target. Choices are saved right away. Also has the selector for what a sync covers. |
| Courses | Every course in the source with its color. Pick a new color from the 11 Google colors. |
| Exams | Every exam session with its date, what the source calendar says (enrolled / not enrolled), whether you're already subscribed to another date, and your subscription. Toggle the subscription or pick a color. |
| Deadlines | Same as Courses, for deadlines. |
| Sync | Choose what to color: everything, lectures only, exams only or deadlines only (the other tabs follow). Preview changes lists the inserts, updates and deletes. Apply writes them to Google Calendar with a live progress panel. |
| Rules | The rules that decide what each event is, and the exam enrollment conditions. A live preview shows how every event title is classified. |
In the Sync tab, expand an event in the preview to see its color, time and original title. While applying, the progress panel shows a spinner, a bar, how many inserts, updates and deletes are written, and the time elapsed and left.
In the Rules tab, add, edit, delete and reorder rules, or press Enter on an unmatched event to start a rule from its title.
The Status column shows where each value comes from:
- saved: chosen earlier and stored in the profile.
- suggested: not chosen yet, so the coloring rules decide. It's saved at the next sync.
- modified: changed in this session and not saved yet.
- not set: an exam with no enrollment information. Its color is left as is until you choose one.
| Key | Action |
|---|---|
Enter / c |
Pick a color for the selected row |
Space |
Toggle the exam subscription (Exams tab) |
Ctrl+S |
Save rules and preferences without syncing |
p |
Preview the changes |
a |
Apply the previewed changes |
n |
New rule (Rules tab) |
e / Enter |
Edit the selected rule (Rules tab) |
d |
Delete the selected rule (Rules tab) |
[ / ] |
Move the selected rule up / down (Rules tab) |
q |
Quit (asks again if there are unsaved changes) |
Toggling a subscription switches between the default red and grey. A custom color you picked for the exam is kept. Applying saves your preferences, just like a normal sync. If you edit something after a preview, preview again before applying.
With an iCal URL the source can't be changed in the Setup tab. If
SOURCE_CALENDAR_NAME or TARGET_CALENDAR_NAME is set, it replaces the saved
calendar every time the tool starts, and the tab says so.
Interactive prompts
unical -i
unical exams -i
Without the TUI you get a question-by-question flow before the sync: your
subscription to each exam session, then a color for each course and deadline.
The current choice is the default, so you can press Enter to keep it. Other
dates of the same exam with no saved choice reuse your first answer. Add
--dry-run to try choices without saving them.
Use an iCal URL as the source
You can read events straight from your personal iCal feed (e.g. from your university app) instead of a Google calendar:
unical --ical "https://ical.example.com/<id>/<token>"
Or set it once in .env, where it takes precedence over the source calendar:
SOURCE_ICAL_URL="https://ical.example.com/<id>/<token>"
Recurring events (RRULE/EXDATE) are passed to Google, which expands them.
Rules can match the feed's CATEGORIES with "field": "category". The built-in
PoliMi rules use them (Lezione, Esame, Scadenza) when the title prefixes
are missing.
Command reference
unical [all|exams|lectures|deadlines] [options]
| Option | Description |
|---|---|
all (default), exams, lectures, deadlines |
Which kinds of events to sync; the others are left untouched. |
--tui |
Open the terminal UI (default in a terminal). Not with -i, --dry-run, --no-tui. |
--no-tui |
Run a plain sync instead of opening the terminal UI. |
-i, --interactive |
Ask about subscriptions and colors before syncing. |
-n, --dry-run |
Show what would change. Nothing is written to the calendar or the profile. |
--ical URL |
Read from an iCal feed. Overrides the source calendar and SOURCE_ICAL_URL. |
--prune-before YYYY-MM-DD |
Also delete synced events starting before this date. |
-v, --verbose |
Show the decision for every event. |
-q, --quiet |
Show only warnings and errors. |
Profile and rules
profile.json holds everything about your calendar: the calendars to sync, the
rules that classify events, and the colors and subscriptions you chose. Without
one, the tool starts from the built-in PoliMi profile and saves it on the first
sync, so you can see and edit the rules.
{
"version": 1,
"name": "Politecnico di Milano",
"calendars": {
"source": "Calendar",
"target": "Calendar Colored",
"time_zone": null
},
"rules": [
{
"kind": "exam",
"field": "title",
"match": "starts_with",
"value": "Esame: ",
"ignore_case": false,
"title": "{title}"
},
{
"kind": "lecture",
"field": "title",
"match": "starts_with",
"value": "Lezione: Didattica - ",
"ignore_case": false,
"title": "{name}"
}
],
"enrollment": {
"enrolled": {
"field": "description",
"match": "starts_with",
"value": "Iscritto",
"ignore_case": false
},
"not_enrolled": {
"field": "description",
"match": "starts_with",
"value": "Non iscritto",
"ignore_case": false
}
},
"courses": { "Computer Security": "7" },
"exams": {
"Computer Security (2027-01-20)": { "color": "11", "subscribed": true }
},
"deadlines": { "Esame di laurea": "4" }
}
The example is shortened. The built-in profile has six rules: the title prefix and the iCal category of each kind of event.
When the target calendar doesn't exist, the first sync creates it in
time_zone, an IANA time zone
such as Europe/Rome. With null, it gets the time zone of your primary Google
calendar.
Rules
Rules are checked in order and the first one that matches decides the kind of
the event. Events that match no rule are copied without a color. Without a
rules key, the built-in PoliMi rules apply; "rules": [] means no rules. The
easiest way to write them is the Rules tab of the
terminal UI, which shows live how each event is classified.
| Key | Values |
|---|---|
kind |
exam, lecture or deadline. |
field |
What to look at: title, description, location or category (any iCal category). |
match |
starts_with, contains, equals or regex (a Python regular expression, searched). |
value |
The text or regular expression to match. |
ignore_case |
true to ignore upper/lower case. Optional, false by default. |
title |
Title in the colored calendar. {title} is the original title, {name} the event's name. |
The name of an event identifies its course, exam or deadline: lectures with
the same name share a color. It's the title without the matched text when the
rule looks at the title (Lezione: Didattica - Algebra → Algebra), and the
whole title otherwise. With a regex, a (?P<name>…) group picks it out
explicitly, e.g. ^\[\w+\] (?P<name>.+) - Lecture$ turns
[CS101] Algorithms - Lecture into Algorithms.
enrollment is optional: it tells, from an exam event, whether you're enrolled
(enrolled) or not (not_enrolled). Each is a condition like the ones of the
rules, or null. Invalid rules are skipped with a warning.
Coloring rules
These rules fill in anything you haven't chosen yourself. Your saved choices always win.
| Event | Rule |
|---|---|
| Exam, another session of an exam you're subscribed to | Not subscribed, Graphite (grey). Checked first. |
Exam, the enrolled condition matches |
Subscribed, Tomato (red). |
Exam, the not_enrolled condition matches |
Not subscribed, Graphite. |
| Exam, no information | Left uncolored until you choose. |
| Lecture | A color derived from the course name, so it's the same on every machine. |
| Deadline | A color derived from the name. |
Saved preferences
| Profile key | Content |
|---|---|
courses |
{"<course>": "<color id>"} |
exams |
{"<exam name> (<YYYY-MM-DD>)": {"color": "<id>", "subscribed": true}} |
deadlines |
{"<deadline>": "<color id>"} |
Color IDs are Google's: 1 Lavender, 2 Sage, 3 Grape, 4 Flamingo, 5 Banana, 6 Tangerine, 7 Peacock, 8 Graphite, 9 Blueberry, 10 Basil, 11 Tomato. You can edit the file by hand. Invalid entries are skipped with a warning, and the file is written atomically, so an interrupted run can't corrupt it.
Configuration
The calendars, rules and colors live in the profile. The
optional settings below come from environment variables, usually set in .env
(see .env.example).
Configuration and credential paths (PROFILE_PATH, CREDENTIALS_PATH, TOKEN_PATH)
are resolved in this order:
- Explicit environment variable (relative paths resolved from current working directory).
- Current working directory (
./credentials.json,./profile.json, etc.) if the file exists. - Standard user configuration directory for your platform:
- Linux:
~/.config/unical/(or$XDG_CONFIG_HOME/unical/) - macOS:
~/Library/Application Support/unical/ - Windows:
%APPDATA%\unical\
- Linux:
| Variable | Default | Description |
|---|---|---|
PROFILE_PATH |
profile.json or config dir |
The profile. |
SOURCE_CALENDAR_NAME |
(the profile's) | Overrides the calendar to read from. |
TARGET_CALENDAR_NAME |
(the profile's) | Overrides the calendar to write to. |
SOURCE_ICAL_URL |
(unset) | Read from this iCal feed instead of a calendar. |
CREDENTIALS_PATH |
credentials.json or config dir |
OAuth client downloaded from Google Cloud. |
TOKEN_PATH |
token.json or config dir |
Cached Google login. |
Run it in the cloud with GitHub Actions
The repository includes a workflow,
.github/workflows/manual_sync.yml, that
syncs every Monday at 06:00 UTC. You can also run it from the Actions
tab and choose what to sync, verbose output, or a dry run.
-
Fork the repository and commit your own
profile.json(the one in the repository belongs to its author), or override the calendar names with the secrets below. -
Log in locally once so that
token.jsonexists, e.g. withunical --dry-run. -
In your fork, go to Settings → Secrets and variables → Actions and add these repository secrets:
Secret Value GCP_CREDENTIALS_JSONContents of credentials.json.GCP_TOKEN_JSONContents of token.json.SOURCE_CALENDAR_NAMEOptional, overrides the profile's source calendar. TARGET_CALENDAR_NAMEOptional, overrides the profile's target calendar. SOURCE_ICAL_URLOptional. If set, it's used instead of the source calendar.
After each run, the workflow commits the updated profile.json back to the
repository, so colors you pick locally (and push) and colors the cloud run
assigns stay in sync.
Troubleshooting
| Problem | Fix |
|---|---|
403 access_denied when logging in |
Add your Google address as a Test user on the OAuth consent screen. |
OAuth client file 'credentials.json' not found |
Download the Desktop OAuth client JSON and save it as credentials.json, or point CREDENTIALS_PATH to it. |
Source calendar '…' not found |
The source calendar in profile.json must match the name exactly as shown in Google Calendar. |
| Every event shows up twice | Hide the original calendar in Google Calendar. |
Could not download iCal feed |
The iCal URL may have expired or been revoked. Generate a new one. |
The terminal UI needs the 'textual' dependency |
Run pip install .. |
| Login keeps expiring after a week | That's Google's Testing mode: log in again locally (and update GCP_TOKEN_JSON if you use GitHub Actions). |
| Some events have no color | They match no rule. Check the Rules tab, where unmatched titles are listed. |
Upgrading
- The command was renamed from
calendar-coloringtounical, and the Python package fromcalendar_coloringtounical. Reinstall withpip install ., and update scripts and cron jobs.profile.json,token.jsonand the events already in your target calendar keep working. token.pickleis migrated totoken.jsonautomatically, and the oldGCP_TOKEN_PICKLE_B64secret is still accepted.- Events synced by older versions carry an older tag: the next sync updates each of them once to the current tag, then they're left alone again.
Development
pip install -e ".[dev]"
pytest # unit, golden regression, Textual pilot and packaging tests
mypy # strict type checking
ruff check . # linting
black . # formatting
pre-commit install
# Build standalone single-file binary with PyInstaller:
pyinstaller packaging/unical.spec
./dist/unical --help
# Build distribution wheels & sdist:
python -m build
twine check dist/*
CI runs all of the above on Python 3.12, 3.13, and 3.14 and requires 95% test coverage.
Architecture
The core never prints, prompts or touches the network directly. It depends on small interfaces (ports) that the adapters implement, so the CLI, the interactive prompts and the TUI are all thin frontends over the same discover → preferences → plan → apply phases.
src/unical/
├── palette.py # GoogleColor: the 11 Google colors (id, name, RGB)
├── events.py # Event accessors and exam sessions
├── rules.py # Classification rules: event → exam/lecture/deadline + name
├── presets.py # Built-in rules (Politecnico di Milano)
├── profile.py # Profile model + JSON repository (atomic writes)
├── targets.py # SyncTarget: which kinds of events a run covers
├── catalog.py # Discover: courses, exam sessions and deadlines in the source
├── preferences.py # Preferences model: colors and exam subscriptions
├── suggestions.py # Pure rules for default colors and subscriptions
├── resolution.py # Fill in preferences the user never chose
├── strategies.py # Pure lookups: event + preferences → color
├── workflow.py # Use case: load, preview/plan, apply (and run = all at once)
├── reporting.py # Reporter port (progress and messages)
├── sync/
│ ├── source.py # EventSource port + Google calendar source
│ ├── planner.py # Pure diff: source + target events → SyncPlan
│ ├── models.py # Mutation, SyncPlan, SyncResult value objects
│ ├── gateway.py # CalendarGateway port
│ └── service.py # Target-calendar I/O around the planner, with progress
├── ical_source.py # iCal feed source (standard library parser)
├── google_client.py # Google Calendar adapter (batch requests + retries)
├── auth.py # Google OAuth, Desktop flow, token.json
├── config.py # Settings and overrides from environment variables
├── cli/
│ ├── main.py # Argument parsing and wiring of the adapters
│ ├── prompts.py # Interactive preference editor (-i)
│ ├── console.py # Colored console reporter
│ └── ansi.py # ANSI styling helpers
└── tui/ # Terminal UI
├── model.py # View model of the editor (pure Python, no Textual)
├── setup.py # Setup tab model: calendars, overrides, first run
├── rules.py # Rules tab model: live classification preview
├── widgets.py # Color picker, calendar picker, plan tree, progress
├── wizard.py # First-run welcome and rules check screens
└── app.py # Textual app: tabs, workers, reporter
License
Metadata
Release files for uni-calendar-coloring 1.0.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| uni_calendar_coloring-1.0.1.tar.gz | 141.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| uni_calendar_coloring-1.0.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 226.7 kB
Release files / uni_calendar_coloring-1.0.1.tar.gz
| Download URL | uni_calendar_coloring-1.0.1.tar.gz |
|---|---|
| Size | 141.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
51b81bec481c593b02a5d8c81bbf4a3c18b1d8ba08ebf2e0eecff0f79ad5c220
|
|
BLAKE2b-256 checksum How to use checksums |
47a37fc35e4392ab1ceb79f74106686b4aab565c1d3fcda9c9a80e1aab8e3ada
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 8, 2026.
Transparency logRelease files / uni_calendar_coloring-1.0.1-py3-none-any.whl
| Download URL | uni_calendar_coloring-1.0.1-py3-none-any.whl |
|---|---|
| Size | 85.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
dfdf1bc45d1ebcec95ad16d538c2781fba14b7c8f92469421e894069f96f60a2
|
|
BLAKE2b-256 checksum How to use checksums |
adc4d7e20e8a4302fa6dcdebe7af35962510f5e0289f7bf4faae4a3f06285fe5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 8, 2026.
Transparency log