bakasync
One-way sync from a public Bakaláři timetable into a dedicated Google Calendar.
- Reads the public timetable page. No Bakaláři account, no login.
- Writes to a calendar of its own. It never touches an event it did not create.
- One static binary. No database.
Four steps to a working sync: Install, Google, Configure, Run.
Install
macOS and Linux:
$ brew tap kucendro/lazybaka https://github.com/kucendro/lazybaka
$ brew install bakasync
Windows:
$ scoop bucket add lazybaka https://github.com/kucendro/lazybaka
$ scoop install bakasync
Anything else: Releases. Static binary,
.env.example, readme, SHA256SUMS. Linux and macOS on Intel and ARM, Windows on x86-64.
On NixOS, use the module instead.
- Enable the Calendar API in a GCP project.
- Create a service account. Download its JSON key.
- Create a calendar for bakasync alone.
- Share it with the service account e-mail. Permission: Make changes to events.
No OAuth, no browser. The service account signs its own token, so the sync can run unattended.
Step 3 is not optional advice. Point it at a calendar you also use by hand and you lose the one thing that keeps your own events safe: a calendar it fully owns.
Configure
1. Write the config file
$ bakasync init
config ~/.config/bakasync/config.env (created)
key ~/.config/bakasync/service-account.json (missing)
Download the service account key from GCP and save it
to the path above, then run: bakasync doctor
2. Put the key where it says
$ mv ~/Downloads/project-a1b2c3.json ~/.config/bakasync/service-account.json
$ chmod 600 ~/.config/bakasync/service-account.json
3. Fill in three values
BAKASYNC_BASE_URL=https://bakalari.example.cz/bakaweb
BAKASYNC_CLASS_ID=1A
BAKASYNC_CALENDAR_ID=abc123@group.calendar.google.com
BAKASYNC_CLASS_ID is the id in the timetable URL, not the class name. Open the public timetable in a
browser and read it off the address bar.
Add BAKASYNC_GROUPS if your class splits, and BAKASYNC_EXPECTED_CLASS_NAME to be told when the
page stops being your class. Everything else has a default: see Variables.
4. Check
$ bakasync doctor
config ~/.config/bakasync/config.env
key ok, sync@project.iam.gserviceaccount.com
timetable ok, class "1.A", 43 lessons
calendar FAILED: google api returned 404 Not Found: ...
the calendar id is wrong, or it is not shared with the service account
A clean run ends with all good, run bakasync --plan to see what the next sync would change.
Run
$ bakasync init # write the config file, say where the key goes
$ bakasync doctor # check config, key, timetable, calendar
$ bakasync --plan # print what the next sync would change, write nothing
$ bakasync --once # sync once, ignore the interval
$ bakasync # sync on BAKASYNC_INTERVAL, or once if unset
doctor and --plan never write.
Set BAKASYNC_INTERVAL=10min to leave it running. Otherwise it syncs once and exits, which is what
you want under a timer or cron.
--config <path> works on every command, so one machine can drive two classes:
$ bakasync --config ~/.config/bakasync/sister.env --plan
On NixOS the module runs it as a systemd service. Elsewhere, a timer or a --once cron
line is enough.
Variables
Plain KEY=value file. The first location that exists wins:
--config <path>, orBAKASYNC_ENV_FILE.env.localand.envin the working directory~/.config/bakasync/config.env, or%APPDATA%\bakasync\config.env
Quote a value with a space: BAKASYNC_GROUPS="S1, PX2". A leading ~ expands.
| Variable | Required | Default | Meaning |
|---|---|---|---|
BAKASYNC_BASE_URL | yes | Root of the Bakaláři web, e.g. https://bakalari.example.cz/bakaweb | |
BAKASYNC_CLASS_ID | yes | Id from the timetable URL | |
BAKASYNC_CALENDAR_ID | yes | Target calendar, …@group.calendar.google.com | |
BAKASYNC_SERVICE_ACCOUNT_KEY | no | beside the config | Path to the JSON key |
BAKASYNC_WEEKS | no | Actual,Next | Weeks to scrape |
BAKASYNC_EXPECTED_CLASS_NAME | no | Warn if the page shows another class | |
BAKASYNC_GROUPS | no | Your split groups, comma separated | |
BAKASYNC_MERGE_GAP_MINUTES | no | 25 | Break that still merges two lessons |
BAKASYNC_INTERVAL | no | 10min, 1h, 600s | |
BAKASYNC_DRY_RUN | no | false | Log the plan, write nothing |
BAKASYNC_DUMP_HTML_DIR | no | Save every fetched page here | |
RUST_LOG | no | info | tracing filter |
Groups
BAKASYNC_GROUPS=S1,PX2 keeps those groups. Whole-class lessons are always kept. Matching ignores
case, diacritics and the long form, so S1 matches S1 - Skupina 1.
Key path
An unset key path means service-account.json next to the config file. A relative path resolves from
there too. With no config file at all, under systemd or in a container, every variable comes from the
environment and the key path is required.
The key is a credential. Keep it chmod 600 and out of any repository. .env and .env.local are
gitignored, and a pre-push hook refuses a real school host.
Behaviour
What a sync does to the calendar:
- Only touches events it created. They carry a private
bakasyncproperty, so an exam you added by hand survives. - Back-to-back lessons of one subject become one event, if the break fits
BAKASYNC_MERGE_GAP_MINUTES. - Substitutions and cancellations get ⚠ in the title.
- Title is the subject, location is the room, description is teacher, group, change, theme.
- Each event keys off a hash of start time, subject and group. Nothing is stored locally, so there is no state to lose.
- A moved lesson patches in place. A cancelled one is deleted.
Two lessons in the timetable:
| Period | Time | Subject | Room | Teacher | Group |
|---|---|---|---|---|---|
| 3 | 09:55 | M | B302 | Nová | S2 |
| 4 | 10:50 | M | B302 | Nová | S2 |
become one event: Matematika, 09:55 to 11:35, location B302, description Nová | S2.
When it goes wrong
doctor proves the calendar is readable, not writable. A read-only share still passes it. Only an
insert would prove the rest, and that leaves an event behind, so --plan covers the gap.
A class id that no longer exists shows up as a timetable FAILED line, not as an empty sync. An empty
timetable is never treated as “delete everything”.
NixOS
{
inputs.bakasync.url = "github:kucendro/lazybaka";
outputs = { nixpkgs, bakasync, ... }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
modules = [
bakasync.nixosModules.bakasync
({ config, ... }: {
services.bakasync = {
enable = true;
baseUrl = "https://bakalari.example.cz/bakaweb";
classId = "1A";
expectedClassName = "1.A";
groups = [ "S1" "PX2" ];
calendarId = "abc123@group.calendar.google.com";
serviceAccountKeyFile = config.age.secrets.bakasync-sa.path;
interval = "10min";
};
})
];
};
};
}
Hardened DynamicUser service. The key goes in through LoadCredential, so it never reaches the Nix
store. Use environmentFile for other secrets and extraSettings for the rest of the
table.
$ systemctl status bakasync
$ journalctl -u bakasync -f
Development
$ nix develop
$ cargo test
$ nix build .#bakasync
The parser is tested twice. Against checked-in pages, which is all CI has, and against the live
timetable named by .env.local. The live check asserts the class name is still there, matches
BAKASYNC_EXPECTED_CLASS_NAME, and that every atom parsed. Those are the two ways this breaks in
practice. It skips itself with no env file, and lefthook runs the suite on pre-push.
nix develop puts the flake-built binary on PATH next to the Rust toolchain. Running it in the
source directory picks up .env, so use --plan, not a bare bakasync.
These docs
$ mdbook serve docs --open
mdbook is in the dev shell. A push to main that touches docs/ publishes them.
Release
Push a vX.Y.Z tag matching Cargo.toml. That builds five targets, publishes the release, and
commits Formula/bakasync.rb and bucket/bakasync.json to main. Those two files make the
repository a Homebrew tap and a Scoop bucket. Homebrew reads the formula from the default branch, so
an install only resolves after a tag has run.