Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

Google

  1. Enable the Calendar API in a GCP project.
  2. Create a service account. Download its JSON key.
  3. Create a calendar for bakasync alone.
  4. 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:

  1. --config <path>, or BAKASYNC_ENV_FILE
  2. .env.local and .env in the working directory
  3. ~/.config/bakasync/config.env, or %APPDATA%\bakasync\config.env

Quote a value with a space: BAKASYNC_GROUPS="S1, PX2". A leading ~ expands.

VariableRequiredDefaultMeaning
BAKASYNC_BASE_URLyesRoot of the Bakaláři web, e.g. https://bakalari.example.cz/bakaweb
BAKASYNC_CLASS_IDyesId from the timetable URL
BAKASYNC_CALENDAR_IDyesTarget calendar, …@group.calendar.google.com
BAKASYNC_SERVICE_ACCOUNT_KEYnobeside the configPath to the JSON key
BAKASYNC_WEEKSnoActual,NextWeeks to scrape
BAKASYNC_EXPECTED_CLASS_NAMEnoWarn if the page shows another class
BAKASYNC_GROUPSnoYour split groups, comma separated
BAKASYNC_MERGE_GAP_MINUTESno25Break that still merges two lessons
BAKASYNC_INTERVALno10min, 1h, 600s
BAKASYNC_DRY_RUNnofalseLog the plan, write nothing
BAKASYNC_DUMP_HTML_DIRnoSave every fetched page here
RUST_LOGnoinfotracing 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 bakasync property, 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:

PeriodTimeSubjectRoomTeacherGroup
309:55MB302NováS2
410:50MB302Nová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.