BUSY.Tedt.org BUSY Bar Apps
← All Apps

CalDAV Calendar

Meeting reminders with a countdown, then a red BUSY while it runs, from any CalDAV or ICS calendar - iCloud, Fastmail, Nextcloud, PrivateEmail or an Outlook link.

Updated Oct 27, 2026 · Added Oct 27, 2026 · ↓ 0 package download starts

By kurok · ▲ 0 · Rate and Discuss

How It Works

busybar-calendar

Calendar reminders for the BUSY Bar from any CalDAV account or ICS feed, shown exactly the way the bar shows its built-in Google Calendar events.

A reminder on the BUSY Bar: calendar icon, title and countdown

The bar's own Google Calendar integration runs in BUSY Cloud and only talks to Google. This does the same thing for iCloud, Fastmail, Nextcloud, PrivateEmail, self-hosted CalDAV servers, and any calendar published as an ICS link (Outlook, Google, Proton). It runs on a machine of yours and draws through the bar's HTTP API, via BUSY Cloud or on the LAN. The events look the same as Google's; only where they come from is different. Requested upstream as busybar-firmware#1086.

On the bar

Every picture here is a frame read back from a real bar (firmware 1.2.4) through GET /api/screen.

Before the event - a red calendar, the title, and a countdown to the start. Reminder
During the event - a red calendar, the title, a countdown to the end and a red BUSY ... Status with the event name
Marked "Maybe" (tentative): a yellow calendar and a yellow BUSY, in the reminder and during the event, so you can tell it from one you accepted. Status of a tentative event
... or one of the bar's own statuses, such as meeting, on_call or dnd. Meeting status
Long titles scroll beside the icon. Scrolling title
Titles in other scripts are transliterated for the bar's bitmap fonts (Планування спринту becomes Planuvannya sprintu) ... Transliterated title
... or drawn as they are with title_font = "global", on one line beside the countdown. Cyrillic title in the global font
Event name hidden for a calendar (show_name = false). Hidden name

Like Google events, these go in above an active BUSY session, so they show on top of any status. A sound plays as a reminder opens and as an event starts, using the bar's own calendar sounds. After the event, the bar goes back to whatever it was showing.

Features

  • CalDAV with discovery (/.well-known/caldav, principal, calendar home), or a calendar URL directly; Basic or Digest auth.
  • Calendar filters: include / exclude by name, id or URL, with wildcards (exclude = ["Room *"]).
  • ICS / webcal feeds, fetched with If-None-Match.
  • Recurring events expanded locally (RRULE, EXDATE, moved occurrences), timezones and floating times handled.
  • Google's per-calendar settings (reminder time, sound, show name, status), set once as defaults and overridden per source or per calendar.
  • Rules by title, from any calendar: keep Focus time off the bar, show Lunch* as the bar's own lunch status.
  • Overlaps settled by your response: an event you accepted wins over one you marked tentative, which wins over one you have not answered - when they start together and when they are shifted against each other, so a "maybe" never covers a meeting you said yes to; then, as in Google's integration, the newest event wins.
  • "Maybe" in yellow: an event you marked tentative gets a yellow calendar and a yellow BUSY, so you can tell it from one you accepted (display.tentative_yellow, default on).
  • Google's filters: no all-day or 24-hour-plus events. Also skipped: cancelled events, events marked free, invitations you declined.
  • On time: the loop wakes at the next change, not on the next poll.
  • Live config: the running service applies an edited config within a minute; an edit that does not load is left out, and the log says why.
  • Resilient: a failing source keeps its last good events; a bar that cannot be reached is retried; drawings expire on the bar by themselves if the service stops.
  • No secrets in files: passwords and tokens come from the environment or a command such as the macOS Keychain.

Get started

uv tool install git+https://github.com/kurok/busybar-calendar
busybar-calendar init     # a few questions: how to reach the bar, which calendar
busybar-calendar check    # proves it works, or says what to fix
busybar-calendar install  # runs it in the background from now on

init asks how to reach the bar (BUSY Cloud, Wi-Fi or USB) and which calendar to read, keeps the passwords in the macOS Keychain (libsecret on Linux), signs in so you can pick calendars, and writes ~/.config/busybar-calendar/config.toml. To write the config yourself, start from config.example.toml.

Everyday commands

Command
busybar-calendar Where you stand: the first steps before setup, the status after.
busybar-calendar status Is it running, what did it last log, what is on the bar now and next.
busybar-calendar check The bar, its token and every calendar, with what to fix.
busybar-calendar add Another calendar, by the same questions as init.
busybar-calendar restart After a new password or a [bar] change; other config edits apply by themselves within a minute.
busybar-calendar upcoming The events the bar will show, and when; tentative ones marked.
busybar-calendar calendars What each CalDAV account has, with ids, for include.
busybar-calendar replay Tomorrow's events on the bar, a minute each.
busybar-calendar demo A sample reminder on the bar, to see the look.
busybar-calendar uninstall Stop and remove the background service.

status and check also take --json, for scripts. A newer version is uv tool upgrade busybar-calendar && busybar-calendar restart - or, with busybar-calendar install --auto-update, the service fetches it by itself every time it starts or restarts.

As a BUSY Bar gallery app

app.py follows the community gallery's conventions: one entrypoint, --host (default 10.0.4.20, the bar over USB), secrets from the environment only. It runs from a clone:

git clone https://github.com/kurok/busybar-calendar.git && cd busybar-calendar
pip install -r requirements.txt
python app.py                        # the config init wrote; a demo schedule before that
python app.py --host 192.168.1.50 --ics "https://example.com/work.ics"
CALDAV_PASSWORD=... python app.py --caldav https://caldav.icloud.com --user me@icloud.com
BUSYBAR_TOKEN=... python app.py --cloud --ics "https://example.com/work.ics"

CALDAV_PASSWORD is the calendar's password, BUSYBAR_TOKEN the bar's HTTP API password or, with --cloud, a BUSY Cloud token. On macOS the first start after installing the requirements takes a few seconds while the system checks the new libraries; later starts take a fraction of a second.

Settings

The per-calendar settings are the Google Calendar integration's:

Google Calendar integration config.toml
Event reminders: on/off reminder.enabled
How long before the event reminder.minutes_before
Sound reminder.sound
Show event name reminder.show_name
Status during events: on/off status.enabled
Event name, or a status from the list status.show: name, meeting, on_call, dnd, ...
Sound status.sound
(not in Google's) Busy status for events you marked tentative status.tentative (default on)
(not in Google's) Yellow calendar and BUSY for events you marked tentative display.tentative_yellow (default on)

They layer: [defaults] for every calendar, then per source, then per calendar ([sources.calendars."Work".reminder]); a default set once is the account-level setting BUSY Cloud does not have yet (busybar-firmware#1083). Last come the [[titles]] rules, which match event titles in any calendar:

[[titles]]
match = ["Focus time", "Commute*"]   # * and ? are wildcards; case does not matter
reminder.enabled = false
status.enabled = false

[display] sets the look: the icons (red_calendar or the bar's own animation), the word beside the countdown (busy_label, busy_color) and whether events you marked tentative are drawn in yellow (tentative_yellow). Every option, with its default, is in the configuration reference.

Documentation

  • Setup - the bar, calendar providers, secrets, running as a service.
  • Troubleshooting - what check and status say when something is wrong, and the fix.
  • Configuration reference - every option and its default, generated from the code.
  • Changelog - what changed in each version.
  • How it works - the design, the API calls, and what was measured on the hardware.

Development

uv sync
uv run pytest
uv run ruff check . && uv run ruff format --check .

scripts/capture_screens.py regenerates docs/images from a real bar.

License

MIT, except the red and yellow calendar icons in src/busybar_calendar/icons.py: they are redrawn from Google's Noto Emoji 📅 and stay under the SIL Open Font License 1.1 (LICENSES/noto-emoji-OFL.txt).

Declared Dependencies

# busylib: Flipper's official BUSY Bar client (pre-approved) - LAN and BUSY Cloud.
busylib>=2.6,<3
# The calendar side, which the standard library cannot do safely or correctly:
# httpx speaks CalDAV's PROPFIND/REPORT with Basic and Digest auth,
httpx>=0.27
# icalendar parses the events and recurring-ical-events expands RRULE/EXDATE
# and moved occurrences in their own timezones,
icalendar>=6
recurring-ical-events>=3.3
# defusedxml parses the server's XML without entity expansion attacks,
defusedxml>=0.7
# anyascii turns titles in other scripts into text the bar's bitmap fonts draw.
anyascii>=0.3

Source Files

These are the mirrored source bytes. The complete package also includes every asset and supporting file.

CHANGELOG.md
# Changelog

All notable changes to busybar-calendar. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
[Semantic Versioning](https://semver.org/).

## [Unreleased]

## [0.8.0] - 2026-10-20

### Added

- Events you marked tentative ("Maybe") are drawn in yellow: a yellow calendar
  and a yellow `BUSY`, in the reminder and during the event, so they read
  differently from the events you accepted. `[display] tentative_yellow = false`
  draws them in red like the rest. With `reminder_icon` / `event_icon` set to
  `animation` only the label turns yellow, as the bar's own animation has no
  yellow version.

### Changed

- `scripts/capture_screens.py` draws the tentative status for the README, and
  its docs now say that a capture takes a running service's screen down until
  the service redraws.
- The screen is redrawn when you change your answer to an event that is
  already up - marked "maybe" from your phone, say - at the next refresh,
  not only when the screen changes.
- CI tests Python 3.12, 3.14 and 3.15 (it was 3.11, 3.12 and 3.13), and its
  actions run on Node 24. `requires-python` stays `>=3.11`, which is no
  longer tested.

## [0.7.2] - 2026-10-13

### Fixed

- `restart` on macOS waited up to five minutes when the service had
  started recently: `launchctl kickstart -k` waits out the agent's restart
  throttle. It now unloads the agent and loads it again, which starts it
  at once - and also works for an agent that was not loaded. With
  `--auto-update`, that makes `restart` the quick way to update.

## [0.7.1] - 2026-10-13

### Fixed

- `install` over a running launch agent could fail with
  `launchctl bootstrap ... failed (exit 5)`, leaving the service stopped:
  `launchctl bootout` returns before the old agent is gone. `install` now
  waits for it (up to 10 seconds) before loading the new one.

## [0.7.0] - 2026-10-13

### Added

- `busybar-calendar install --auto-update`: the background service updates
  itself every time it starts - at login, on `restart`, after it stopped -
  with `uv tool upgrade busybar-calendar`, before it reads the config, then
  runs the new version at once. A failed update (no network yet at login)
  starts the version it has. It needs the one-line install from git, and
  `install` says so when it is not that. `status` shows
  `updates itself at each start`, and `--json` has `service.auto_update`.
- `run --auto-update`, what such a service runs.

## [0.6.1] - 2026-10-13

### Fixed

- A CalDAV account lists its calendars again every hour, to find new
  ones; a timeout or server error at that moment no longer drops the whole
  refresh. It goes on with the calendars it knows, says so in the log, and
  lists them again at the next refresh. A refused password still stops the
  service, as before.

## [0.6.0] - 2026-10-13

### Added

- `upcoming` names the `[[titles]]` patterns that apply to each event, so a
  rule that hides or changes one shows why: `(hidden, titles 'Focus*')`.

### Fixed

- `upcoming` no longer lists `status` for a tentative event when
  `status.tentative` is off; it shows only the reminder, as the bar does.

## [0.5.1] - 2026-10-13

### Fixed

- `status`, and `busybar-calendar` on its own, report a config that does
  not load - with its problems and the fact that a running service keeps
  the config it last loaded - instead of stopping at the first error and
  showing nothing about the service. `status` exits 1 then, and its
  `--json` has a `config` block with the path and the problems.

## [0.5.0] - 2026-10-13

### Added

- A running service follows edits to its config: they apply within a
  minute, with the calendars read again at once and the screen redrawn
  (without a second chime). An edit that does not load leaves the service
  on the config it has, and the log says what is wrong; a change under
  `[bar]` waits for `busybar-calendar restart`.

## [0.4.0] - 2026-10-13

### Added

- `[[titles]]` rules: reminder and status settings for the events whose
  title matches (`*` and `?` wildcards, any case), from any calendar, on top
  of the calendar's own - to keep `Focus time` off the bar, or show `Lunch*`
  as the bar's own lunch status. `upcoming` shows what each event ends up
  with, and `demo --title` draws with the rules for that title.

### Fixed

- A bad reminder or status value now says where it is
  (`sources.0.calendars.Team.reminder: minutes_before: ...`), not just which
  key.

## [0.3.2] - 2026-10-12

### Changed

- Install with one line, no clone needed:
  `uv tool install git+https://github.com/kurok/busybar-calendar`, and
  upgrade with `uv tool upgrade busybar-calendar`.

### Fixed

- `status` no longer offers `restart` as the way to a newer version when the
  service runs from another install (a checkout run with `uv run`, say): a
  restart would start the old version again. It names the install to upgrade,
  and the `uv tool upgrade` command when that install came from git.

## [0.3.1] - 2026-10-12

### Added

- `status` checks the bar with one light read-only call and says whether it
  answers, refuses the token, or does not answer at all - with what to do -
  so a stuck bar no longer looks like a healthy setup.

### Fixed

- Event titles are capped at 120 characters (a longer one only scrolls
  longer). A draw request with a title of thousands of characters left a
  bar's HTTP API hanging, on the LAN and through BUSY Cloud, until the bar
  was power-cycled.

## [0.3.0] - 2026-10-12

### Changed

- `init` and `add` ask for the calendar provider from a list - iCloud,
  Fastmail, PrivateEmail, Nextcloud or another server - guessed from your
  address, instead of for the server's URL; Nextcloud needs only its host.

### Added

- `busybar-calendar` on its own says where you stand: the first steps before
  there is a config, the status after, instead of a usage error.
- `status` shows the version the background service runs, read from the line
  it now logs at start, and says to `restart` when that is not the version
  installed; the setup guide has an "Upgrading" section.
- Tentative events in published feeds, which carry no attendees, now count as
  tentative: Outlook's busy status or `STATUS:TENTATIVE` decides there, for
  overlaps and for `status.tentative`.

### Fixed

- A Keychain / libsecret prompt that failed - a mistyped retype - ended
  `init` and `add` with a traceback; it is now asked again, up to three times,
  then reported in one line.
- A failing `launchctl` or `systemctl` ended `install` with a traceback; it is
  now one line naming the command and its exit code.
- The service remembered every screen it had chimed for; it now forgets
  them once their events are gone, so a long-running service does not grow.
- A mistyped `sync.timezone` ended every command in a traceback; it is now
  rejected when the config is read, with an example of a valid name.
- `calendars` and `replay` ended in a traceback when a calendar server could
  not be reached; they now say which address and why.
- A config mistake printed pydantic's whole report; it is now one line per
  problem, naming the key (`bar.adress: Extra inputs are not permitted`), in
  the CLI and in `app.py` alike.
- `app.py --minutes 0` (and other invalid reminder or status settings given
  as flags) was accepted and only failed at the first event; it now fails at
  start.

## [0.2.0] - 2026-10-12

### Added

- `busybar-calendar add`: another calendar source by the same questions as
  `init`, appended to the config with its comments and layout kept, under a
  name that does not clash with the others.
- `--json` for `status` and `check`: the service, the screen now and next,
  and each check's finding with its fields (firmware, calendars, next event),
  with times in ISO 8601.
- `busybar-calendar restart`, for the background service after a new
  password or config.
- [docs/troubleshooting.md](docs/troubleshooting.md): what `check` and
  `status` say for each common problem, from real failures, and the fix.

### Changed

- After `init` or `add`, an installed background service is restarted, so it
  reads the new config.
- `init` and `add` keep each account's password in a Keychain / libsecret
  entry of its own; two accounts on one domain no longer share one.

### Fixed

- `check` called a BUSY Cloud that refused the token "unreachable"; it now
  says the token is wrong, revoked or lacks the BUSY Bar scope.
- For a source whose `include` matched no calendar, `check` added a
  reassuring "no events in the next 7 days"; it now lists the calendars
  there are, and the source's own warnings no longer repeat in its output.

## [0.1.0] - 2026-10-12

The first release.

### Added

- Reminders and a BUSY status on the BUSY Bar from any CalDAV account
  (iCloud, Fastmail, Nextcloud, PrivateEmail, Radicale, ...) or ICS / webcal
  link, drawn through the bar's HTTP API on the LAN or through BUSY Cloud.
- The Google Calendar integration's per-calendar settings - reminder time,
  sound, show name, status during events - layered as defaults, per source
  and per calendar.
- A red calendar icon and a red BUSY beside the countdown during an event;
  the bar's own animations and statuses (`meeting`, `on_call`, ...) as
  options.
- Overlapping events settled by your response: accepted over tentative over
  not answered, then the newest event. `status.tentative` keeps tentative
  events off the busy status.
- Calendar filters: `include` / `exclude` by name, id or URL, with wildcards.
- Commands: `init` (a few questions write the config, passwords go to the
  Keychain or libsecret), `check` (the bar, its token and every calendar),
  `install` / `uninstall` (launchd or systemd), `status` (running? now and
  next), `calendars`, `upcoming`, `demo`, `replay`, `run`, `--version`.
- `app.py`, following the BUSY Bar community gallery's rules: `--host`,
  `--ics`, `--caldav`, `--demo`, `--test`, secrets from the environment.
- Docs with screenshots read back from a real bar, and a configuration
  reference generated from the code.

### Fixed

Found on a bar running firmware 1.2.4, and recorded in
[docs/how-it-works.md](docs/how-it-works.md):

- The countdown overlapped the title: its font sits 4px high in its box.
- The status lost its icon when it replaced a reminder as the reminder's
  `display_until` fired; drawings now outlive their screen by a minute.
- A draw merges by element id, so a new layout could land on top of the old
  one; a clear now comes first only when an element would be left over,
  and a reminder becomes its event without a blink.
- CalDAV discovery turned PROPFIND into GET on a 302 redirect.
- A 401 or 404 from a calendar was retried forever; it now stops the
  service with the reason.
- A refused draw (409) stopped the demo and replay.
- A Ctrl-C during the slow first start printed a traceback.

[Unreleased]: https://github.com/kurok/busybar-calendar/compare/v0.8.0...HEAD
[0.8.0]: https://github.com/kurok/busybar-calendar/compare/v0.7.2...v0.8.0
[0.7.2]: https://github.com/kurok/busybar-calendar/compare/v0.7.1...v0.7.2
[0.7.1]: https://github.com/kurok/busybar-calendar/compare/v0.7.0...v0.7.1
[0.7.0]: https://github.com/kurok/busybar-calendar/compare/v0.6.1...v0.7.0
[0.6.1]: https://github.com/kurok/busybar-calendar/compare/v0.6.0...v0.6.1
[0.6.0]: https://github.com/kurok/busybar-calendar/compare/v0.5.1...v0.6.0
[0.5.1]: https://github.com/kurok/busybar-calendar/compare/v0.5.0...v0.5.1
[0.5.0]: https://github.com/kurok/busybar-calendar/compare/v0.4.0...v0.5.0
[0.4.0]: https://github.com/kurok/busybar-calendar/compare/v0.3.2...v0.4.0
[0.3.2]: https://github.com/kurok/busybar-calendar/compare/v0.3.1...v0.3.2
[0.3.1]: https://github.com/kurok/busybar-calendar/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/kurok/busybar-calendar/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/kurok/busybar-calendar/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/kurok/busybar-calendar/releases/tag/v0.1.0
LICENSES/noto-emoji-OFL.txt
Copyright 2013 Google LLC

This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL


-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------

PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.

The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded, 
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.

DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.

"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).

"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).

"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.

"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.

PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:

1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.

2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.

3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.

4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.

5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.

TERMINATION
This license becomes null and void if any of the above conditions are
not met.

DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
README.md
# busybar-calendar

Calendar reminders for the [BUSY Bar](https://busy.app) from **any CalDAV
account or ICS feed**, shown exactly the way the bar shows its built-in
Google Calendar events.

![A reminder on the BUSY Bar: calendar icon, title and countdown](docs/images/reminder.gif)

The bar's own [Google Calendar integration](https://docs.busy.app/bar/apps-and-integrations/google-calendar)
runs in BUSY Cloud and only talks to Google. This does the same thing for
iCloud, Fastmail, Nextcloud, PrivateEmail, self-hosted CalDAV servers, and
any calendar published as an ICS link (Outlook, Google, Proton). It runs on
a machine of yours and draws through the bar's
[HTTP API](https://docs.busy.app/bar/dev/http-api), via BUSY Cloud or on the
LAN. The events look the same as Google's; only where they come from is
different. Requested upstream as
[busybar-firmware#1086](https://github.com/busy-app/busybar-firmware/issues/1086).

## On the bar

Every picture here is a frame read back from a real bar (firmware 1.2.4)
through `GET /api/screen`.

| | |
| --- | --- |
| **Before the event** - a red calendar, the title, and a countdown to the start. | ![Reminder](docs/images/reminder.gif) |
| **During the event** - a red calendar, the title, a countdown to the end and a red **BUSY** ... | ![Status with the event name](docs/images/status.png) |
| **Marked "Maybe"** (tentative): a yellow calendar and a yellow **BUSY**, in the reminder and during the event, so you can tell it from one you accepted. | ![Status of a tentative event](docs/images/status-tentative.png) |
| ... or one of the bar's own statuses, such as `meeting`, `on_call` or `dnd`. | ![Meeting status](docs/images/status-meeting.gif) |
| **Long titles** scroll beside the icon. | ![Scrolling title](docs/images/long-title.gif) |
| **Titles in other scripts** are transliterated for the bar's bitmap fonts (`Планування спринту` becomes `Planuvannya sprintu`) ... | ![Transliterated title](docs/images/cyrillic-transliterated.gif) |
| ... or drawn as they are with `title_font = "global"`, on one line beside the countdown. | ![Cyrillic title in the global font](docs/images/cyrillic-global.gif) |
| **Event name hidden** for a calendar (`show_name = false`). | ![Hidden name](docs/images/hidden-name.png) |

Like Google events, these go in above an active BUSY session, so they show
on top of any status. A sound plays as a reminder opens and as an event
starts, using the bar's own calendar sounds. After the event, the bar goes
back to whatever it was showing.

## Features

- **CalDAV** with discovery (`/.well-known/caldav`, principal, calendar home),
  or a calendar URL directly; Basic or Digest auth.
- **Calendar filters**: `include` / `exclude` by name, id or URL, with
  wildcards (`exclude = ["Room *"]`).
- **ICS / webcal** feeds, fetched with `If-None-Match`.
- **Recurring events** expanded locally (RRULE, EXDATE, moved occurrences),
  timezones and floating times handled.
- **Google's per-calendar settings** (reminder time, sound, show name,
  status), set once as defaults and overridden per source or per calendar.
- **Rules by title**, from any calendar: keep `Focus time` off the bar,
  show `Lunch*` as the bar's own lunch status.
- **Overlaps settled by your response**: an event you accepted wins over
  one you marked tentative, which wins over one you have not answered - when
  they start together and when they are shifted against each other, so a
  "maybe" never covers a meeting you said yes to; then, as in Google's
  integration, the newest event wins.
- **"Maybe" in yellow**: an event you marked tentative gets a yellow
  calendar and a yellow BUSY, so you can tell it from one you accepted
  (`display.tentative_yellow`, default on).
- **Google's filters**: no all-day or 24-hour-plus events. Also skipped:
  cancelled events, events marked free, invitations you declined.
- **On time**: the loop wakes at the next change, not on the next poll.
- **Live config**: the running service applies an edited config within a
  minute; an edit that does not load is left out, and the log says why.
- **Resilient**: a failing source keeps its last good events; a bar that
  cannot be reached is retried; drawings expire on the bar by themselves
  if the service stops.
- **No secrets in files**: passwords and tokens come from the environment
  or a command such as the macOS Keychain.

## Get started

```sh
uv tool install git+https://github.com/kurok/busybar-calendar
busybar-calendar init     # a few questions: how to reach the bar, which calendar
busybar-calendar check    # proves it works, or says what to fix
busybar-calendar install  # runs it in the background from now on
```

`init` asks how to reach the bar (BUSY Cloud, Wi-Fi or USB) and which
calendar to read, keeps the passwords in the macOS Keychain (libsecret on
Linux), signs in so you can pick calendars, and writes
`~/.config/busybar-calendar/config.toml`. To write the config yourself,
start from [`config.example.toml`](config.example.toml).

## Everyday commands

| Command | |
| --- | --- |
| `busybar-calendar` | Where you stand: the first steps before setup, the status after. |
| `busybar-calendar status` | Is it running, what did it last log, what is on the bar now and next. |
| `busybar-calendar check` | The bar, its token and every calendar, with what to fix. |
| `busybar-calendar add` | Another calendar, by the same questions as `init`. |
| `busybar-calendar restart` | After a new password or a `[bar]` change; other config edits apply by themselves within a minute. |
| `busybar-calendar upcoming` | The events the bar will show, and when; tentative ones marked. |
| `busybar-calendar calendars` | What each CalDAV account has, with ids, for `include`. |
| `busybar-calendar replay` | Tomorrow's events on the bar, a minute each. |
| `busybar-calendar demo` | A sample reminder on the bar, to see the look. |
| `busybar-calendar uninstall` | Stop and remove the background service. |

`status` and `check` also take `--json`, for scripts. A newer version is
`uv tool upgrade busybar-calendar && busybar-calendar restart` - or, with
`busybar-calendar install --auto-update`, the service fetches it by itself
every time it starts or restarts.

## As a BUSY Bar gallery app

`app.py` follows the [community gallery](https://github.com/maxswinkels/busybar-apps)'s
conventions: one entrypoint, `--host` (default `10.0.4.20`, the bar over
USB), secrets from the environment only. It runs from a clone:

```sh
git clone https://github.com/kurok/busybar-calendar.git && cd busybar-calendar
pip install -r requirements.txt
python app.py                        # the config init wrote; a demo schedule before that
python app.py --host 192.168.1.50 --ics "https://example.com/work.ics"
CALDAV_PASSWORD=... python app.py --caldav https://caldav.icloud.com --user me@icloud.com
BUSYBAR_TOKEN=... python app.py --cloud --ics "https://example.com/work.ics"
```

`CALDAV_PASSWORD` is the calendar's password, `BUSYBAR_TOKEN` the bar's HTTP
API password or, with `--cloud`, a BUSY Cloud token. On macOS the first
start after installing the requirements takes a few seconds while the system
checks the new libraries; later starts take a fraction of a second.

## Settings

The per-calendar settings are the Google Calendar integration's:

| Google Calendar integration | `config.toml` |
| --- | --- |
| Event reminders: on/off | `reminder.enabled` |
| How long before the event | `reminder.minutes_before` |
| Sound | `reminder.sound` |
| Show event name | `reminder.show_name` |
| Status during events: on/off | `status.enabled` |
| Event name, or a status from the list | `status.show`: `name`, `meeting`, `on_call`, `dnd`, ... |
| Sound | `status.sound` |
| *(not in Google's)* Busy status for events you marked tentative | `status.tentative` (default on) |
| *(not in Google's)* Yellow calendar and BUSY for events you marked tentative | `display.tentative_yellow` (default on) |

They layer: `[defaults]` for every calendar, then per source, then per
calendar (`[sources.calendars."Work".reminder]`); a default set once is the
account-level setting BUSY Cloud does not have yet
([busybar-firmware#1083](https://github.com/busy-app/busybar-firmware/issues/1083)).
Last come the `[[titles]]` rules, which match event titles in any calendar:

```toml
[[titles]]
match = ["Focus time", "Commute*"]   # * and ? are wildcards; case does not matter
reminder.enabled = false
status.enabled = false
```

`[display]` sets the look: the icons (`red_calendar` or the bar's own
`animation`), the word beside the countdown (`busy_label`, `busy_color`) and
whether events you marked tentative are drawn in yellow (`tentative_yellow`).
Every option, with its default, is in the
[configuration reference](docs/configuration.md).

## Documentation

- [Setup](docs/setup.md) - the bar, calendar providers, secrets, running as a service.
- [Troubleshooting](docs/troubleshooting.md) - what `check` and `status` say when something is wrong, and the fix.
- [Configuration reference](docs/configuration.md) - every option and its default, generated from the code.
- [Changelog](CHANGELOG.md) - what changed in each version.
- [How it works](docs/how-it-works.md) - the design, the API calls, and what was measured on the hardware.

## Development

```sh
uv sync
uv run pytest
uv run ruff check . && uv run ruff format --check .
```

`scripts/capture_screens.py` regenerates `docs/images` from a real bar.

## License

MIT, except the red and yellow calendar icons in `src/busybar_calendar/icons.py`:
they are redrawn from Google's [Noto Emoji](https://github.com/googlefonts/noto-emoji)
📅 and stay under the SIL Open Font License 1.1
([LICENSES/noto-emoji-OFL.txt](LICENSES/noto-emoji-OFL.txt)).
app.py
#!/usr/bin/env python3
"""CalDAV Calendar: meeting reminders and a red BUSY from any CalDAV or ICS calendar.

A countdown before each event, then the event's title with a countdown to its
end and a red BUSY while it runs; the bar is left alone between events.
CalDAV (iCloud, Fastmail, Nextcloud, PrivateEmail, Radicale, ...) or any ICS
link (Outlook "Publish a calendar", Google's secret iCal address).

    python app.py                                  # demo schedule, BUSY Bar over USB (10.0.4.20)
    python app.py --host 127.0.0.1:8080            # emulator or a Wi-Fi bar
    python app.py --ics https://example.com/work.ics
    python app.py --caldav https://caldav.icloud.com --user me@icloud.com --include Work
    python app.py --config config.toml             # every setting: see config.example.toml
                                                   # (without flags, the config that
                                                   # `busybar-calendar init` wrote is used)
    python app.py --test                           # draw one frame of an event and exit

Secrets come from the environment, never the command line or this file:

    CALDAV_PASSWORD   the CalDAV password (iCloud and Fastmail need an app password)
    BUSYBAR_TOKEN     the bar's HTTP API password, if its Wi-Fi API asks for one;
                      with --cloud, a BUSY Cloud token with the "BUSY Bar" scope

The calendar is polled every 5 minutes. A 401 or 404 from it stops the app
with an error rather than retrying. Drawings go in at priority 91, above a
running BUSY session (90), the way the bar's own calendar events do; between
events nothing is drawn, so the bar is never held.
"""

# First of all: Ctrl-C before the app is up ends it quietly. The first start
# after an install can take seconds while the system checks the newly installed
# libraries; once running, every mode handles SIGINT itself and clears the bar.
import signal
import sys

signal.signal(signal.SIGINT, lambda *_: sys.exit(0))

import argparse
import asyncio
import os
from pathlib import Path

# The app's code lives in src/busybar_calendar, next to this file.
sys.path.insert(0, str(Path(__file__).resolve().parent / "src"))

from busybar_calendar import cli, playback
from busybar_calendar.config import Config, ConfigError, find, load

APP = "busybar-calendar"
USB_HOST = "10.0.4.20"


def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    p.add_argument("--host", help=f"the bar: ip[:port] (default {USB_HOST}, USB)")
    p.add_argument("--cloud", action="store_true", help="reach the bar through BUSY Cloud")
    p.add_argument("--ics", action="append", default=[], metavar="URL", help="an ICS feed")
    p.add_argument("--caldav", metavar="URL", help="a CalDAV server or calendar URL")
    p.add_argument("--user", help="CalDAV username, usually the email address")
    p.add_argument(
        "--include",
        action="append",
        default=[],
        metavar="NAME_OR_ID",
        help="use only these CalDAV calendars (* wildcards)",
    )
    p.add_argument(
        "--exclude",
        action="append",
        default=[],
        metavar="NAME_OR_ID",
        help="leave these CalDAV calendars out",
    )
    p.add_argument("--minutes", type=int, default=5, help="reminder before an event (5)")
    p.add_argument("--priority", type=int, default=91, help="draw priority (91)")
    p.add_argument("--quiet", action="store_true", help="no sounds")
    p.add_argument("--config", help="a TOML file with every setting")
    p.add_argument("--demo", action="store_true", help="loop a made-up schedule")
    p.add_argument("--test", action="store_true", help="draw one frame and exit")
    p.add_argument("-v", "--verbose", action="store_true")
    return p.parse_args(argv)


def config_file(args: argparse.Namespace) -> Path | None:
    """The config to use: --config, else - unless calendars are given as flags - the one
    `busybar-calendar init` wrote ($BUSYBAR_CALENDAR_CONFIG, ./config.toml or
    ~/.config/busybar-calendar/config.toml), else none."""
    if args.config:
        return Path(args.config)
    if args.ics or args.caldav:
        return None
    try:
        return find()
    except ConfigError:
        return None


def build_config(args: argparse.Namespace, path: Path | None) -> Config:
    if path is not None:
        config = load(path)
        if args.host:
            config.bar.address = args.host
        return config

    bar: dict = {"application_name": APP, "priority": args.priority}
    if not args.cloud:
        bar["address"] = args.host or USB_HOST
    if os.environ.get("BUSYBAR_TOKEN"):
        bar["token_env"] = "BUSYBAR_TOKEN"
    elif args.cloud:
        raise ConfigError("--cloud needs a BUSY Cloud token in BUSYBAR_TOKEN")

    sources = [
        {"name": f"ics-{n}" if len(args.ics) > 1 else "ics", "type": "ics", "url": url}
        for n, url in enumerate(args.ics, 1)
    ]
    if args.caldav:
        caldav = {
            "name": "caldav",
            "type": "caldav",
            "url": args.caldav,
            "include": args.include,
            "exclude": args.exclude,
        }
        if args.user:
            caldav |= {"username": args.user, "password_env": "CALDAV_PASSWORD"}
        sources.append(caldav)

    sound = not args.quiet
    # --minutes 0 fails here, not at the first event
    return Config.model_validate(
        {
            "bar": bar,
            "defaults": {
                "reminder": {"minutes_before": args.minutes, "sound": sound},
                "status": {"sound": sound},
            },
            "sources": sources,
        }
    )


def main(argv: list[str] | None = None) -> int:
    args = parse_args(argv)
    cli.setup_logging(args.verbose)
    try:
        path = config_file(args)
        config = build_config(args, path)
        if args.test:
            return asyncio.run(playback.draw_test_frame(config))
        if args.demo or not config.sources:
            if not args.demo:
                print(
                    "No calendar yet (--ics, --caldav, or a config from `busybar-calendar "
                    "init`): playing a demo schedule.",
                    file=sys.stderr,
                )
            return asyncio.run(playback.run_demo(config))
        where = config.bar.address or "BUSY Cloud"
        print(
            f"{APP} -> {where}{f', config {path}' if path else ''}  (Ctrl-C to stop)",
            file=sys.stderr,
        )
        return asyncio.run(cli.run_service(config))
    except cli.EXPECTED as error:
        for line in cli.describe(error):
            print(f"{APP}: {line}", file=sys.stderr)
        return 2


if __name__ == "__main__":
    sys.exit(main())
config.example.toml
# An example busybar-calendar config. `busybar-calendar init` writes one for you;
# every option, with its default, is in docs/configuration.md.

[bar]
# Leave out `address` to reach the bar through BUSY Cloud, with a token that has
# the "BUSY Bar" access scope. On your network: address = "192.168.1.50" and the
# bar's HTTP API password as the token.
token_command = ["security", "find-generic-password", "-s", "busybar-calendar-bar", "-w"]

[defaults.reminder]
minutes_before = 5

[defaults.status]
# Events you marked tentative ("maybe") show as busy too; false keeps only
# their reminder. They are drawn in yellow; [display] tentative_yellow = false
# turns that off.
tentative = true

# A CalDAV account: iCloud, Fastmail, Nextcloud, PrivateEmail, Radicale, ...
[[sources]]
name = "iCloud"
type = "caldav"
url = "https://caldav.icloud.com"
username = "me@icloud.com"
password_command = ["security", "find-generic-password", "-s", "busybar-calendar-icloud", "-w"]
# Which calendars: names or ids from `busybar-calendar calendars`, * wildcards.
include = ["Home", "Work"]

# One calendar shown as the bar's own "meeting" status, with an earlier reminder.
[sources.calendars.Work.reminder]
minutes_before = 10

[sources.calendars.Work.status]
show = "meeting"

# A published ICS / webcal link: Outlook's "Publish a calendar", Google's secret
# address in iCal format, a Proton share link.
[[sources]]
name = "Team"
type = "ics"
url = "webcal://outlook.office365.com/owa/calendar/.../calendar.ics"

[sources.reminder]
sound = false

# Settings by event title, from any calendar, on top of the calendar's own.
# `*` and `?` are wildcards; case does not matter.
[[titles]]
match = ["Focus time", "Commute*"]
reminder.enabled = false
status.enabled = false

[[titles]]
match = "Lunch*"
status.show = "lunch"
docs/configuration.md
# Configuration reference

The config is a TOML file. `busybar-calendar init` writes one; it is found
in `~/.config/busybar-calendar/config.toml`, `./config.toml`, or wherever
`$BUSYBAR_CALENDAR_CONFIG` or `-c` points. Only `[[sources]]` is required;
everything else has the default shown.

Secrets - `token` and `password` - take one of three forms. Prefer the last
two, which keep the secret out of the file:

```toml
password = "..."                                  # inline
password_env = "CALDAV_PASSWORD"                  # an environment variable
password_command = ["security", "find-generic-password", "-s", "my-calendar", "-w"]
```

The command is run without a shell, and its output is the secret.

*Generated from the code by `python -m busybar_calendar.reference`; edit the
descriptions in `config.py`, not this file.*

## `[bar]`

How to reach the bar.

| Key | Default | |
| --- | --- | --- |
| `address` | none | The bar on your network (`ip[:port]`; `10.0.4.20` over USB). Leave it out to reach the bar through BUSY Cloud. |
| `token` / `token_env` / `token_command` | none | With an address, the bar's HTTP API password (32 characters at most); without, a BUSY Cloud token with the "BUSY Bar" access scope. |
| `priority` | `91` | Draw priority: 91 is above a running BUSY session (90), like the bar's own calendar events. |
| `application_name` | `"busybar-calendar"` | The name drawings are made under; also the app's id in the community gallery. |

## `[sync]`

How the calendars are read.

| Key | Default | |
| --- | --- | --- |
| `poll_minutes` | `5` | How often calendars are read. |
| `lookahead_hours` | `24` | How far ahead events are read. |
| `skip_free` | `true` | Leave out events marked free (`TRANSP:TRANSPARENT`). |
| `timezone` | none | For event times that carry no timezone, e.g. `Europe/Kyiv`. Default: this machine's. |

## `[display]`

What the bar shows.

| Key | Default | |
| --- | --- | --- |
| `title_font` | `"small"` | `small` fits the title and countdown on two lines; `global` draws Cyrillic, CJK and accents, on one line beside the countdown. |
| `transliterate` | `true` | Fold titles to ASCII for the bitmap fonts (`Планування` -> `Planuvannya`). Not used with `global`. |
| `reminder_sound` | `"shared/sounds/calendar_reminder_ends.snd"` | The stock sound a reminder plays. |
| `event_sound` | `"shared/sounds/calendar_event_starts.snd"` | The stock sound an event's start plays. |
| `reminder_icon` | `"red_calendar"` | The icon before an event: the red calendar, or the bar's own animation. |
| `event_icon` | `"red_calendar"` | The icon during an event: the red calendar, or the bar's own animation. |
| `busy_label` | `"BUSY"` | The word beside the countdown during an event; `""` for none. Not drawn with the `global` font. |
| `busy_color` | `"#FF0000FF"` | Its colour, `#RRGGBB` or `#RRGGBBAA`. |
| `tentative_yellow` | `true` | Draw events you marked tentative in yellow - the calendar icon and the label - so they differ from accepted ones. Off, they look the same. The icon is yellow only as the `red_calendar`; the bar's own animation has no yellow version. |

## `[defaults.reminder]`

The countdown before an event: the settings the bar's Google Calendar integration has per calendar. They layer: `[defaults.reminder]`, then a source's `[sources.reminder]`, then one calendar's `[sources.calendars."<name or id>".reminder]`, then the `[[titles]]` rules that match the event's title.

| Key | Default | |
| --- | --- | --- |
| `enabled` | `true` | Show a reminder before events. |
| `minutes_before` | `5` | How long before the start it appears. |
| `sound` | `true` | Play the reminder sound as it appears. |
| `show_name` | `true` | Show the event's title; off, it says "Next event". |

## `[defaults.status]`

What the bar shows while an event is on. It layers the same way: `[defaults.status]`, `[sources.status]`, `[sources.calendars."<name or id>".status]`, `[[titles]]`.

| Key | Default | |
| --- | --- | --- |
| `enabled` | `true` | Show the event while it is on. |
| `show` | `"name"` | `name`: the red calendar, the title, a countdown and BUSY. Or one of the bar's own statuses: `back_soon`, `booked`, `chill_time`, `coding`, `dnd`, `flow`, `keep_out`, `low_social_battery`, `lunch`, `meeting`, `on_air`, `on_call`. |
| `sound` | `true` | Play the event sound as it starts. |
| `tentative` | `true` | Events you marked tentative show as busy too. Off, they keep their reminder only. |

## `[[sources]]`

One CalDAV account or one ICS link each; at least one.

| Key | Default | |
| --- | --- | --- |
| `name` | **required** | What logs and overrides call this source; unique. |
| `type` | **required** | `caldav` for an account, `ics` for a published link (one calendar). |
| `url` | **required** | CalDAV: the server, or one calendar's URL. ICS: the link; `webcal://` works. |
| `username` | none | Usually the email address. |
| `password` / `password_env` / `password_command` | none | iCloud and Fastmail need an app password. |
| `auth` | `"basic"` | HTTP authentication; a few servers want `digest`. |
| `emails` | `[]` | Your attendee address(es), to read your response to invitations. Default: the username, when it is an email address. |
| `include` | `[]` | CalDAV: only these calendars, by name, id or URL; `*` and `?` are wildcards. Default: all. The one listed last wins an overlap. |
| `exclude` | `[]` | CalDAV: leave these calendars out, after include. |
| `calendars` | `{}` | Per-calendar reminder and status overrides, keyed by name or id. |

## `[[titles]]`

Settings for the events whose title matches, from whichever calendar: a
rule's `reminder` and `status` go on top of the calendar's, and a later rule
on top of an earlier one. To keep some events off the bar, and show lunch as
the bar's own status:

```toml
[[titles]]
match = ["Focus time", "Commute*"]
reminder.enabled = false
status.enabled = false

[[titles]]
match = "Lunch*"
reminder.enabled = false
status.show = "lunch"
```

`busybar-calendar upcoming` shows what each event ends up with.

| Key | Default | |
| --- | --- | --- |
| `match` | **required** | The titles it applies to: one or a list; `*` and `?` are wildcards, and case does not matter. |
docs/how-it-works.md
# How it works

## Why a service, and not an app on the bar

The bar's Google Calendar integration lives in BUSY Cloud, which is not
open source. Reading the firmware shows how it reaches the bar: the cloud
sends ordinary HTTP API requests through an MQTT proxy on the device
(`mqtt_http_proxy.c`). There is no calendar endpoint, on the bar or in the
cloud's device API. A Google event on the bar is made of the same requests
anyone can send:

- `POST /api/display/draw` with the stock calendar animations, a text and a
  countdown element;
- `POST /api/audio/play` with the stock calendar sounds;
- `DELETE /api/display/draw` when it is over.

So anything that makes those requests gets the same events on screen. This
service does that, with a CalDAV account or an ICS feed in place of Google.

A JavaScript app on the bar cannot do the same thing. The firmware runs one
JS app at a time and stops it when you leave it, so it could not put a
reminder on top of a BUSY session or the clock the way the integration does.

```
 CalDAV server ─┐
 ICS feed ──────┤   busybar-calendar                         BUSY Bar
                ├─► sources ─► schedule ─► render ─► runner ─► HTTP API
 (poll)         │   (events)   (what now)  (elements) (draw,    via BUSY Cloud
                                                        clear,  (Bearer token)
                                                        sound)  or the LAN
                                                                (X-API-Token)
```

## The pieces

| Module | Does |
| --- | --- |
| `caldav.py` | Discovery (`/.well-known/caldav`, `current-user-principal`, `calendar-home-set`) and `calendar-query` REPORTs with a time range. Hrefs are joined against the URL that answered, which is what makes iCloud's per-account hosts work. |
| `sources.py` | One class per source type. CalDAV calendars are listed again every 12 refreshes; ICS feeds use `If-None-Match`. |
| `events.py` | iCalendar to events: recurrence expanded with `recurring-ical-events`, times made timezone-aware, and the filters applied. |
| `schedule.py` | Pure logic: which screen is due now, and when that next changes. |
| `render.py` | The display elements for a screen, and its sound. |
| `runner.py` | The loop: refresh, decide, draw or clear, sleep until the next change. |
| `icons.py` | The red and yellow calendars, as XPM2 images drawn inline. |
| `config.py` | The TOML config, its models and where it is found; each option's description lives here. |
| `connect.py` | What every command shares: the bar client, the sources, stopping on a signal. |
| `diagnose.py` | `check` and `status`, as text or JSON. |
| `playback.py` | Showing events outside the schedule: `replay`, the demo, the gallery's test frame. |
| `wizard.py` | `init` and `add`: the questions, the secrets, the config they write. |
| `service.py` | `install`, `uninstall`, the service's state and log: launchd or systemd. |
| `reference.py` | Generates the configuration reference from `config.py`. |
| `cli.py` | The command line: the commands, their options, which code each runs. |

## Rules

Each event can open two screens. A **reminder** runs from `minutes_before`
until the start, and a **status** runs from the start to the end.

- When several screens are due at once:
  1. **your response decides first**: an event you accepted beats one you
     marked tentative, which beats one you have not answered. Events of
     your own, with no attendees, count as accepted. Your response is your
     own attendee entry's `PARTSTAT`, found by your address (`emails`, which
     defaults to the CalDAV username), and it counts even on events you
     organise. Published feeds leave the attendees out; there the event's
     own marking counts instead, Outlook's `X-MICROSOFT-CDO-BUSYSTATUS:TENTATIVE`
     or `STATUS:TENTATIVE`;
  2. then the event starting latest, the Google integration's rule ("the
     newest event and the most recently added calendar take priority"), so
     a reminder for your next meeting replaces the status of the one still
     running, as long as you are as sure to attend the next one;
  3. then the calendar configured last.
- The response ranks whole screens, so it holds however two events are
  placed against each other. With Maybe 10:00-11:00 and Sure 10:30-11:30
  the bar shows Maybe, then Sure from its reminder at 10:25 until it
  ends at 11:30 - Maybe's end at 11:00 changes nothing. With Sure
  10:00-11:00 and Maybe 10:30-11:30, Maybe's reminder and start do not
  cut in, and Maybe appears when Sure ends. A tentative event never takes
  the bar from an accepted one that is showing something.
- An event you marked tentative puts the busy status up like any other,
  unless `status.tentative = false`: then it keeps its reminder but never
  shows as busy, and whatever else is on carries on.
- A tentative event is also drawn differently: a yellow calendar and a yellow
  `BUSY`, in its reminder and its status alike, where an accepted or
  unanswered one gets the red ones. `display.tentative_yellow = false`
  draws them all in red. A calendar icon set to the bar's own `animation`
  stays as it is (the stock animation has no yellow version) and only the
  label turns yellow, and a stock status such as `meeting` is never recoloured.
  Changing your answer on your phone redraws the screen at the next refresh.
- Like Google: events of 24 hours or more, and all-day events, are not
  shown.
- Also skipped: `STATUS:CANCELLED`, `TRANSP:TRANSPARENT` (free) unless
  `skip_free = false`, and invitations you declined or delegated.
- A sound plays when a screen opens, but not when the service starts in
  the middle of one: restarting during a meeting does not chime again.

## Drawing

| Element | Reminder | Status (`show = "name"`) | Status (`show = "meeting"` etc.) |
| --- | --- | --- | --- |
| `cal_xpm` | the red calendar (default) | the red calendar (default) | |
| `cal_xpm_maybe` | the yellow calendar, for a tentative event | the yellow calendar, for a tentative event | |
| `cal_icon` | `shared/animations/calendar_reminder_16x16.anim` with `reminder_icon = "animation"` | `shared/animations/calendar_event_16x16.anim` with `event_icon = "animation"` | |
| `cal_title` | title, `small` font, scrolls if long | title | |
| `cal_time` | countdown to the start | countdown to the end | |
| `cal_busy` | | `BUSY` in red (yellow for a tentative event), right-aligned on the countdown's line | |
| `cal_status` | | | `shared/animations/<status>_72x16.anim` |

The calendar travels inside each drawing as an XPM2 `xpmbitmap`
element (the firmware takes up to 32 colours, full colour on the front
display), so nothing is uploaded to the bar. busylib 2.6 has no model for
that element, so drawings are posted with `api_request` rather than
`display_draw`. The icons are redrawn from Noto Emoji's 📅 at 16x16; see
`icons.py` for the pixels and their licence. The yellow one has an element id of its own
(`cal_xpm_maybe`), so a switch between the colours clears and redraws the
icon instead of depending on the bar swapping an existing bitmap's pixels.

Sounds: `shared/sounds/calendar_reminder_ends.snd` when a reminder opens and
`shared/sounds/calendar_event_starts.snd` when an event starts. Both can be
changed under `[display]`.

Drawings go in at priority 91. The firmware uses 10 for its built-in apps
and 90 for an active BUSY session, so like Google events these appear above
a running session. Every element carries `display_until`, so the bar takes
the drawing down by itself if the service stops.

## Measured on the hardware

The layout and timing were checked on a bar running firmware 1.2.4 / API
27.5.0, by drawing each screen and reading the pixels back with
`GET /api/screen` (the source of the pictures in the README). That turned
up things the API description does not say:

- **The countdown sits high in its box.** Countdown elements use the 7px
  `superscript` font, which puts its digits 4px above the bottom of the
  element. Anchored `bottom_left` at y=16, the digits landed on rows 7-11,
  into the title's descenders. At y=19 they land on rows 10-14, level with
  the icon's last row. Beside a `global`-font title, the countdown sits at
  y=9 so both share rows 6-10.
- **A redraw at the moment of expiry loses its animation.** Redrawing an
  element at the same instant its old `display_until` fires drops the
  animated icon (0 of 256 icon pixels lit). With the old expiry even a few
  seconds later, the redraw keeps it. So `display_until` is the screen's
  end plus 60 seconds: the service redraws or clears on time anyway, and
  the extra minute only matters if it has stopped.
- **A draw merges by element id.** It updates the ids it names and leaves
  the others up, so drawing the `meeting` animation after a reminder would
  leave the reminder's elements underneath. The service clears its own
  drawing first only when an element would be left over, and otherwise
  updates in place. A reminder turning into its event only adds BUSY, so
  that change is seamless: sampling the screen across it, clearing first
  caught a half-drawn icon (96 of 234 pixels lit) in 1 of 20 frames, and
  updating in place showed the full icon in all 20.
- **The screen has one owner.** `DELETE /api/display/draw` with this
  application's name, while another application is on screen, answers
  `400 this application_name is currently not displaying anything`. The
  service takes that as "already clear" rather than an error to retry.
- **Text is not ASCII-only.** The published schema limits `text` to
  printable ASCII, but the firmware accepts any text, and the `global` font
  (Lana Pixel 11) draws Cyrillic. The default stays the schema-conforming
  `small` font with transliteration; `title_font = "global"` is opt-in.
- **LAN tokens are 32 characters at most**, and over Wi-Fi nothing but
  `/api/version`, `/api/access` and `/api/transport` answers while the
  HTTP API is disabled.
- Through BUSY Cloud, a reminder, its start and its end each reached the
  bar within half a second of the event's time.

## Limits

- Changes are seen on the next poll (`poll_minutes`, default 5), and ICS
  feeds are only as fresh as their publisher makes them.
- Only the front display is drawn on.
- The bar's own clock decides `display_until` and the countdowns. It keeps
  network time, so this matters only if its clock is off.
docs/setup.md
# Setup

The quick way:

```sh
uv tool install git+https://github.com/kurok/busybar-calendar
busybar-calendar init      # answers in, config out: the bar, the calendar, the passwords
busybar-calendar check     # proves it works, or says what to fix
busybar-calendar install   # runs it in the background from now on
busybar-calendar status    # and later: is it running, and what is on the bar next
```

The rest of this page is what those commands do for you, for when you
want to do it yourself, or need more than one calendar source. Every option
of the config is in the [configuration reference](configuration.md).

1. [Connect to the bar](#connect-to-the-bar)
2. [Add your calendars](#add-your-calendars)
3. [Keep secrets out of files](#keep-secrets-out-of-files)
4. [Check it](#check-it)
5. [Run it as a service](#run-it-as-a-service)
6. [Troubleshooting](troubleshooting.md)

## Connect to the bar

There are two ways to reach the bar. They draw exactly the same things.

### Through BUSY Cloud (simplest)

This is the route the bar's Google Calendar integration takes, and it works
from anywhere, with nothing to enable on the bar.

1. In your BUSY account, create an API token and choose **BUSY Bar** under
   **Access scope**.
2. Store it (see [secrets](#keep-secrets-out-of-files)) and give it to
   `[bar]` without an `address`:

```toml
[bar]
token_command = ["security", "find-generic-password", "-s", "busybar-token", "-w"]
```

It is sent as `Authorization: Bearer ...` to `https://api.busy.app/busybar`.

### On the local network

Faster, and does not depend on the cloud, but the bar's HTTP API over Wi-Fi
is **disabled by default**.

1. Connect the bar to your computer by USB and open `http://10.0.4.20`.
2. On the **Network** tab, in **HTTP API**, choose **Set password and
   enable**. Keep the password to **32 characters or fewer**. The firmware
   refuses longer ones.
3. Find the bar's address: **Settings -> Wi-Fi -> your network -> View IP
   Address**.

```toml
[bar]
address = "192.168.1.50"
token_command = ["security", "find-generic-password", "-s", "busybar-lan", "-w"]
```

The password is sent as `X-API-Token`. To check the bar's mode without a
password: `curl http://<address>/api/access` returns `{"mode":"disabled"}`,
`"enabled"` or `"key"`. While it says `disabled`, every other request over
Wi-Fi gets `403 Forbidden`, whatever token you send. Over USB
(`address = "10.0.4.20"`) no password is needed at all.

## Add your calendars

Each `[[sources]]` entry is one CalDAV account or one ICS feed. A CalDAV
account uses all of its calendars unless you [filter them](#choosing-calendars).

`busybar-calendar add` adds one more by asking the same questions as `init`,
and appends it to your config without touching the rest, then restarts a
background service so it reads the new calendar at once.

A running service also follows edits you make by hand: they apply within a
minute. An edit that does not load leaves it on the config it has, and its
log says what is wrong; a change under `[bar]` waits for
`busybar-calendar restart`.

### CalDAV

| Provider | `url` | Password |
| --- | --- | --- |
| iCloud | `https://caldav.icloud.com` | an app-specific password (account.apple.com -> Sign-In and Security -> App-Specific Passwords) |
| Fastmail | `https://caldav.fastmail.com` | an app password (Settings -> Privacy & Security) |
| Nextcloud | `https://cloud.example.com/remote.php/dav` | your password, or an app password |
| PrivateEmail (Namecheap) | `https://dav.privateemail.com` | your mailbox password |
| Radicale, Baïkal, Synology, others | the server's root URL, or a calendar's URL | as the server wants it |

`username` is usually your full email address. A root URL is enough when
the server supports discovery (`/.well-known/caldav`); otherwise give the
URL of one calendar, and it is used as it is. Servers that only speak
Digest auth need `auth = "digest"`.

```toml
[[sources]]
name = "iCloud"
type = "caldav"
url = "https://caldav.icloud.com"
username = "me@icloud.com"
password_command = ["security", "find-generic-password", "-s", "busybar-icloud", "-w"]
include = ["Home", "Work"]

[sources.calendars.Work.status]
show = "meeting"
```

### Choosing calendars

`busybar-calendar calendars` lists each calendar on the account with its
**id** (the last part of its URL) and **name**, and marks the ones in use:

```
PrivateEmail (caldav): 22 calendars, 1 used
  * 4711   Doe, Jane
    5001   Ops Maintenances
    5002   Infra Maintenances
    6001   Office - Room 1 (6 people)
    ...
```

Two lists filter them. Each entry is a calendar's **name**, its **id**, or
its **URL**, and may use the wildcards `*` and `?`. Matching is exact and
case-sensitive.

| | |
| --- | --- |
| `include` | Use only these calendars. Without it, all of them. |
| `exclude` | Leave these out, after `include`. |

```toml
include = ["Doe, Jane"]                 # by name
include = ["4711"]                     # by id: survives a rename
include = ["Doe, Jane", "* Maintenances"]
exclude = ["Office - Room *", "Room *"]   # everything except the meeting rooms
```

Organisation accounts often expose shared calendars. A PrivateEmail
business mailbox, for example, lists every meeting room and team calendar
next to your own. With no filter, other people's room bookings would reach
your bar, so name your own calendar under `include`.

Calendars come in the order `include` lists them: when two events start at
the same time, the calendar listed last wins. An entry that matches nothing
is reported, by `calendars` and in the service's log.

Per-calendar settings can be keyed by name or by id, and the id wins when
both are given:

```toml
[sources.calendars."4711".reminder]
minutes_before = 10
```

Your answer to each invitation is read from your attendee entry, found by
your address: declined invitations are skipped, and when two events overlap,
the one you accepted wins over one you marked tentative or have not
answered - whether they start together or one is shifted against the other.
An event you marked tentative ("Maybe") is drawn with a yellow calendar and
a yellow BUSY instead of the red ones. The address defaults to `username` when that is an email address;
set `emails = [...]` if your invitations go to another one.

### ICS / webcal feeds

For calendars that only publish a link. A feed is one calendar, named after
its source. `webcal://` links work as they are.

| Calendar | Where to find the link |
| --- | --- |
| Outlook / Microsoft 365 | Settings -> Calendar -> Shared calendars -> **Publish a calendar** -> ICS link |
| Google Calendar | Settings -> the calendar -> Integrate calendar -> **Secret address in iCal format** |
| Proton Calendar | the calendar's menu -> Share -> **Share with anyone** (link) |

```toml
[[sources]]
name = "Outlook"
type = "ics"
url = "https://outlook.office365.com/owa/calendar/.../calendar.ics"
```

A published feed leaves out who is invited, so your own answer to an invitation
is not in it; an event the feed marks tentative (Outlook does, as its busy
status) still counts as tentative. Outlook's Windows timezone names are
understood. A feed is only as fresh as the provider makes it. Outlook and Google can
take a while to republish a change, while CalDAV sees it on the next poll
(`poll_minutes`, default 5).

## Keep secrets out of files

Every password and token takes one of three forms. Prefer the last two:

```toml
password = "..."                       # inline: only for throwaway testing
password_env = "ICLOUD_APP_PASSWORD"   # an environment variable
password_command = ["security", "find-generic-password", "-s", "busybar-icloud", "-w"]
```

The same goes for the bar: `token`, `token_env`, `token_command`. A command
is run without a shell and its output, stripped, is the secret.

On macOS, store a secret in the Keychain from your own terminal. It prompts,
so the secret ends up neither on screen nor in your shell history:

```sh
security add-generic-password -U -s busybar-icloud -a me@icloud.com -w
```

On Linux, `secret-tool` (libsecret) or `pass` work the same way:
`password_command = ["secret-tool", "lookup", "service", "busybar-icloud"]`.

`config.toml` is in `.gitignore`. Keep it that way even with no secrets in
it, since it names your accounts.

## Check it

```sh
busybar-calendar check
```

checks the whole setup and says what to fix:

```
Bar
  ok   BUSY Cloud: firmware 1.2.4, API 27.5.0, token accepted
Calendars
  ok   iCloud: signed in; 2 of 5 calendars used: Home, Work
  ok   iCloud: 23 events in the next 7 days; next Tue 13 Oct 09:00 Standup

All good.
```

A refused bar call comes with its cause: the Wi-Fi HTTP API switched off,
a missing password, or a cloud token given as a LAN password. The other
commands look closer:

```sh
busybar-calendar calendars                 # discovery: calendars per CalDAV source
busybar-calendar upcoming --hours 48       # events, settings, your response, what is on now
busybar-calendar demo                      # a sample reminder for 20 seconds
busybar-calendar demo --status --show meeting
busybar-calendar demo --font global --title "Планування спринту"
busybar-calendar replay                    # tomorrow's real events, one a minute
busybar-calendar replay --day today --seconds 30 --quiet
busybar-calendar -v run                    # run in the foreground with debug logs
```

## Run it as a service

```sh
busybar-calendar install
```

checks the setup, then makes it run in the background from now on: at
login, and again if it stops. On macOS that is a launch agent
(`~/Library/LaunchAgents/app.busybar.calendar.plist`, logging to
`~/Library/Logs/busybar-calendar.log`); on Linux a systemd user unit
(`~/.config/systemd/user/busybar-calendar.service`, logs in
`journalctl --user -u busybar-calendar`).

- It runs from the environment you installed from (a `uv tool` or a
  virtualenv) and reads the config `install` found, wherever you start it.
- Restarts are five minutes apart, so a calendar that refuses your password
  is not asked again every few seconds.
- A launch agent runs in your login session, so it can read the login
  Keychain for `token_command` / `password_command`.
- `--dry-run` shows the file and the commands without changing anything;
  `--force` installs even when `check` finds a problem.
- `--auto-update` makes it [update itself](#updating-by-itself) every time
  it starts.
- `busybar-calendar uninstall` stops and removes it.

`busybar-calendar status` shows how it is doing:

```
busybar-calendar 0.3.1
Service   running 0.3.1, pid 4321
          log /Users/me/Library/Logs/busybar-calendar.log
            16:40:19 INFO busybar_calendar.runner: reminder: Design review (Work, accepted) ...
            16:45:00 INFO busybar_calendar.runner: status: Design review (Work, accepted) ...
Bar       BUSY Cloud: answering
Calendar  now status: Design review, until Tue 13 Oct 17:30 (in 44m)
          next reminder: Retro, at Tue 13 Oct 17:55 (in 1h 09m)
```

A stopped service shows its last exit code, with the log lines that explain it.

Both `status` and `check` take `--json`, for scripts, dashboards or a menu-bar
widget. `check --json` exits non-zero when something fails, like `check`:

```sh
busybar-calendar status --json | jq -r '.calendar.next | "\(.kind) \(.title) at \(.at)"'
busybar-calendar check --json | jq '.findings[] | select(.ok | not) | .message'
```

### Upgrading

Upgrade the install, then restart the service, which keeps running the old
version until then:

```sh
uv tool upgrade busybar-calendar   # fetches the newest from git, for the command and the service
busybar-calendar restart
```

That works for the one-line install from git. An install from a clone takes
`git pull && uv tool install --force .`, and one from a release file or a
tag (`...@v0.3.1`) takes `uv tool install --force` with the new one. Your
config and passwords stay where they are.

`status` shows which version the service runs, and says when it is not the
one just installed:

```
Service   running 0.3.0, pid 4321
          that is 0.3.0, but this is 0.3.1: `busybar-calendar restart` runs this one
```

When the command you run is from another install than the service (a
checkout run with `uv run`, say), a restart would start the old version
again, so `status` names the install to upgrade instead:

```
          that is 0.3.0, from ~/.local/share/uv/tools/busybar-calendar, not this 0.3.1:
          `uv tool upgrade busybar-calendar`, then `busybar-calendar restart`
```

It gives the `uv tool upgrade` command when that install is a uv tool from
git, and says "upgrade that one" otherwise.

#### Updating by itself

```sh
busybar-calendar install --auto-update
```

installs the service so that every start - at login, after a restart, or
after it stopped - first runs `uv tool upgrade busybar-calendar`, then
starts the newest version. It comes before the config is read, so a config
that uses something new gets the version that understands it.

- It needs the one-line install from git (`uv tool install
  git+https://github.com/kurok/busybar-calendar`): that is the install
  `uv tool upgrade` moves forward, to the newest commit on `main`. `install`
  refuses `--auto-update` for any other install, and when there is no `uv`.
- If the update fails - no network yet at login, say - the service starts
  the version it has, and tries again at its next start. Each start logs
  what happened:

  ```
  INFO busybar_calendar.update: auto-update: 0.5.1 (a0c7935) -> 0.6.1 (0046d3c)
  INFO busybar_calendar: busybar-calendar 0.6.1 started: 1 calendar source(s), bar via BUSY Cloud
  ```

- So `busybar-calendar restart` is also how to update now, and `status`
  says so: `Service   running 0.6.1, pid 4321, updates itself at each start`.
- `install` without the flag turns it off again.

### Docker

```sh
docker build -t busybar-calendar .
docker run -d --restart unless-stopped \
  -e BUSYBAR_TOKEN -e CALDAV_PASSWORD \
  -v "$PWD/config.toml:/config/config.toml:ro" busybar-calendar
```

In a container, use `token_env` and `password_env`, since the Keychain is
not there.

## Troubleshooting

See [troubleshooting.md](troubleshooting.md): what `check` and `status` say
for each common problem, and what to do about it.
docs/troubleshooting.md
# Troubleshooting

Two commands answer most questions:

```sh
busybar-calendar check    # is the setup right? the bar, its token, every calendar
busybar-calendar status   # is the service running, what did it last log, what is on the bar?
```

The outputs below are the commands' own, from real failures.

## The bar refuses: `refused (403)`

`check` names the cause after the code.

**The bar's HTTP API is off over Wi-Fi.** It is off until you switch it on:

```
Bar
  FAIL 192.168.1.50: refused (403). The bar's HTTP API over Wi-Fi is disabled: enable it at http://10.0.4.20 (USB) -> Network -> HTTP API, or use a BUSY Cloud token without an address.
```

Connect the bar by USB, open `http://10.0.4.20`, then **Network -> HTTP API
-> Set password and enable**. Or leave `address` out of `[bar]` and give a
BUSY Cloud token instead: that needs nothing switched on.

**The cloud token is wrong, revoked, or lacks the scope:**

```
Bar
  FAIL BUSY Cloud: refused (403). The cloud token is wrong, revoked, or lacks the BUSY Bar access scope.
```

Create a new token in your BUSY account with **BUSY Bar** under **Access
scope**, and store it where `token_command` / `token_env` reads it.

**A cloud token given as the bar's password.** With `address` set, the token
must be the HTTP API password, 32 characters at most; `check` spots a cloud
token by its length (`The token is 43 characters ... Is it a cloud token?`).
Leave `address` out to use the cloud token as one.

## The bar is unreachable

```
  FAIL 192.168.1.50: unreachable (...)
```

The address is wrong, or the bar is off or on another network: the computer
must be on the bar's network for a LAN address (**Settings -> Wi-Fi -> your
network -> View IP Address** on the bar). Over USB the bar is always
`10.0.4.20`. BUSY Cloud works from anywhere.

## The bar stopped answering

`status` checks the bar too:

```
Bar       BUSY Cloud: NOT answering (no answer). Is it on and online? If it stays stuck, unplug it and plug it back in.
```

A bar that answers ping but not its HTTP API - not on the LAN, not through
BUSY Cloud - has a stuck HTTP server; the service cannot draw until it is
power-cycled, and carries on by itself once the bar is back. (Seen on
firmware 1.2.4 after a draw request with a title of thousands of
characters; the service now caps titles at 120.)

## A calendar refuses the password

```
Calendars
  FAIL iCloud: PROPFIND https://caldav.icloud.com/: 401, check the credentials
```

iCloud and Fastmail need an **app password**, not the account's own. Store
the new one under the same Keychain entry, then restart the service, which
reads passwords only when it starts:

```sh
security add-generic-password -U -s busybar-calendar-me@icloud.com -a me@icloud.com -w
busybar-calendar restart
```

A running service stops on a refused password rather than asking again and
again, and `status` shows it:

```
Service   stopped (last exit code 1); the log says why
          log /Users/me/Library/Logs/busybar-calendar.log
            busybar-calendar: iCloud: PROPFIND https://caldav.icloud.com/: 401, check the credentials
```

It is started again five minutes later, so fixing the password is enough;
`busybar-calendar restart` does not wait.

## `no calendar matches`

```
Calendars
  FAIL Work: signed in; 0 of 2 calendars used: none
  FAIL Work: no calendar matches 'Wrok'; there are: Team, Work
```

An `include` or `exclude` entry names no calendar. Names are exact and
case-sensitive; `busybar-calendar calendars` lists them with their ids, and
an id keeps working when a calendar is renamed.

## `Config does not load`

```
Config    does not load: /Users/me/.config/busybar-calendar/config.toml
          the running service keeps the config it last loaded
            titles.0.status: show: show must be 'name' or one of back_soon, booked, ...
```

An edit broke the config. The service picks up edits by itself within a
minute, but not this one: it carries on with the config it last loaded,
and its log has the same lines. Each says where the problem is -
`titles.0` is the first `[[titles]]` rule, `sources.1` the second source -
and once the file is fixed, the service takes it at its next wake.

## Nothing on the bar during a meeting

`busybar-calendar status` says what the service thinks is on the bar:

- **`nothing on the bar now`**: the event is not one it shows. `busybar-calendar
  upcoming` lists every event it will show, with your response. Events are
  left out when they are all-day or 24 hours or longer, cancelled, marked
  free (`skip_free`), declined, in a calendar that `include` / `exclude`
  leaves out, or tentative with `status.tentative = false`.
- **`now status: ...` but the bar shows something else**: something above
  `bar.priority` (91) holds the screen, such as a menu you opened on the bar.
  The calendar comes back when it lets go; the log shows `bar refused the
  update (...)` meanwhile.

## A reminder came late, or not at all

Calendars are read every `poll_minutes` (5): an event added two minutes
before it starts can miss its reminder. ICS links are only as fresh as their
publisher makes them; Outlook and Google can take hours to republish.

## Getting more detail

```sh
busybar-calendar -v run                    # the service in the foreground, with debug logs
busybar-calendar check --json              # the findings as data, to attach to a report
busybar-calendar status --json
```
manifest.yaml
name: CalDAV Calendar
author: kurok
description: Meeting reminders with a countdown, then a red BUSY while it runs, from any CalDAV or ICS calendar - iCloud, Fastmail, Nextcloud, PrivateEmail or an Outlook link.
tags:
  - calendar
  - caldav
  - ics
  - productivity
  - info
preview: ./preview.gif
repo: https://github.com/kurok/busybar-calendar
pyproject.toml
[project]
name = "busybar-calendar"
version = "0.8.0"
description = "CalDAV and ICS calendar reminders for the BUSY Bar, drawn the way its Google Calendar integration draws them"
readme = "README.md"
license = "MIT AND OFL-1.1"
license-files = ["LICENSE", "LICENSES/*"]
requires-python = ">=3.11"
dependencies = [
  "busylib>=2.6,<3",
  "httpx>=0.27",
  "icalendar>=6",
  "recurring-ical-events>=3.3",
  "defusedxml>=0.7",
  "anyascii>=0.3",
]

[project.scripts]
busybar-calendar = "busybar_calendar.cli:main"

[dependency-groups]
dev = ["pytest>=8", "pytest-asyncio>=0.24", "ruff>=0.6"]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

[tool.ruff]
line-length = 100
target-version = "py311"

[tool.ruff.lint]
select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF"]

[tool.ruff.lint.per-file-ignores]
# Non-Latin event titles are the point of these tests.
"tests/*" = ["RUF001"]
# The SIGINT handler has to be in place before the first slow import.
"app.py" = ["E402", "I001"]
requirements.txt
# busylib: Flipper's official BUSY Bar client (pre-approved) - LAN and BUSY Cloud.
busylib>=2.6,<3
# The calendar side, which the standard library cannot do safely or correctly:
# httpx speaks CalDAV's PROPFIND/REPORT with Basic and Digest auth,
httpx>=0.27
# icalendar parses the events and recurring-ical-events expands RRULE/EXDATE
# and moved occurrences in their own timezones,
icalendar>=6
recurring-ical-events>=3.3
# defusedxml parses the server's XML without entity expansion attacks,
defusedxml>=0.7
# anyascii turns titles in other scripts into text the bar's bitmap fonts draw.
anyascii>=0.3
scripts/capture_screens.py
"""
Regenerate the screenshots in docs/images from a real bar.

Draws each layout on the bar named in a busybar-calendar config, reads the
front display back through GET /api/screen, and saves it as LED-style PNGs,
plus GIFs for the moving ones. Sounds stay off. Needs Pillow:

    uv run --with pillow python scripts/capture_screens.py -c config.toml docs/images

With the service running, a capture takes its screen down: the drawing goes up
over whatever the service has on the bar - under another application name too,
the firmware swaps the canvas - and what it replaces does not come back by
itself, as the service only redraws at its next change. Stop the service first,
or touch its config file afterwards, which makes it redraw at once.
"""

from __future__ import annotations

import argparse
import asyncio
import contextlib
import itertools
import time
from datetime import UTC, datetime, timedelta
from pathlib import Path

from PIL import Image, ImageDraw

from busybar_calendar import render
from busybar_calendar.config import DisplayConfig, ReminderSettings, StatusSettings, load
from busybar_calendar.connect import make_bar
from busybar_calendar.events import CalendarRef, Event
from busybar_calendar.schedule import Screen

W, H = 72, 16
CELL, DOT, PAD = 10, 8, 16
BACKGROUND, UNLIT = (12, 12, 12), (28, 28, 28)


def led(rgb: bytes) -> Image.Image:
    """A front-display frame as the bar shows it: square LEDs on a dark panel."""
    image = Image.new("RGB", (W * CELL + 2 * PAD, H * CELL + 2 * PAD), BACKGROUND)
    draw = ImageDraw.Draw(image)
    for y in range(H):
        for x in range(W):
            i = (y * W + x) * 3
            colour = tuple(rgb[i : i + 3])
            if max(colour) < 20:
                colour = UNLIT
            left, top = PAD + x * CELL, PAD + y * CELL
            draw.rounded_rectangle((left, top, left + DOT, top + DOT), radius=2, fill=colour)
    return image


def scenarios(now: datetime):
    """(file name, screen, display settings, frames: 1 for a PNG, more for a GIF)."""
    ref = CalendarRef("Work", "Work", 0)

    def event(title: str, start_in: int, length: int = 30, response: str = "accepted") -> Event:
        start = now + timedelta(minutes=start_in)
        return Event("doc", title, start, start + timedelta(minutes=length), ref, response)

    reminder, status, small = ReminderSettings(), StatusSettings(), DisplayConfig()
    meeting = StatusSettings(show="meeting")
    hidden = ReminderSettings(show_name=False)
    cyrillic = event("Планування спринту", 5)
    yield "reminder", Screen("reminder", event("Design review", 5), reminder, status), small, 18
    yield "status", Screen("status", event("Design review", -2, 45), reminder, status), small, 1
    maybe = event("Design review", -2, 45, "tentative")
    yield "status-tentative", Screen("status", maybe, reminder, status), small, 1
    in_progress = event("Design review", -2, 45)
    yield "status-meeting", Screen("status", in_progress, reminder, meeting), small, 18
    long_title = event("Quarterly planning with the design team", 10)
    yield "long-title", Screen("reminder", long_title, reminder, status), small, 24
    global_font = DisplayConfig(title_font="global")
    yield "cyrillic-global", Screen("reminder", cyrillic, reminder, status), global_font, 24
    yield "cyrillic-transliterated", Screen("reminder", cyrillic, reminder, status), small, 24
    yield "hidden-name", Screen("reminder", event("Design review", 5), hidden, status), small, 1


async def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("-c", "--config", default="config.toml")
    parser.add_argument("out", type=Path)
    parser.add_argument("only", nargs="*", help="capture only these names")
    args = parser.parse_args()

    config = load(args.config)
    app = config.bar.application_name
    args.out.mkdir(parents=True, exist_ok=True)
    bar = make_bar(config)
    try:
        for name, screen, display, count in scenarios(datetime.now(UTC)):
            if args.only and name not in args.only:
                continue
            with contextlib.suppress(Exception):  # 400 when nothing of ours is up
                await bar.display_clear(application_name=app)
            await render.send(bar, render.drawing(screen, display, app, config.bar.priority))
            await asyncio.sleep(2.5)  # let the cloud deliver it and the scroll begin
            frames, stamps = [], []
            for _ in range(count):
                frames.append(led(await bar.screen(0)))
                stamps.append(time.monotonic())
            if count > 1:
                gaps = [int((b - a) * 1000) for a, b in itertools.pairwise(stamps)]
                frames[0].save(
                    args.out / f"{name}.gif",
                    save_all=True,
                    append_images=frames[1:],
                    duration=[*gaps, gaps[-1]],
                    loop=0,
                    optimize=True,
                )
            frames[len(frames) // 2].save(args.out / f"{name}.png", optimize=True)
            print(f"{name}: {count} frame(s)")
    finally:
        with contextlib.suppress(Exception):
            await bar.display_clear(application_name=app)
        await bar.aclose()


if __name__ == "__main__":
    asyncio.run(main())
src/busybar_calendar/__init__.py
"""CalDAV and ICS calendar reminders for the BUSY Bar."""

from importlib.metadata import PackageNotFoundError, version

try:
    __version__ = version("busybar-calendar")
except PackageNotFoundError:  # run from a checkout without installing, as app.py does
    __version__ = "unknown"
src/busybar_calendar/__main__.py
import sys

from .cli import main

sys.exit(main())
src/busybar_calendar/caldav.py
"""
A small, read-only CalDAV client: find the calendars, read events in a range.

Only the parts of RFC 4791 a reminder needs: discovery through
current-user-principal and calendar-home-set (RFC 5397, RFC 6764's
/.well-known/caldav as a fallback), and calendar-query REPORTs with a time
range. Recurrence is expanded by the caller, not the server.

Hrefs are joined against the URL that answered, which is what makes iCloud
work: its principal and calendars live on a per-account host, returned as
absolute URLs.
"""

from __future__ import annotations

import logging
from dataclasses import dataclass
from datetime import UTC, datetime
from urllib.parse import unquote, urljoin, urlsplit
from xml.etree.ElementTree import Element

import httpx
from defusedxml.ElementTree import fromstring

logger = logging.getLogger(__name__)

DAV = "DAV:"
CALDAV = "urn:ietf:params:xml:ns:caldav"
APPLE = "http://apple.com/ns/ical/"
NS = {"d": DAV, "c": CALDAV, "a": APPLE}

_PROPFIND_ENTRY = """<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
  <d:prop><d:current-user-principal/><d:resourcetype/><d:displayname/></d:prop>
</d:propfind>"""

_PROPFIND_HOME = """<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
  <d:prop><c:calendar-home-set/></d:prop>
</d:propfind>"""

_PROPFIND_CALENDARS = """<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
  <d:prop>
    <d:resourcetype/><d:displayname/><c:supported-calendar-component-set/>
  </d:prop>
</d:propfind>"""

_REPORT_RANGE = """<?xml version="1.0" encoding="utf-8"?>
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
  <d:prop><c:calendar-data/></d:prop>
  <c:filter>
    <c:comp-filter name="VCALENDAR">
      <c:comp-filter name="VEVENT">
        <c:time-range start="{start}" end="{end}"/>
      </c:comp-filter>
    </c:comp-filter>
  </c:filter>
</c:calendar-query>"""


MAX_REDIRECTS = 5


class CalDAVError(Exception):
    def __init__(self, message: str, status: int | None = None) -> None:
        super().__init__(message)
        self.status = status


class CalDAVAuthError(CalDAVError):
    pass


@dataclass(frozen=True)
class RemoteCalendar:
    url: str
    name: str

    @property
    def id(self) -> str:
        """The calendar's own path segment: 4711 in .../calendars/me@example.com/4711/."""
        return unquote(urlsplit(self.url).path.rstrip("/").rsplit("/", 1)[-1])


@dataclass(frozen=True)
class _Response:
    href: str
    props: Element


def _stamp(value: datetime) -> str:
    return value.astimezone(UTC).strftime("%Y%m%dT%H%M%SZ")


class CalDAVClient:
    def __init__(self, http: httpx.AsyncClient, url: str) -> None:
        self._http = http
        self._url = url

    async def _send(self, method: str, url: str, body: str, depth: str) -> httpx.Response:
        """
        Send a DAV request, following redirects with the method and body intact.

        httpx, like browsers, turns a 302 into a GET; a PROPFIND that becomes
        a GET is refused. /.well-known/caldav is usually a 302 (RFC 6764).
        """
        headers = {"Depth": depth, "Content-Type": "application/xml; charset=utf-8"}
        for _ in range(MAX_REDIRECTS + 1):
            response = await self._http.request(
                method, url, content=body.encode(), headers=headers, follow_redirects=False
            )
            location = response.headers.get("Location")
            if response.status_code not in (301, 302, 303, 307, 308) or not location:
                return response
            url = urljoin(str(response.url), location)
        raise CalDAVError(f"{method} {url}: more than {MAX_REDIRECTS} redirects")

    async def _dav(self, method: str, url: str, body: str, depth: str) -> list[_Response]:
        response = await self._send(method, url, body, depth)
        code = response.status_code
        if code in (401, 403):
            raise CalDAVAuthError(f"{method} {url}: {code}, check the credentials", code)
        if code != 207:
            raise CalDAVError(f"{method} {url}: expected 207, got {code}", code)
        base = str(response.url)
        root = fromstring(response.content)
        results = []
        for item in root.findall("d:response", NS):
            href = item.findtext("d:href", default="", namespaces=NS).strip()
            for propstat in item.findall("d:propstat", NS):
                status = propstat.findtext("d:status", default="", namespaces=NS)
                prop = propstat.find("d:prop", NS)
                if " 200 " in f"{status} " and prop is not None:
                    results.append(_Response(urljoin(base, href), prop))
        return results

    async def _href(self, url: str, body: str, path: str) -> str | None:
        for response in await self._dav("PROPFIND", url, body, "0"):
            href = response.props.findtext(path, default="", namespaces=NS).strip()
            if href:
                return urljoin(response.href, href)
        return None

    async def _principal(self, url: str) -> tuple[str | None, list[_Response]]:
        try:
            entry = await self._dav("PROPFIND", url, _PROPFIND_ENTRY, "0")
        except CalDAVAuthError:
            raise
        except CalDAVError as error:
            logger.debug("%s", error)
            return None, []
        for response in entry:
            href = response.props.findtext(
                "d:current-user-principal/d:href", default="", namespaces=NS
            ).strip()
            if href:
                return urljoin(response.href, href), entry
        return None, entry

    async def discover(self) -> list[RemoteCalendar]:
        """The event calendars behind the configured URL."""
        principal, entry = await self._principal(self._url)
        # A calendar URL given directly is used as it is.
        for response in entry:
            if response.props.find("d:resourcetype/c:calendar", NS) is not None:
                name = response.props.findtext("d:displayname", default="", namespaces=NS)
                return [RemoteCalendar(response.href, name.strip() or response.href)]

        if principal is None:
            parts = urlsplit(self._url)
            well_known = f"{parts.scheme}://{parts.netloc}/.well-known/caldav"
            if self._url.rstrip("/") != well_known:
                principal, _ = await self._principal(well_known)
        if principal is None:
            raise CalDAVError(f"{self._url}: no current-user-principal; give a calendar URL")

        home = await self._href(principal, _PROPFIND_HOME, "c:calendar-home-set/d:href")
        if home is None:
            raise CalDAVError(f"{principal}: no calendar-home-set")

        calendars = []
        for response in await self._dav("PROPFIND", home, _PROPFIND_CALENDARS, "1"):
            if response.props.find("d:resourcetype/c:calendar", NS) is None:
                continue
            components = response.props.findall("c:supported-calendar-component-set/c:comp", NS)
            if components and not any(c.get("name") == "VEVENT" for c in components):
                continue  # a task list or a journal
            name = response.props.findtext("d:displayname", default="", namespaces=NS).strip()
            calendars.append(RemoteCalendar(response.href, name or response.href))
        return calendars

    async def events(self, calendar: RemoteCalendar, start: datetime, end: datetime) -> list[str]:
        """The iCalendar data of every event resource overlapping the range."""
        body = _REPORT_RANGE.format(start=_stamp(start), end=_stamp(end))
        documents = []
        for response in await self._dav("REPORT", calendar.url, body, "1"):
            data = response.props.findtext("c:calendar-data", default="", namespaces=NS)
            if data.strip():
                documents.append(data)
        return documents
src/busybar_calendar/cli.py
"""Command line: the commands, their options, and which code each one runs."""

from __future__ import annotations

import argparse
import asyncio
import logging
import os
import shlex
import subprocess
import sys
from datetime import UTC, datetime, timedelta
from pathlib import Path

import httpx
from pydantic import ValidationError

from . import __version__, connect, diagnose, playback, schedule, service, sources, update, wizard
from . import config as config_module
from .caldav import CalDAVError
from .config import Config, ConfigError
from .events import Event
from .runner import Runner

logger = logging.getLogger("busybar_calendar")


def _stamp(path: Path) -> tuple[int, int] | None:
    try:
        stat = path.stat()
    except OSError:
        return None
    return stat.st_mtime_ns, stat.st_size


class ConfigWatch:
    """
    Puts an edited config to work in a running service, within a minute.

    An edit that does not load leaves the service on the config it has, and
    says why in the log. `[bar]` is read only at start: a change there waits
    for `busybar-calendar restart`.
    """

    def __init__(self, path: Path, runner: Runner) -> None:
        self.path = path
        self.runner = runner
        self._stamp = _stamp(path)

    async def __call__(self) -> None:
        stamp = _stamp(self.path)
        if stamp == self._stamp:
            return
        self._stamp = stamp
        running = self.runner.config
        try:
            config = config_module.load(self.path)
            items = connect.make_sources(config)
        except EXPECTED as error:
            logger.warning(
                "%s changed, but the service keeps the config it has: %s",
                self.path,
                "; ".join(describe(error)),
            )
            return
        if config.bar != running.bar:
            logger.warning("[bar] changed: `busybar-calendar restart` puts it to use")
            config = config.model_copy(update={"bar": running.bar})
        retired = self.runner.sources
        self.runner.reconfigure(config, items)
        await connect.close_all(retired)
        logger.info("%s changed: reloaded, %d calendar source(s)", self.path, len(items))


async def run_service(config: Config, path: Path | None = None) -> int:
    """
    Keep the bar in step with the calendars until a signal, or a source that
    cannot work; with the config's `path`, follow its edits too.
    """
    items = connect.make_sources(config)
    bar = connect.make_bar(config)
    stop = connect.stop_on_signals()
    # `status` reads this line to tell which version the service runs.
    logger.info(
        "busybar-calendar %s started: %d calendar source(s), bar %s",
        __version__,
        len(items),
        config.bar.address or "via BUSY Cloud",
    )
    runner = Runner(config, bar, items)
    try:
        await runner.run(stop, ConfigWatch(path, runner) if path is not None else None)
    except sources.FatalSourceError as error:
        print(f"busybar-calendar: {error}", file=sys.stderr)
        return 1
    finally:
        await connect.close_all(runner.sources)
        await bar.aclose()
    return 0


async def cmd_run(config: Config, args: argparse.Namespace) -> int:
    return await run_service(config, args.config_path)


async def cmd_check(config: Config, args: argparse.Namespace) -> int:
    return await diagnose.check(config, as_json=args.json)


async def cmd_replay(config: Config, args: argparse.Namespace) -> int:
    return await playback.replay(config, args.day, args.seconds, args.quiet)


async def cmd_demo(config: Config, args: argparse.Namespace) -> int:
    return await playback.demo(config, args)


async def cmd_install(config: Config, args: argparse.Namespace) -> int:
    """Run the service in the background from now on: at login, restarted if it stops."""
    plan = service.install_plan(args.config_path, auto_update=args.auto_update)
    if args.dry_run:
        print(f"Would write {plan.path}:\n")
        print(plan.content.decode())
        print("Would run:")
        for command in [*plan.cleanup, *plan.commands]:
            print("  " + shlex.join(command))
        return 0
    if not args.force:
        print("Checking the setup first.\n")
        if await diagnose.check(config):
            print("Fix the problems above, or install anyway with --force.")
            return 1
        print()
    service.install(plan)
    print(f"Installed {plan.path}.")
    print("It runs at login, comes back if it stops, and reads", args.config_path.resolve())
    if args.auto_update:
        print(f"At every start and restart it first runs `{' '.join(update.upgrade_command())}`.")
    if plan.log:
        print(f"Log: {plan.log}")
    print("Remove it with: busybar-calendar uninstall")
    return 0


def cmd_restart(_args: argparse.Namespace) -> int:
    """Restart the background service, after a password or the config changed."""
    if service.restart():
        print("Restarted. `busybar-calendar status` shows how it is doing.")
        return 0
    print("Not installed: `busybar-calendar install` runs it in the background.")
    return 1


def cmd_uninstall(args: argparse.Namespace) -> int:
    plan = service.uninstall_plan()
    if args.dry_run:
        print(f"Would remove {plan.path} and run:")
        for command in [*plan.cleanup, *plan.commands]:
            print("  " + shlex.join(command))
        return 0
    if service.uninstall(plan):
        print(f"Stopped and removed {plan.path}.")
    else:
        print("Not installed.")
    return 0


async def cmd_calendars(config: Config, _args: argparse.Namespace) -> int:
    items = connect.make_sources(config)
    try:
        for item in items:
            if isinstance(item, sources.CalDAVSource):
                found = await item.calendars()
                used, unmatched = sources.select(found, item.config.include, item.config.exclude)
                print(f"{item.name} (caldav): {len(found)} calendars, {len(used)} used")
                width = max((len(calendar.id) for calendar in found), default=0)
                for calendar in found:
                    mark = "*" if calendar in used else " "
                    print(f"  {mark} {calendar.id:<{width}}  {calendar.name}")
                for pattern in unmatched:
                    print(f"  ! no calendar matches {pattern!r}")
            else:
                print(f"{item.name} (ics): one calendar, {item.config.url}")
    finally:
        await connect.close_all(items)
    print("\n* = used. Filter with include/exclude by id, name or URL; * and ? are wildcards.")
    return 0


def upcoming_notes(config: Config, event: Event) -> str:
    """What the bar does with an event, and why: its settings, your answer, its rules."""
    ref = event.calendar
    reminder, status = config.settings_for(ref.source, ref.name, ref.id, event.title)
    notes = []
    if reminder.enabled:
        notes.append(f"reminder {reminder.minutes_before}m")
    if schedule.shows_status(event, status):
        notes.append(f"status {status.show}")
    notes = notes or ["hidden"]
    if event.response != "accepted":
        notes.append(event.response)
    if patterns := config.title_patterns(event.title):
        notes.append("titles " + " ".join(map(repr, patterns)))
    return ", ".join(notes)


async def cmd_upcoming(config: Config, args: argparse.Namespace) -> int:
    now = datetime.now(UTC)
    runner = await connect.read_calendars(config, now)
    tz = connect.floating_tz(config)
    horizon = now + timedelta(hours=args.hours)
    shown = [e for e in runner.events if e.end > now and e.start < horizon]
    for event in sorted(shown, key=lambda e: e.start):
        start = event.start.astimezone(tz)
        end = event.end.astimezone(tz)
        print(
            f"{start:%a %d %b %H:%M}-{end:%H:%M}  [{event.calendar.source}/"
            f"{event.calendar.name}]  {event.title}  ({upcoming_notes(config, event)})"
        )
    screen = schedule.choose(now, runner.events, runner.settings_for)
    print(f"\nNow on the bar: {screen.kind + ': ' + screen.event.title if screen else 'nothing'}")
    return 0


def setup_logging(verbose: bool) -> None:
    logging.basicConfig(
        level=logging.DEBUG if verbose else logging.INFO,
        format="%(asctime)s %(levelname)s %(name)s: %(message)s",
    )
    if not verbose:
        logging.getLogger("busylib").setLevel(logging.WARNING)
        logging.getLogger("httpx").setLevel(logging.WARNING)
        logging.getLogger("httpx2").setLevel(logging.WARNING)
    # Its bounds check ignores anchors, so a bottom-anchored line at y=16 -
    # busylib's own calibrated two-line layout - is reported as off-screen.
    logging.getLogger("busylib.client.display").setLevel(logging.ERROR)
    # "Device API minor version is behind Busy Lib": busylib's own nag, on every
    # bar that runs released firmware; `check` prints both versions instead.
    logging.getLogger("busylib.client.firmware").setLevel(logging.ERROR)
    # It logs every refused call; this service reports those itself, with what to do.
    logging.getLogger("busylib.client.base").setLevel(logging.CRITICAL)


FIRST_STEPS = """\
busybar-calendar shows your CalDAV and ICS calendars on a BUSY Bar: a
reminder before each event, then a red BUSY while it runs.

No config yet. To set it up:

  busybar-calendar init       a few questions: how to reach the bar, which calendar
  busybar-calendar check      proves it works, or says what to fix
  busybar-calendar install    runs it in the background from now on

busybar-calendar --help lists every command."""


def _status(args: argparse.Namespace, as_json: bool = False) -> int | None:
    """
    `status`, which needs no config for its service part, and takes a broken
    one in its stride: a running service keeps the last config that loaded,
    so it is reported beside the problems. None when there is no config yet.
    """
    try:
        path = config_module.find(args.config)
    except ConfigError:
        path = None
    if path is None or not path.is_file():
        if as_json:
            return asyncio.run(diagnose.status(None, as_json=True))
        return None
    try:
        config, problems = config_module.load(path), []
    except EXPECTED as error:
        config, problems = None, describe(error)
    return asyncio.run(diagnose.status(config, as_json, path=path, problems=problems))


def _where_you_stand(args: argparse.Namespace) -> int:
    """`busybar-calendar` alone: first steps before a config exists, the status after."""
    code = _status(args)
    if code is None:
        print(FIRST_STEPS)
        return 0
    print("\nbusybar-calendar --help lists every command.")
    return code


# The failures a user can meet and fix: reported in a line or two, never a traceback.
EXPECTED = (
    ValidationError,
    ConfigError,
    CalDAVError,
    sources.FatalSourceError,
    httpx.HTTPError,
    subprocess.CalledProcessError,
    OSError,
    ValueError,
)


def describe(error: BaseException) -> list[str]:
    """An expected failure in the user's terms: what went wrong, where."""
    if isinstance(error, ValidationError):  # the config: one line per problem
        return [
            f"{'.'.join(str(part) for part in problem['loc']) or 'config'}: "
            f"{problem['msg'].removeprefix('Value error, ')}"
            for problem in error.errors()
        ]
    if isinstance(error, httpx.RequestError):
        return [f"cannot reach {error.request.url}: {error}"]
    if isinstance(error, subprocess.CalledProcessError):  # launchctl, systemctl
        return [f"{shlex.join(error.cmd)} failed (exit {error.returncode})"]
    return [str(error)]


def _update_first() -> None:
    """`run --auto-update`, before the config is read - a newer version may be what reads
    it - and once: not again in the process an update restarts into."""
    if not os.environ.get(update.UPDATED_ENV) and update.update():
        update.restart_into_update()


def _restart_after(code: int) -> int:
    """After the config changed: a running background service reads it only when it starts."""
    if code == 0 and service.restart():
        print("Restarted the background service, so it reads the new config.")
    return code


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(prog="busybar-calendar", description=__doc__)
    parser.add_argument(
        "-c",
        "--config",
        help="TOML configuration (default: $BUSYBAR_CALENDAR_CONFIG, ./config.toml, "
        "then ~/.config/busybar-calendar/config.toml)",
    )
    parser.add_argument("-v", "--verbose", action="store_true")
    parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
    # No command: where you stand, and what to do next (see _where_you_stand).
    commands = parser.add_subparsers(dest="command")
    run = commands.add_parser("run", help="keep the bar in step with the calendars")
    run.add_argument(
        "--auto-update",
        action="store_true",
        help="first update a uv tool installed from git to its latest version",
    )
    init = commands.add_parser("init", help="answer a few questions to write the config")
    init.add_argument("--force", action="store_true", help="replace an existing config")
    commands.add_parser("add", help="add another calendar to the config, a few questions")
    check = commands.add_parser("check", help="check the bar, its token and every calendar")
    check.add_argument("--json", action="store_true", help="the findings as JSON")
    status = commands.add_parser(
        "status", help="is it running, and what is on the bar now and next"
    )
    status.add_argument("--json", action="store_true", help="the same, as JSON")
    install = commands.add_parser(
        "install", help="run in the background from now on (launchd / systemd)"
    )
    install.add_argument("--dry-run", action="store_true", help="show what it would do")
    install.add_argument("--force", action="store_true", help="install even if check fails")
    install.add_argument(
        "--auto-update",
        action="store_true",
        help="update to the latest version at every start and restart (uv tool from git)",
    )
    commands.add_parser("restart", help="restart the background service, e.g. after a new password")
    uninstall = commands.add_parser("uninstall", help="stop and remove the background service")
    uninstall.add_argument("--dry-run", action="store_true", help="show what it would do")
    commands.add_parser("calendars", help="list the calendars each CalDAV source has")
    upcoming = commands.add_parser("upcoming", help="list the events the bar will show")
    upcoming.add_argument("--hours", type=int, default=24)
    demo = commands.add_parser("demo", help="draw a sample event on the bar")
    demo.add_argument("--title", default="Weekly sync with the team")
    demo.add_argument("--minutes", type=int, default=5, help="reminder: minutes to the start")
    demo.add_argument("--status", action="store_true", help="draw the in-event status instead")
    demo.add_argument("--show", help="status to draw: name, meeting, on_call, ...")
    demo.add_argument("--font", choices=["tiny", "small", "global"])
    demo.add_argument("--sound", action="store_true")
    demo.add_argument("--seconds", type=int, default=20, help="how long to leave it up")
    replay = commands.add_parser(
        "replay", help="play a day's events on the bar one after another, in short slots"
    )
    replay.add_argument("--day", default="tomorrow", help="today, tomorrow or YYYY-MM-DD")
    replay.add_argument("--seconds", type=int, default=60, help="slot per event")
    replay.add_argument("--quiet", action="store_true", help="no sounds")
    args = parser.parse_args(argv)

    setup_logging(args.verbose)

    handlers = {
        "run": cmd_run,
        "check": cmd_check,
        "install": cmd_install,
        "calendars": cmd_calendars,
        "upcoming": cmd_upcoming,
        "demo": cmd_demo,
        "replay": cmd_replay,
    }
    try:
        if args.command == "uninstall":  # needs no config: there may be none left
            return cmd_uninstall(args)
        if args.command == "restart":
            return cmd_restart(args)
        if args.command is None:
            return _where_you_stand(args)
        if args.command == "status":
            code = _status(args, args.json)
            return asyncio.run(diagnose.status(None)) if code is None else code
        if args.command == "init":  # writes the config
            path = Path(
                args.config
                or os.environ.get(config_module.CONFIG_ENV)
                or config_module.search_paths()[-1]
            )
            return _restart_after(wizard.run(path, overwrite=args.force))
        if args.command == "add":  # appends to the config
            return _restart_after(wizard.add(config_module.find(args.config)))
        if args.command == "run" and args.auto_update:
            _update_first()
        args.config_path = config_module.find(args.config)
        config = config_module.load(args.config_path)
        return asyncio.run(handlers[args.command](config, args))
    except EXPECTED as error:
        for line in describe(error):
            print(f"busybar-calendar: {line}", file=sys.stderr)
        return 1
    except KeyboardInterrupt:
        return 130
src/busybar_calendar/config.py
"""
Configuration, read from a TOML file.

The reminder and status settings are the ones the Google Calendar
integration offers per calendar, so a calendar here behaves the way a Google
calendar does on the same bar. They layer: `[defaults]` applies everywhere, a
source can override it, and a single calendar inside a source can override
that again - which is also the account-level default that BUSY Cloud does not
have yet (busy-app/busybar-firmware#1083).
"""

from __future__ import annotations

import os
import subprocess
import tomllib
from collections.abc import Iterable
from fnmatch import fnmatchcase
from pathlib import Path
from typing import Any, Literal
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

from pydantic import (
    BaseModel,
    ConfigDict,
    Field,
    ValidationError,
    ValidationInfo,
    field_validator,
    model_validator,
)

# The 72x16 statuses the firmware ships, under shared/animations/<name>_72x16.anim.
STOCK_STATUSES = (
    "back_soon",
    "booked",
    "chill_time",
    "coding",
    "dnd",
    "flow",
    "keep_out",
    "low_social_battery",
    "lunch",
    "meeting",
    "on_air",
    "on_call",
)


class _Strict(BaseModel):
    model_config = ConfigDict(extra="forbid")


class ReminderSettings(_Strict):
    """A countdown shown before the event starts."""

    enabled: bool = Field(default=True, description="Show a reminder before events.")
    minutes_before: int = Field(
        default=5, ge=1, le=24 * 60, description="How long before the start it appears."
    )
    sound: bool = Field(default=True, description="Play the reminder sound as it appears.")
    show_name: bool = Field(
        default=True, description='Show the event\'s title; off, it says "Next event".'
    )


class StatusSettings(_Strict):
    """What the bar shows while the event is on."""

    enabled: bool = Field(default=True, description="Show the event while it is on.")
    show: str = Field(
        default="name",
        description="`name`: the red calendar, the title, a countdown and BUSY. Or one of "
        "the bar's own statuses: " + ", ".join(f"`{s}`" for s in STOCK_STATUSES) + ".",
    )
    sound: bool = Field(default=True, description="Play the event sound as it starts.")
    tentative: bool = Field(
        default=True,
        description="Events you marked tentative show as busy too. Off, they keep their "
        "reminder only.",
    )

    @field_validator("show")
    @classmethod
    def _known_status(cls, value: str) -> str:
        if value != "name" and value not in STOCK_STATUSES:
            raise ValueError(f"show must be 'name' or one of {', '.join(STOCK_STATUSES)}")
        return value


class Overrides(_Strict):
    """Partial reminder/status settings; unset fields fall through to the level above."""

    reminder: dict[str, Any] = Field(default_factory=dict)
    status: dict[str, Any] = Field(default_factory=dict)

    @field_validator("reminder", "status")
    @classmethod
    def _valid(cls, value: dict[str, Any], info: ValidationInfo) -> dict[str, Any]:
        """Check the values here, where an error can say which section it is in."""
        model = ReminderSettings if info.field_name == "reminder" else StatusSettings
        try:
            model(**value)
        except ValidationError as error:
            problems = (
                f"{'.'.join(map(str, p['loc']))}: {p['msg'].removeprefix('Value error, ')}"
                for p in error.errors()
            )
            raise ValueError("; ".join(problems)) from None
        return value


def _merged(layers: Iterable[Overrides]) -> tuple[ReminderSettings, StatusSettings]:
    reminder: dict[str, Any] = {}
    status: dict[str, Any] = {}
    for layer in layers:
        reminder |= layer.reminder
        status |= layer.status
    return ReminderSettings(**reminder), StatusSettings(**status)


class TitleRule(Overrides):
    """Reminder/status overrides for the events whose title matches, from any calendar."""

    match: list[str] = Field(
        min_length=1,
        description="The titles it applies to: one or a list; `*` and `?` are wildcards, "
        "and case does not matter.",
    )

    @field_validator("match", mode="before")
    @classmethod
    def _one_or_many(cls, value: Any) -> Any:
        return [value] if isinstance(value, str) else value

    def matching(self, title: str) -> str | None:
        """The first of `match` that names this title, if one does."""
        title = title.casefold()
        return next((p for p in self.match if fnmatchcase(title, p.casefold())), None)


class Secret(_Strict):
    """
    A credential given inline, by environment variable, or by a command.

    The command form is argv, run without a shell - for example the macOS
    keychain: ["security", "find-generic-password", "-s", "busybar", "-w"].
    """

    value: str | None = None
    env: str | None = None
    command: list[str] | None = None

    def resolve(self) -> str | None:
        if self.value is not None:
            return self.value
        if self.env is not None:
            found = os.environ.get(self.env)
            if found is None:
                raise ConfigError(f"environment variable {self.env} is not set")
            return found
        if self.command:
            result = subprocess.run(self.command, capture_output=True, text=True, check=False)
            if result.returncode != 0:
                raise ConfigError(f"{self.command[0]} exited with {result.returncode}")
            return result.stdout.strip()
        return None


def _secret(data: dict[str, Any], key: str) -> dict[str, Any]:
    """Fold `key`, `key_env` and `key_command` into one Secret."""
    secret = {
        "value": data.pop(key, None),
        "env": data.pop(f"{key}_env", None),
        "command": data.pop(f"{key}_command", None),
    }
    given = [name for name, value in secret.items() if value is not None]
    if len(given) > 1:
        raise ValueError(f"give only one of {key}, {key}_env, {key}_command")
    data[key] = secret
    return data


class BarConfig(_Strict):
    address: str | None = Field(
        default=None,
        description="The bar on your network (`ip[:port]`; `10.0.4.20` over USB). Leave it "
        "out to reach the bar through BUSY Cloud.",
    )
    token: Secret = Field(
        default_factory=Secret,
        description="With an address, the bar's HTTP API password (32 characters at most); "
        'without, a BUSY Cloud token with the "BUSY Bar" access scope.',
    )
    priority: int = Field(
        default=91,
        ge=1,
        le=100,
        description="Draw priority: 91 is above a running BUSY session (90), like the bar's "
        "own calendar events.",
    )
    application_name: str = Field(
        default="busybar-calendar",
        max_length=32,
        description="The name drawings are made under; also the app's id in the community gallery.",
    )

    @model_validator(mode="before")
    @classmethod
    def _fold_token(cls, data: Any) -> Any:
        return _secret(dict(data), "token") if isinstance(data, dict) else data


class DisplayConfig(_Strict):
    title_font: Literal["tiny", "small", "global"] = Field(
        default="small",
        description="`small` fits the title and countdown on two lines; `global` draws "
        "Cyrillic, CJK and accents, on one line beside the countdown.",
    )
    transliterate: bool = Field(
        default=True,
        description="Fold titles to ASCII for the bitmap fonts (`Планування` -> "
        "`Planuvannya`). Not used with `global`.",
    )
    reminder_sound: str = Field(
        default="shared/sounds/calendar_reminder_ends.snd",
        description="The stock sound a reminder plays.",
    )
    event_sound: str = Field(
        default="shared/sounds/calendar_event_starts.snd",
        description="The stock sound an event's start plays.",
    )
    reminder_icon: Literal["animation", "red_calendar"] = Field(
        default="red_calendar",
        description="The icon before an event: the red calendar, or the bar's own animation.",
    )
    event_icon: Literal["animation", "red_calendar"] = Field(
        default="red_calendar",
        description="The icon during an event: the red calendar, or the bar's own animation.",
    )
    busy_label: str = Field(
        default="BUSY",
        max_length=8,
        description='The word beside the countdown during an event; `""` for none. Not drawn '
        "with the `global` font.",
    )
    busy_color: str = Field(
        default="#FF0000FF", description="Its colour, `#RRGGBB` or `#RRGGBBAA`."
    )
    tentative_yellow: bool = Field(
        default=True,
        description="Draw events you marked tentative in yellow - the calendar icon and the "
        "label - so they differ from accepted ones. Off, they look the same. The icon is "
        "yellow only as the `red_calendar`; the bar's own animation has no yellow version.",
    )

    @field_validator("busy_label")
    @classmethod
    def _ascii_label(cls, value: str) -> str:
        if not all(" " <= c <= "~" for c in value):
            raise ValueError("busy_label must be printable ASCII")
        return value

    @field_validator("busy_color")
    @classmethod
    def _rgba(cls, value: str) -> str:
        digits = value.removeprefix("#")
        if len(digits) not in (6, 8) or any(c not in "0123456789abcdefABCDEF" for c in digits):
            raise ValueError("busy_color must be #RRGGBB or #RRGGBBAA")
        return f"#{digits.upper()}" + ("FF" if len(digits) == 6 else "")


class SyncConfig(_Strict):
    poll_minutes: int = Field(default=5, ge=1, description="How often calendars are read.")
    lookahead_hours: int = Field(
        default=24, ge=1, le=24 * 14, description="How far ahead events are read."
    )
    skip_free: bool = Field(
        default=True, description="Leave out events marked free (`TRANSP:TRANSPARENT`)."
    )
    timezone: str | None = Field(
        default=None,
        description="For event times that carry no timezone, e.g. `Europe/Kyiv`. Default: "
        "this machine's.",
    )

    @field_validator("timezone")
    @classmethod
    def _known_timezone(cls, value: str | None) -> str | None:
        if value is not None:
            try:
                ZoneInfo(value)
            except (ZoneInfoNotFoundError, ValueError) as error:
                raise ValueError(
                    f"{value!r} is not a timezone name; use one like 'Europe/Kyiv'"
                ) from error
        return value


class SourceConfig(Overrides):
    name: str = Field(description="What logs and overrides call this source; unique.")
    type: Literal["caldav", "ics"] = Field(
        description="`caldav` for an account, `ics` for a published link (one calendar)."
    )
    url: str = Field(
        description="CalDAV: the server, or one calendar's URL. ICS: the link; `webcal://` works."
    )
    username: str | None = Field(default=None, description="Usually the email address.")
    password: Secret = Field(
        default_factory=Secret,
        description="iCloud and Fastmail need an app password.",
    )
    auth: Literal["basic", "digest"] = Field(
        default="basic", description="HTTP authentication; a few servers want `digest`."
    )
    emails: list[str] = Field(
        default_factory=list,
        description="Your attendee address(es), to read your response to invitations. "
        "Default: the username, when it is an email address.",
    )
    include: list[str] = Field(
        default_factory=list,
        description="CalDAV: only these calendars, by name, id or URL; `*` and `?` are "
        "wildcards. Default: all. The one listed last wins an overlap.",
    )
    exclude: list[str] = Field(
        default_factory=list, description="CalDAV: leave these calendars out, after include."
    )
    calendars: dict[str, Overrides] = Field(
        default_factory=dict,
        description="Per-calendar reminder and status overrides, keyed by name or id.",
    )

    @model_validator(mode="before")
    @classmethod
    def _fold_password(cls, data: Any) -> Any:
        return _secret(dict(data), "password") if isinstance(data, dict) else data

    @model_validator(mode="after")
    def _defaults(self) -> SourceConfig:
        if self.url.startswith("webcal://"):
            self.url = "https://" + self.url.removeprefix("webcal://")
        if not self.emails and self.username and "@" in self.username:
            self.emails = [self.username]
        if self.type == "ics" and (self.include or self.exclude or self.calendars):
            # An ICS feed is one calendar, named after its source.
            raise ValueError(
                f"source {self.name!r}: include/exclude/calendars apply to caldav only"
            )
        return self


class Config(_Strict):
    bar: BarConfig = Field(default_factory=BarConfig)
    sync: SyncConfig = Field(default_factory=SyncConfig)
    display: DisplayConfig = Field(default_factory=DisplayConfig)
    defaults: Overrides = Field(default_factory=Overrides)
    # Empty only for the gallery app's --demo and --test, which need no calendar.
    sources: list[SourceConfig] = Field(default_factory=list)
    titles: list[TitleRule] = Field(default_factory=list)

    @model_validator(mode="after")
    def _unique_names(self) -> Config:
        names = [source.name for source in self.sources]
        if len(names) != len(set(names)):
            raise ValueError("source names must be unique")
        return self

    def settings_for(
        self, source: str, calendar: str, calendar_id: str = "", title: str = ""
    ) -> tuple[ReminderSettings, StatusSettings]:
        """
        The effective settings of one event: defaults, then the source, then the
        calendar's overrides by name, then by id, then every `[[titles]]` rule
        that matches the title, in order.
        """
        layers: list[Overrides] = [self.defaults]
        src = next((s for s in self.sources if s.name == source), None)
        if src is not None:  # an event of no configured source (a demo) gets the defaults
            layers.append(src)
            layers += [src.calendars[k] for k in (calendar, calendar_id) if k in src.calendars]
        layers += [rule for rule in self.titles if rule.matching(title) is not None]
        return _merged(layers)

    def title_patterns(self, title: str) -> list[str]:
        """The `[[titles]]` patterns that name this title, one per rule that applies."""
        return [p for rule in self.titles if (p := rule.matching(title)) is not None]


class ConfigError(Exception):
    pass


CONFIG_ENV = "BUSYBAR_CALENDAR_CONFIG"


def search_paths() -> list[Path]:
    """Where a config is looked for when none is named: here, then the user's config dir."""
    config_home = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
    return [Path("config.toml"), config_home / "busybar-calendar" / "config.toml"]


def find(explicit: str | Path | None = None) -> Path:
    """The config file: the one named, else $BUSYBAR_CALENDAR_CONFIG, else the first that exists."""
    if explicit:
        return Path(explicit)
    if os.environ.get(CONFIG_ENV):
        return Path(os.environ[CONFIG_ENV])
    for path in search_paths():
        if path.is_file():
            return path
    tried = ", ".join(str(path) for path in search_paths())
    raise ConfigError(
        f"no config yet (looked for {tried}): run `busybar-calendar init` to write one, "
        f"or name one with -c or ${CONFIG_ENV}"
    )


def load(path: str | Path) -> Config:
    if not Path(path).is_file():
        raise ConfigError(f"{path}: no such config; `busybar-calendar init` writes one")
    with open(path, "rb") as handle:
        data = tomllib.load(handle)
    config = Config.model_validate(data)
    if not config.sources:
        raise ConfigError(f"{path}: no [[sources]]: add a CalDAV account or an ICS feed")
    return config
src/busybar_calendar/connect.py
"""The plumbing every command shares: the bar client, the calendar sources, signals."""

from __future__ import annotations

import asyncio
import contextlib
import signal
from datetime import datetime, tzinfo
from zoneinfo import ZoneInfo

from busylib.client import AsyncBusyBar

from . import sources
from .config import Config, ConfigError
from .runner import Runner


def floating_tz(config: Config) -> tzinfo:
    if config.sync.timezone:
        return ZoneInfo(config.sync.timezone)
    local = datetime.now().astimezone().tzinfo
    assert local is not None
    return local


def make_sources(config: Config) -> list[sources.Source]:
    tz = floating_tz(config)
    return [
        sources.build(source, index, tz, config.sync.skip_free)
        for index, source in enumerate(config.sources)
    ]


def make_bar(config: Config) -> AsyncBusyBar:
    token = config.bar.token.resolve()
    if config.bar.address:
        return AsyncBusyBar(addr=config.bar.address, token=token)
    if not token:
        raise ConfigError("give bar.address, or a bar-scope cloud token as bar.token")
    return AsyncBusyBar(token=token)


async def close_all(items: list[sources.Source]) -> None:
    for item in items:
        await item.aclose()


async def read_calendars(config: Config, now: datetime) -> Runner:
    """The events and their settings as the service sees them at `now`, with no bar."""
    items = make_sources(config)
    runner = Runner(config, bar=None, sources=items)  # type: ignore[arg-type]
    try:
        await runner.refresh(now)
    finally:
        await close_all(items)
    return runner


def stop_on_signals() -> asyncio.Event:
    """An event that SIGINT or SIGTERM sets, so every mode can stop cleanly."""
    stop = asyncio.Event()
    loop = asyncio.get_running_loop()
    for sig in (signal.SIGINT, signal.SIGTERM):
        with contextlib.suppress(NotImplementedError):
            loop.add_signal_handler(sig, stop.set)
    return stop
src/busybar_calendar/diagnose.py
"""`check` and `status`: is the setup right, is the service running, what is on the bar."""

from __future__ import annotations

import asyncio
import contextlib
import json
import logging
import sys
from collections.abc import Callable, Iterator, Sequence
from datetime import UTC, datetime, timedelta, tzinfo
from pathlib import Path

from busylib.client import AsyncBusyBar
from busylib.exceptions import BusyBarAPIError

from . import __version__, connect, schedule, service, sources, update
from .config import Config, ConfigError
from .events import Event

LAN_TOKEN_LIMIT = 32  # the firmware refuses longer X-API-Token values


# report(ok, message, **fields): one finding of `check`, printed or collected as JSON.
Report = Callable[..., None]


async def _check_bar(config: Config, report: Report) -> None:
    where = config.bar.address or "BUSY Cloud"
    bar = connect.make_bar(config)
    try:
        try:
            version = await bar.version()
            firmware = await bar.status_firmware()
        except BusyBarAPIError as error:  # it answered: the token, not the network
            if error.status_code not in (401, 403):
                report(False, f"{where}: {error}", area="bar", where=where)
                return
            report(
                False,
                f"{where}: refused ({error.status_code}). {await _access_hint(bar, config, error)}",
                area="bar",
                where=where,
                status=error.status_code,
            )
            return
        except Exception as error:
            report(False, f"{where}: unreachable ({error})", area="bar", where=where)
            return
        report(
            True,
            f"{where}: firmware {firmware.version}, API {version.api_semver}, token accepted",
            area="bar",
            where=where,
            firmware=firmware.version,
            api=version.api_semver,
        )
    finally:
        await bar.aclose()


async def _access_hint(bar: AsyncBusyBar, config: Config, error: BusyBarAPIError) -> str:
    """Why an authenticated call was refused, in the words of the fix."""
    if not config.bar.address:
        return "The cloud token is wrong, revoked, or lacks the BUSY Bar access scope."
    try:
        access = await bar.access()  # answered without a token
    except Exception:
        access = None
    if access is not None and access.mode == "disabled":
        return (
            "The bar's HTTP API over Wi-Fi is disabled: enable it at http://10.0.4.20 "
            "(USB) -> Network -> HTTP API, or use a BUSY Cloud token without an address."
        )
    token = config.bar.token.resolve()
    if not token:
        return "The bar wants its HTTP API password: set bar.token / token_env / token_command."
    if len(token) > LAN_TOKEN_LIMIT:
        return (
            f"The token is {len(token)} characters; the bar takes at most "
            f"{LAN_TOKEN_LIMIT}. Is it a cloud token? Then leave bar.address out."
        )
    return "The bar did not accept the password."


async def _check_source(item: sources.Source, report: Report, tz: tzinfo) -> None:
    now = datetime.now(UTC)
    where = {"area": "calendar", "source": item.name}
    try:
        if isinstance(item, sources.CalDAVSource):
            found = await item.calendars()
            used, unmatched = sources.select(found, item.config.include, item.config.exclude)
            names = ", ".join(c.name for c in used) or "none"
            report(
                bool(used),
                f"{item.name}: signed in; {len(used)} of {len(found)} calendars used: {names}",
                **where,
                calendars=[c.name for c in used],
                available=len(found),
            )
            available = ", ".join(c.name for c in found[:MAX_NAMES])
            more = f" and {len(found) - MAX_NAMES} more" if len(found) > MAX_NAMES else ""
            for pattern in unmatched:
                report(
                    False,
                    f"{item.name}: no calendar matches {pattern!r}; there are: {available}{more}",
                    **where,
                    unmatched=pattern,
                )
            if not used:
                return  # no calendar is read, so there are no events to report on
        with _quiet(sources.logger):  # its warnings repeat what is reported here
            events = await item.fetch(now - timedelta(hours=24), now + timedelta(days=7))
    except Exception as error:
        report(False, f"{item.name}: {error}", **where)
        return
    ahead = sorted((e for e in events if e.end > now), key=lambda e: e.start)
    if not ahead:
        report(True, f"{item.name}: no events in the next 7 days", **where, events=0)
        return
    first = ahead[0]
    report(
        True,
        f"{item.name}: {len(ahead)} events in the next 7 days; next "
        f"{first.start.astimezone(tz):%a %d %b %H:%M} {first.title}",
        **where,
        events=len(ahead),
        next=_event_json(first, tz),
    )


MAX_NAMES = 8


@contextlib.contextmanager
def _quiet(log: logging.Logger) -> Iterator[None]:
    level = log.level
    log.setLevel(logging.ERROR)
    try:
        yield
    finally:
        log.setLevel(level)


def _iso(moment: datetime, tz: tzinfo) -> str:
    return moment.astimezone(tz).isoformat(timespec="minutes")


def _event_json(event: Event, tz: tzinfo) -> dict:
    return {
        "title": event.title,
        "start": _iso(event.start, tz),
        "end": _iso(event.end, tz),
        "calendar": event.calendar.name,
        "response": event.response,
    }


async def check(config: Config, as_json: bool = False) -> int:
    """Check the setup end to end: the bar, its token, every calendar."""
    findings: list[dict] = []

    def report(good: bool, text: str, **fields: object) -> None:
        findings.append({"ok": good, "message": text, **fields})
        if not as_json:
            print(f"  {'ok ' if good else 'FAIL'} {text}")

    if not as_json:
        print(f"busybar-calendar {__version__}\nBar")
    await _check_bar(config, report)
    if not as_json:
        print("Calendars")
    items = connect.make_sources(config)
    try:
        for item in items:
            await _check_source(item, report, connect.floating_tz(config))
    finally:
        await connect.close_all(items)
    problems = sum(not finding["ok"] for finding in findings)
    if as_json:
        result = {"version": __version__, "ok": not problems, "findings": findings}
        print(json.dumps(result, indent=2, ensure_ascii=False))
    else:
        print("\nAll good." if not problems else f"\n{problems} problem(s) above.")
    return 0 if not problems else 1


def _when(moment: datetime, now: datetime, tz: tzinfo) -> str:
    minutes = max(0, int((moment - now).total_seconds() // 60))
    hours, minutes = divmod(minutes, 60)
    delta = f"in {hours}h {minutes:02d}m" if hours else f"in {minutes}m"
    return f"{moment.astimezone(tz):%a %d %b %H:%M} ({delta})"


def _screen_json(screen: schedule.Screen, tz: tzinfo, **moment: str) -> dict:
    return {"kind": screen.kind, **_event_json(screen.event, tz), **moment}


BAR_TIMEOUT = 5.0  # seconds; a bar that takes longer is as good as not answering


async def bar_health(config: Config, timeout: float = BAR_TIMEOUT) -> dict:
    """Whether the bar answers at all: one light, read-only call, as `check` makes."""
    where = config.bar.address or "BUSY Cloud"
    try:
        bar = connect.make_bar(config)
    except ConfigError as error:  # no address and no token: nothing to ask
        return {"where": where, "answering": False, "detail": str(error)}
    try:
        await asyncio.wait_for(bar.version(), timeout)
        return {"where": where, "answering": True, "detail": None}
    except BusyBarAPIError as error:
        if error.status_code in (401, 403):  # it is there; the token is the problem
            return {
                "where": where,
                "answering": True,
                "detail": f"refused ({error.status_code}): `busybar-calendar check` says why",
            }
        return {"where": where, "answering": False, "detail": str(error)}
    except Exception as error:
        detail = (
            "no answer" if isinstance(error, TimeoutError) else str(error) or type(error).__name__
        )
        return {"where": where, "answering": False, "detail": detail}
    finally:
        with contextlib.suppress(Exception):
            await bar.aclose()


async def status_data(
    config: Config | None, path: Path | None = None, problems: Sequence[str] = ()
) -> dict:
    """
    What `status` reports, as data: the service, the config's problems if it
    does not load, and the calendar's screen now and next.
    """
    state = service.status()
    data: dict = {
        "version": __version__,
        "service": {
            "installed": state.installed,
            "running": state.running,
            "pid": state.pid,
            "detail": state.detail or None,
            "log": str(state.log) if state.log and state.log.exists() else None,
            "recent_log": service.recent_log(state),
            "version": service.running_version(state) if state.running else None,
            "python": service.installed_python() if state.installed else None,
            "auto_update": service.auto_updates() if state.installed else False,
        },
        "config": {"path": str(path) if path else None, "problems": list(problems)},
        "bar": None,
        "calendar": None,
    }
    if config is None:
        return data
    data["bar"] = await bar_health(config)
    now = datetime.now(UTC)
    runner = await connect.read_calendars(config, now)
    tz = connect.floating_tz(config)
    screen = schedule.choose(now, runner.events, runner.settings_for)
    following = schedule.upcoming(now, runner.events, runner.settings_for)
    data["calendar"] = {
        "lookahead_hours": config.sync.lookahead_hours,
        "now": _screen_json(screen, tz, until=_iso(screen.until, tz)) if screen else None,
        "next": _screen_json(following[1], tz, at=_iso(following[0], tz)) if following else None,
    }
    return data


def _environment(python: str) -> str:
    """The environment an interpreter belongs to (its bin/ folder's parent), home as ~."""
    folder = Path(python).parent.parent
    with contextlib.suppress(ValueError):
        return str(Path("~") / folder.relative_to(Path.home()))
    return str(folder)


def upgrade_command(python: str) -> str | None:
    """`uv tool upgrade` for a uv tool installed from git, the install it can move forward.

    A tool installed from a wheel file would reinstall that same file.
    """
    return " ".join(update.upgrade_command()) if update.from_git(python) else None


def print_status(data: dict) -> None:
    print(f"busybar-calendar {data['version']}")
    svc = data["service"]
    if svc["running"]:
        pid = f", pid {svc['pid']}" if svc["pid"] else ""
        running = svc.get("version")
        updating = ", updates itself at each start" if svc.get("auto_update") else ""
        print(f"Service   running{f' {running}' if running else ''}{pid}{updating}")
        if running and running != data["version"]:
            python = svc.get("python")
            if svc.get("auto_update"):
                print(
                    f"          that is {running}, not this {data['version']}: "
                    "`busybar-calendar restart` updates it"
                )
            elif python and python != sys.executable:
                # Another environment: restarting it starts the same old version again.
                print(
                    f"          that is {running}, from {_environment(python)}, "
                    f"not this {data['version']}:"
                )
                command = upgrade_command(python)
                how = f"`{command}`" if command else "upgrade that one"
                print(f"          {how}, then `busybar-calendar restart`")
            else:
                print(
                    f"          that is {running}, but this is {data['version']}: "
                    "`busybar-calendar restart` runs this one"
                )
    elif svc["installed"]:
        why = f" ({svc['detail']})" if svc["detail"] else ""
        print(f"Service   stopped{why}; the log says why")
    else:
        print("Service   not installed: `busybar-calendar install` runs it in the background")
    if svc["log"]:
        print(f"          log {svc['log']}")
    for line in svc["recent_log"]:
        print(f"            {line}")
    bar = data.get("bar")
    if bar is not None:
        if bar["answering"] and not bar["detail"]:
            print(f"Bar       {bar['where']}: answering")
        elif bar["answering"]:
            print(f"Bar       {bar['where']}: {bar['detail']}")
        else:
            print(
                f"Bar       {bar['where']}: NOT answering ({bar['detail']}). Is it on and "
                "online? If it stays stuck, unplug it and plug it back in."
            )
    problems = data["config"]["problems"]
    if problems:
        print(f"Config    does not load: {data['config']['path']}")
        if svc["running"]:
            print("          the running service keeps the config it last loaded")
        for line in problems:
            print(f"            {line}")
    calendar = data["calendar"]
    if calendar is None:
        if not problems:
            print("Calendar  no config yet: `busybar-calendar init` writes one")
        return
    now = datetime.now(UTC)
    if calendar["now"] is None:
        print("Calendar  nothing on the bar now")
    else:
        current = calendar["now"]
        until = datetime.fromisoformat(current["until"])
        print(
            f"Calendar  now {current['kind']}: {current['title']}, "
            f"until {_when(until, now, until.tzinfo)}"
        )
    if calendar["next"] is None:
        print(f"          nothing more in the next {calendar['lookahead_hours']} hours")
    else:
        nxt = calendar["next"]
        at = datetime.fromisoformat(nxt["at"])
        print(f"          next {nxt['kind']}: {nxt['title']}, at {_when(at, now, at.tzinfo)}")


async def status(
    config: Config | None,
    as_json: bool = False,
    *,
    path: Path | None = None,
    problems: Sequence[str] = (),
) -> int:
    """Is it running, what did it last do, and what is on the bar now and next.

    1 when the config does not load (`problems`), so a script notices.
    """
    data = await status_data(config, path, problems)
    if as_json:
        print(json.dumps(data, indent=2, ensure_ascii=False))
    else:
        print_status(data)
    return 1 if problems else 0
src/busybar_calendar/events.py
"""
Calendar events, reduced to what the bar needs, from iCalendar data.

CalDAV and ICS feeds both end up here: CalDAV returns one VCALENDAR per
event resource, an ICS feed returns one for the whole calendar, and either
way recurring events arrive as a master with RRULE/EXDATE and overrides.
Expansion happens here rather than on the server, because servers differ in
how well they implement CalDAV's `expand`.

The filtering follows the Google Calendar integration: events of 24 hours or
more are not shown, and neither are cancelled ones. Free events and
invitations you declined or delegated are skipped too, since none of them
makes you busy. Your answer to the rest (accepted, tentative, not yet) is
kept, because it decides which of two overlapping events the bar shows.
"""

from __future__ import annotations

import logging
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import UTC, date, datetime, timedelta, tzinfo

import icalendar
import recurring_ical_events

logger = logging.getLogger(__name__)

ALL_DAY = timedelta(hours=24)
UNTITLED = "Busy"


@dataclass(frozen=True)
class CalendarRef:
    source: str
    name: str
    # Position in the configuration: a calendar added later wins an overlap.
    order: int
    # The CalDAV calendar's id (its URL's last segment); empty for an ICS feed.
    id: str = ""


@dataclass(frozen=True)
class Event:
    uid: str
    title: str
    start: datetime
    end: datetime
    calendar: CalendarRef
    # Your answer to the invitation: "accepted", "tentative" or "needs-action".
    # Events of your own, with no attendees, count as accepted.
    response: str = "accepted"

    @property
    def key(self) -> str:
        """One occurrence of one event; a recurring event has one per start."""
        return f"{self.calendar.source}/{self.calendar.name}/{self.uid}@{self.start.isoformat()}"


def _aware(value: datetime, floating_tz: tzinfo) -> datetime:
    if value.tzinfo is None:
        value = value.replace(tzinfo=floating_tz)
    return value.astimezone(UTC)


# Which of two overlapping events wins first depends on how sure you are to
# be there. Declined and delegated invitations are not shown at all.
RESPONSE_RANK = {"accepted": 2, "tentative": 1, "needs-action": 0}
_PARTSTAT = {"ACCEPTED": "accepted", "TENTATIVE": "tentative"}
_NOT_ATTENDING = {"DECLINED", "DELEGATED"}


def _address(value: object) -> str:
    return str(value).lower().removeprefix("mailto:")


def _marked(component: icalendar.Event) -> str:
    """How sure the event itself says it is: Outlook's busy status, or STATUS."""
    busy = str(component.get("X-MICROSOFT-CDO-BUSYSTATUS", "")).upper()
    status = str(component.get("STATUS", "")).upper()
    return "tentative" if "TENTATIVE" in (busy, status) else "accepted"


def _response(component: icalendar.Event, emails: Iterable[str]) -> str | None:
    """
    Your answer to this event, or None when you are not going.

    Your own attendee entry decides, even on an event you organise. Without
    one to read - an event of your own, a published feed (which leaves the
    attendees out), or no address of yours to look for - the event's own
    marking does: tentative when it says so, accepted otherwise.
    """
    mine = {email.lower() for email in emails}
    attendees = component.get("ATTENDEE", [])
    if not isinstance(attendees, list):
        attendees = [attendees]
    if not attendees or not mine:
        return _marked(component)
    for attendee in attendees:
        if _address(attendee) in mine:
            # RFC 5545: an attendee without PARTSTAT has not answered.
            partstat = str(attendee.params.get("PARTSTAT", "NEEDS-ACTION")).upper()
            if partstat in _NOT_ATTENDING:
                return None
            return _PARTSTAT.get(partstat, "needs-action")
    if _address(component.get("ORGANIZER", "")) in mine:
        return "accepted"
    # Invited some other way, say through a group: no answer of yours on record.
    return "needs-action"


def parse(
    data: str | bytes | Iterable[str | bytes],
    calendar: CalendarRef,
    window_start: datetime,
    window_end: datetime,
    *,
    floating_tz: tzinfo,
    skip_free: bool = True,
    emails: Iterable[str] = (),
) -> list[Event]:
    """Every occurrence that overlaps the window and is worth showing."""
    documents = [data] if isinstance(data, str | bytes) else list(data)
    emails = list(emails)
    events: list[Event] = []
    for document in documents:
        try:
            parsed = icalendar.Calendar.from_ical(document)
            occurrences = recurring_ical_events.of(parsed).between(window_start, window_end)
        except Exception as error:  # one broken resource must not hide the rest
            logger.warning(
                "%s/%s: skipping unparsable calendar data: %s",
                calendar.source,
                calendar.name,
                error,
            )
            continue
        for component in occurrences:
            if component.name != "VEVENT":
                continue
            event = _event(component, calendar, floating_tz, skip_free, emails)
            if event is not None:
                events.append(event)
    events.sort(key=lambda e: (e.start, e.calendar.order))
    return events


def _event(
    component: icalendar.Event,
    calendar: CalendarRef,
    floating_tz: tzinfo,
    skip_free: bool,
    emails: list[str],
) -> Event | None:
    start_value = component.get("DTSTART")
    if start_value is None:
        return None
    start_raw = start_value.dt
    if isinstance(start_raw, date) and not isinstance(start_raw, datetime):
        return None  # an all-day event
    start = _aware(start_raw, floating_tz)

    if component.get("DTEND") is not None:
        end = _aware(component["DTEND"].dt, floating_tz)
    elif component.get("DURATION") is not None:
        end = start + component["DURATION"].dt
    else:
        end = start

    if end - start >= ALL_DAY:
        return None
    if str(component.get("STATUS", "")).upper() == "CANCELLED":
        return None
    if skip_free and str(component.get("TRANSP", "")).upper() == "TRANSPARENT":
        return None
    response = _response(component, emails)
    if response is None:
        return None

    title = " ".join(str(component.get("SUMMARY", "")).split()) or UNTITLED
    return Event(
        uid=str(component.get("UID", title)),
        title=title,
        start=start,
        end=end,
        calendar=calendar,
        response=response,
    )
src/busybar_calendar/icons.py
"""
Icons drawn inline, as XPM2, so nothing has to be uploaded to the bar first.

RED_CALENDAR is the "calendar" emoji (U+1F4C5) of Google's Noto Emoji
(https://github.com/googlefonts/noto-emoji, Copyright 2013 Google LLC),
scaled to 16x16 and redrawn pixel by pixel for the LED panel: a solid red
header with the binder holes, a white page and a crisp "17", without the
month lettering that blurs at this size. YELLOW_CALENDAR is the same
drawing with a yellow header, for events you marked tentative. As Modified
Versions of Noto Emoji they are under the SIL Open Font License 1.1, unlike
the rest of this project: see LICENSES/noto-emoji-OFL.txt.
"""

from __future__ import annotations

# One character per pixel: . clear, R the header colour, P its pale divider,
# W page, G page edge, K the digits (black is an unlit LED).
_CALENDAR_PIXELS = (
    ".RRRRRRRRRRRRRR.",
    "RR.RRRRRRRRRR.RR",
    "RRRRRRRRRRRRRRRR",
    "RRRRRRRRRRRRRRRR",
    "RRRRRRRRRRRRRRRR",
    "RRRRRRRRRRRRRRRR",
    "GPPPPPPPPPPPPPPG",
    "GWWWWWWWWWWWWWWG",
    "GWWWWKWWKKKKWWWG",
    "GWWWKKWWWWWKWWWG",
    "GWWWWKWWWWKWWWWG",
    "GWWWWKWWWWKWWWWG",
    "GWWWWKWWWKWWWWWG",
    "GWWWWKWWWKWWWWWG",
    "GWWWWWWWWWWWWWWG",
    ".GGGGGGGGGGGGGG.",
)


def _colours(header: str, divider: str) -> dict[str, str]:
    return {
        ".": "none",
        "R": header,
        "P": divider,
        "W": "#F5F5F5",
        "G": "#B0B0B0",
        "K": "#000000",
    }


def xpm(pixels: tuple[str, ...], colours: dict[str, str]) -> str:
    """An XPM2 image, in the form the bar's xpmbitmap element takes."""
    width, height = len(pixels[0]), len(pixels)
    assert all(len(row) == width for row in pixels)
    lines = ["! XPM2", f"{width} {height} {len(colours)} 1"]
    lines += [f"{char} c {value}" for char, value in colours.items()]
    lines += pixels
    return "\n".join(lines) + "\n"


RED_CALENDAR = xpm(_CALENDAR_PIXELS, _colours("#F44336", "#F8C6C2"))
YELLOW_CALENDAR = xpm(_CALENDAR_PIXELS, _colours("#FFC800", "#FFEBA0"))
src/busybar_calendar/playback.py
"""Showing events on the bar outside the schedule: replay a day, the demo, test frames."""

from __future__ import annotations

import argparse
import asyncio
import contextlib
import dataclasses
import json
import logging
from datetime import UTC, date, datetime, time, timedelta, tzinfo

from . import connect, render, schedule
from .config import Config
from .events import CalendarRef, Event
from .runner import Runner

logger = logging.getLogger(__name__)


def _day(text: str, tz: tzinfo) -> date:
    today = datetime.now(tz).date()
    named = {"today": today, "tomorrow": today + timedelta(days=1)}
    return named.get(text) or date.fromisoformat(text)


async def _wait(stop: asyncio.Event, moment: datetime) -> bool:
    """Sleep until `moment`; True if `stop` was set first."""
    delay = max(0.0, (moment - datetime.now(UTC)).total_seconds())
    with contextlib.suppress(TimeoutError):
        await asyncio.wait_for(stop.wait(), delay)
    return stop.is_set()


async def _show(runner: Runner, screen: schedule.Screen | None, *, chime: bool) -> None:
    """Draw (or, with no screen, clear) and carry on if the bar refuses: a 409 means
    something above our priority holds the screen - a menu, a button press."""
    try:
        if screen is None:
            await runner.clear()
        else:
            await runner.show(screen, chime=chime)
    except Exception as error:
        logger.warning("bar refused the update (%s); carrying on", error)


async def play(
    runner: Runner,
    events: list[Event],
    seconds: int,
    stop: asyncio.Event,
    *,
    chime: bool,
    tz: tzinfo | None = None,
) -> None:
    """
    Show events one after another, each in a slot of `seconds`.

    A third of the slot is the reminder, counting down to a start moved to
    its end; the rest is the status. Titles, settings and drawings are the
    events' own; only the times are not.
    """
    reminder_part = timedelta(seconds=max(5, seconds // 3))
    status_part = timedelta(seconds=seconds) - reminder_part
    for number, event in enumerate(events, 1):
        reminder, status = runner.settings_for(event)
        start = datetime.now(UTC).replace(microsecond=0) + reminder_part
        moved = dataclasses.replace(event, start=start, end=start + status_part)
        if tz is not None:
            print(
                f"{number:>2}/{len(events)}  {event.start.astimezone(tz):%H:%M}-"
                f"{event.end.astimezone(tz):%H:%M}  {event.title}  [{event.response}]",
                flush=True,
            )
        if reminder.enabled:
            await _show(runner, schedule.Screen("reminder", moved, reminder, status), chime=chime)
        if await _wait(stop, moved.start):
            return
        shown = schedule.shows_status(moved, status)
        await _show(
            runner,
            schedule.Screen("status", moved, reminder, status) if shown else None,
            chime=chime,
        )
        if await _wait(stop, moved.end):
            return


async def replay(config: Config, day: str, seconds: int, quiet: bool) -> int:
    """Play a day's real events on the bar, one after another, in short slots."""
    tz = connect.floating_tz(config)
    day = _day(day, tz)
    day_start = datetime.combine(day, time.min, tzinfo=tz)
    day_end = day_start + timedelta(days=1)
    items = connect.make_sources(config)
    try:
        found = await asyncio.gather(*(item.fetch(day_start, day_end) for item in items))
    finally:
        await connect.close_all(items)
    events = sorted(
        (e for events in found for e in events if day_start <= e.start < day_end),
        key=lambda e: (e.start, e.calendar.order),
    )
    if not events:
        print(f"No events on {day:%a %d %b}.")
        return 0
    print(f"Replaying {len(events)} events of {day:%a %d %b}, {seconds}s each.")
    bar = connect.make_bar(config)
    runner = Runner(config, bar, [])
    stop = connect.stop_on_signals()
    try:
        await play(runner, events, seconds, stop, chime=not quiet, tz=tz)
    finally:
        with contextlib.suppress(Exception):
            await runner.clear()
        await bar.aclose()
    return 0


DEMO_EVENTS = (
    ("Design review", "accepted"),
    ("1:1 with Alex", "accepted"),
    ("Quarterly planning with the design team", "tentative"),
)


def _demo_events() -> list[Event]:
    ref = CalendarRef("demo", "demo", 0)
    now = datetime.now(UTC)
    return [
        Event(f"demo-{i}", title, now, now + timedelta(minutes=30), ref, response)
        for i, (title, response) in enumerate(DEMO_EVENTS)
    ]


async def run_demo(config: Config, seconds: int = 20) -> int:
    """Loop a made-up schedule on the bar until a signal: reminders, then BUSY."""
    bar = connect.make_bar(config)
    runner = Runner(config, bar, [])
    stop = connect.stop_on_signals()
    try:
        while not stop.is_set():
            await play(runner, _demo_events(), seconds, stop, chime=False)
    finally:
        with contextlib.suppress(Exception):
            await runner.clear()
        await bar.aclose()
    return 0


async def draw_test_frame(config: Config) -> int:
    """Draw one made-up event in progress - the red calendar and BUSY - and exit."""
    event = dataclasses.replace(_demo_events()[0], start=datetime.now(UTC) - timedelta(minutes=2))
    reminder, status = config.settings_for("demo", "demo")
    screen = schedule.Screen("status", event, reminder, status)
    bar = connect.make_bar(config)
    try:
        drawing = render.drawing(
            screen, config.display, config.bar.application_name, config.bar.priority
        )
        await render.send(bar, drawing)
    finally:
        await bar.aclose()
    return 0


async def demo(config: Config, args: argparse.Namespace) -> int:
    """Draw a made-up event, to see the layout on the bar."""
    now = datetime.now(UTC)
    start = now + timedelta(minutes=args.minutes)
    if args.status:
        start = now - timedelta(minutes=1)
    event = Event(
        "demo", args.title, start, start + timedelta(minutes=30), CalendarRef("demo", "demo", 0)
    )
    first = config.sources[0].name
    reminder, status = config.settings_for(first, first, title=args.title)
    if args.show:
        status = status.model_copy(update={"show": args.show})
    screen = schedule.Screen("status" if args.status else "reminder", event, reminder, status)
    if args.font:
        config.display.title_font = args.font
    drawing = render.drawing(
        screen, config.display, config.bar.application_name, config.bar.priority
    )
    print(json.dumps(drawing.payload(), indent=2, ensure_ascii=False))
    bar = connect.make_bar(config)
    try:
        with contextlib.suppress(Exception):  # 400 when nothing of ours is up
            await bar.display_clear(application_name=config.bar.application_name)
        await render.send(bar, drawing)
        if args.sound and (sound := render.sound(screen, config.display)):
            await bar.audio_play(stock_path=sound, application_name=config.bar.application_name)
        await asyncio.sleep(args.seconds)
        with contextlib.suppress(Exception):
            await bar.display_clear(application_name=config.bar.application_name)
    finally:
        await bar.aclose()
    return 0
src/busybar_calendar/reference.py
"""
The configuration reference, generated from the models in config.py.

    python -m busybar_calendar.reference > docs/configuration.md

A test compares docs/configuration.md with this output, so the reference
cannot fall behind the code.
"""

from __future__ import annotations

from pydantic import BaseModel
from pydantic.fields import FieldInfo

from .config import (
    BarConfig,
    DisplayConfig,
    ReminderSettings,
    Secret,
    SourceConfig,
    StatusSettings,
    SyncConfig,
    TitleRule,
)

INTRO = """\
# Configuration reference

The config is a TOML file. `busybar-calendar init` writes one; it is found
in `~/.config/busybar-calendar/config.toml`, `./config.toml`, or wherever
`$BUSYBAR_CALENDAR_CONFIG` or `-c` points. Only `[[sources]]` is required;
everything else has the default shown.

Secrets - `token` and `password` - take one of three forms. Prefer the last
two, which keep the secret out of the file:

```toml
password = "..."                                  # inline
password_env = "CALDAV_PASSWORD"                  # an environment variable
password_command = ["security", "find-generic-password", "-s", "my-calendar", "-w"]
```

The command is run without a shell, and its output is the secret.

*Generated from the code by `python -m busybar_calendar.reference`; edit the
descriptions in `config.py`, not this file.*
"""

TITLES = """\
## `[[titles]]`

Settings for the events whose title matches, from whichever calendar: a
rule's `reminder` and `status` go on top of the calendar's, and a later rule
on top of an earlier one. To keep some events off the bar, and show lunch as
the bar's own status:

```toml
[[titles]]
match = ["Focus time", "Commute*"]
reminder.enabled = false
status.enabled = false

[[titles]]
match = "Lunch*"
reminder.enabled = false
status.show = "lunch"
```

`busybar-calendar upcoming` shows what each event ends up with.

"""


def _default(field: FieldInfo) -> str:
    if field.is_required():
        return "**required**"
    if field.default_factory is not None:
        made = field.default_factory()
        if isinstance(made, Secret):
            return "none"
        return "`[]`" if isinstance(made, list) else "`{}`"
    value = field.default
    if value is None:
        return "none"
    if isinstance(value, bool):
        return f"`{str(value).lower()}`"
    if isinstance(value, str):
        return f'`"{value}"`'
    return f"`{value}`"


def _key(name: str, field: FieldInfo) -> str:
    if field.annotation is Secret or field.annotation == "Secret":
        return f"`{name}` / `{name}_env` / `{name}_command`"
    return f"`{name}`"


def table(model: type[BaseModel], skip: tuple[str, ...] = ()) -> str:
    rows = ["| Key | Default | |", "| --- | --- | --- |"]
    for name, field in model.model_fields.items():
        if name in skip:
            continue
        text = (field.description or "").replace("|", "\\|")
        rows.append(f"| {_key(name, field)} | {_default(field)} | {text} |")
    return "\n".join(rows)


def markdown() -> str:
    sections = [
        INTRO,
        "## `[bar]`\n\nHow to reach the bar.\n\n" + table(BarConfig),
        "## `[sync]`\n\nHow the calendars are read.\n\n" + table(SyncConfig),
        "## `[display]`\n\nWhat the bar shows.\n\n" + table(DisplayConfig),
        "## `[defaults.reminder]`\n\nThe countdown before an event: the settings the bar's "
        "Google Calendar integration has per calendar. They layer: `[defaults.reminder]`, "
        "then a source's `[sources.reminder]`, then one calendar's "
        '`[sources.calendars."<name or id>".reminder]`, then the `[[titles]]` rules that '
        "match the event's title.\n\n" + table(ReminderSettings),
        "## `[defaults.status]`\n\nWhat the bar shows while an event is on. It layers the "
        "same way: `[defaults.status]`, `[sources.status]`, "
        '`[sources.calendars."<name or id>".status]`, `[[titles]]`.\n\n' + table(StatusSettings),
        "## `[[sources]]`\n\nOne CalDAV account or one ICS link each; at least one.\n\n"
        + table(SourceConfig, skip=("reminder", "status")),
        TITLES + table(TitleRule, skip=("reminder", "status")),
    ]
    return "\n\n".join(section.rstrip() for section in sections) + "\n"


if __name__ == "__main__":
    print(markdown(), end="")
src/busybar_calendar/render.py
"""
The drawing for a screen.

The front panel is 72x16. A reminder is a 16x16 calendar icon with the
event's title beside it and a countdown to the start. The status during an
event is the same with a countdown to the end and a red BUSY beside it, or
one of the bar's own 72x16 status animations. The icon is the bar's own
calendar animation or a red calendar drawn inline, per `[display]`. An event
you marked tentative gets a yellow calendar and a yellow BUSY instead.

Every element carries `display_until`, so the bar takes the drawing down by
itself even if this service has stopped - a minute after the screen ends
rather than on the second, because this service redraws or clears the same
elements right then, and a redraw that lands as the old element expires
loses its animation on firmware 1.2.4 (measured: 0 of 256 icon pixels lit;
with the expiry a few seconds later, the icon stays).
"""

from __future__ import annotations

import unicodedata
from typing import Annotated, Any, Literal, Protocol

from anyascii import anyascii
from busylib import types
from pydantic import BaseModel, Field

from . import icons
from .config import DisplayConfig
from .events import UNTITLED
from .schedule import Screen

WIDTH = 72
ICON = 16
TEXT_X = ICON + 2
REMINDER_ANIMATION = "shared/animations/calendar_reminder_16x16.anim"
EVENT_ANIMATION = "shared/animations/calendar_event_16x16.anim"
HIDDEN_TITLE = "Next event"
# What an event you marked tentative is drawn in; see `[display] tentative_yellow`.
TENTATIVE_COLOR = "#FFC800FF"
# How long a drawing outlives its screen if nothing replaces it; see above.
EXPIRY_GRACE = 60
DRAW_PATH = "/api/display/draw"

# Rough glyph advance per font, only to decide whether a line must scroll.
CHAR_WIDTH = {"tiny": 4, "small": 4, "global": 6}
# The countdown is drawn in the 7px superscript font; room for "1:05:00".
COUNTDOWN_WIDTH = 28
# The title's top_left y, calibrated in busylib: small glyphs land on rows 2-6.
TITLE_TOP_Y = {"tiny": 1, "small": 0}
# The superscript font sits its digits 4px above the bottom of its box, so a
# box anchored at y=16 puts them on rows 7-11, into the title's descenders.
# Anchored at 19 they land on rows 10-14, level with the icon's last row.
# Measured on firmware 1.2.4 from GET /api/screen.
COUNTDOWN_BOTTOM_Y = 19
# Beside an 11px global-font title, centred on the same rows (6-10).
COUNTDOWN_MID_Y = 9
# The BUSY label, in the small font on the countdown's rows (10-14): 8px
# below the title's anchor, whose glyphs start 2px down.
LABEL_TOP_Y = 8
SCROLL_RATE = 1200
SCROLL_START_DELAY = 1500
SCROLL_REPEAT_DELAY = 2500


class XpmBitmapElement(types.DisplayElementBase):
    """An inline XPM2 image: in the bar's API, but not yet among busylib's models."""

    type: Literal["xpmbitmap"] = "xpmbitmap"
    data: str
    opacity: int = Field(default=100, ge=0, le=100)


Element = Annotated[
    types.TextElement | types.AnimationElement | types.CountdownElement | XpmBitmapElement,
    Field(discriminator="type"),
]


class Drawing(BaseModel):
    """The body of POST /api/display/draw."""

    application_name: str = Field(min_length=1, max_length=32)
    priority: int = Field(ge=1, le=100)
    elements: list[Element] = Field(min_length=1)

    def payload(self) -> dict[str, Any]:
        return self.model_dump(mode="json", exclude_none=True)


class Poster(Protocol):
    async def api_request(self, method: str, path: str, **kwargs: Any) -> Any: ...


async def send(bar: Poster, drawing: Drawing) -> None:
    """Draw on the bar - on the LAN or through BUSY Cloud, whichever `bar` reaches."""
    await bar.api_request("POST", DRAW_PATH, json_payload=drawing.payload())


def _drawable(c: str) -> bool:
    # No font has control characters, emoji or other pictographs; and
    # transliterated, an emoji becomes ":coffee:".
    category = unicodedata.category(c)
    return category[0] != "C" and category != "So" and not "︀" <= c <= "️"


# A longer title only scrolls longer; and a draw request carrying a title of
# thousands of characters left a bar's HTTP API hanging (firmware 1.2.4,
# reached on the LAN and through BUSY Cloud alike) until it was power-cycled.
MAX_TITLE = 120


def title_text(title: str, display: DisplayConfig) -> str:
    """The title as the chosen font can draw it, and no longer than MAX_TITLE."""
    title = "".join(" " if c.isspace() else c for c in title if c.isspace() or _drawable(c))
    if display.title_font != "global":
        if display.transliterate:
            title = anyascii(title)
        title = "".join(c for c in title if " " <= c <= "~")
    title = " ".join(title.split()) or UNTITLED
    if len(title) > MAX_TITLE:
        title = title[: MAX_TITLE - 3].rstrip() + "..."
    return title


def _text(
    element_id: str, text: str, font: str, x: int, y: int, align: str, available: int, until: str
) -> types.TextElement:
    scroll = len(text) * CHAR_WIDTH.get(font, 5) > available
    return types.TextElement(
        id=element_id,
        text=text,
        font=font,
        x=x,
        y=y,
        align=align,
        display_until=until,
        width=available if scroll else None,
        scroll_rate=SCROLL_RATE if scroll else None,
        scroll_start_delay=SCROLL_START_DELAY if scroll else None,
        scroll_repeat_delay=SCROLL_REPEAT_DELAY if scroll else None,
    )


def _tentative(screen: Screen, display: DisplayConfig) -> bool:
    return display.tentative_yellow and screen.event.response == "tentative"


def _icon(choice: str, animation: str, until: str, tentative: bool) -> Element:
    # Different ids for the two kinds: a draw updates elements by id, and an
    # element is not turned from one kind into the other. The yellow calendar
    # has an id of its own so that going from one colour to the other redraws
    # the bitmap rather than relying on the bar to swap an element's pixels.
    if choice == "red_calendar":
        return XpmBitmapElement(
            id="cal_xpm_maybe" if tentative else "cal_xpm",
            data=icons.YELLOW_CALENDAR if tentative else icons.RED_CALENDAR,
            x=0,
            y=0,
            align="top_left",
            display_until=until,
        )
    return types.AnimationElement(
        id="cal_icon",
        stock_path=animation,
        loop=True,
        x=0,
        y=0,
        align="top_left",
        display_until=until,
    )


def _titled(
    screen: Screen, icon: Element, title: str, display: DisplayConfig, until: str
) -> list[Element]:
    """A calendar icon, a title, a countdown to `screen.until` and, during an event, BUSY."""
    font = display.title_font
    if font == "global":
        # 11px leaves no second line: title and countdown share one.
        available = WIDTH - TEXT_X - COUNTDOWN_WIDTH - 1
        title_at = (TEXT_X, 8, "mid_left")
        countdown_at = (WIDTH - 1, COUNTDOWN_MID_Y, "mid_right")
    else:
        available = WIDTH - TEXT_X
        title_at = (TEXT_X, TITLE_TOP_Y[font], "top_left")
        countdown_at = (TEXT_X, COUNTDOWN_BOTTOM_Y, "bottom_left")
    elements: list[Element] = [
        icon,
        _text("cal_title", title, font, *title_at, available, until),
        types.CountdownElement(
            id="cal_time",
            timestamp=str(int(screen.until.timestamp())),
            direction="time_left",
            show_hours="when_non_zero",
            x=countdown_at[0],
            y=countdown_at[1],
            align=countdown_at[2],
            display_until=until,
        ),
    ]
    # Beside a global-font title there is no room left for the label.
    if screen.kind == "status" and display.busy_label and font != "global":
        elements.append(
            types.TextElement(
                id="cal_busy",
                text=display.busy_label,
                font="small",
                color=TENTATIVE_COLOR if _tentative(screen, display) else display.busy_color,
                x=WIDTH,
                y=LABEL_TOP_Y,
                align="top_right",
                display_until=until,
            )
        )
    return elements


def elements(screen: Screen, display: DisplayConfig) -> list[Element]:
    until = str(int(screen.until.timestamp()) + EXPIRY_GRACE)
    if screen.kind == "reminder":
        title = (
            title_text(screen.event.title, display) if screen.reminder.show_name else HIDDEN_TITLE
        )
        icon = _icon(display.reminder_icon, REMINDER_ANIMATION, until, _tentative(screen, display))
        return _titled(screen, icon, title, display, until)

    if screen.status.show == "name":
        icon = _icon(display.event_icon, EVENT_ANIMATION, until, _tentative(screen, display))
        return _titled(screen, icon, title_text(screen.event.title, display), display, until)
    return [
        types.AnimationElement(
            id="cal_status",
            stock_path=f"shared/animations/{screen.status.show}_72x16.anim",
            loop=True,
            x=0,
            y=0,
            align="top_left",
            display_until=until,
        )
    ]


def drawing(
    screen: Screen, display: DisplayConfig, application_name: str, priority: int
) -> Drawing:
    return Drawing(
        application_name=application_name,
        priority=priority,
        elements=elements(screen, display),
    )


def sound(screen: Screen, display: DisplayConfig) -> str | None:
    """The stock sound for a screen appearing, if its settings want one."""
    if screen.kind == "reminder":
        return display.reminder_sound if screen.reminder.sound else None
    return display.event_sound if screen.status.sound else None
src/busybar_calendar/runner.py
"""
The loop: refresh the sources, decide the screen, draw it when it changes.

The loop sleeps until the next moment the screen can change - a reminder
opening, an event starting or ending - or the next refresh, whichever is
first, so a reminder appears on the second rather than on the next poll.

A source that fails for a while keeps its last good events, so a calendar
server hiccup does not take a running meeting off the bar. One that answers
401, 403 or 404 is configured wrong, and the loop stops and says so rather
than retrying it forever. A bar that refuses a
drawing (unreachable, or something above our priority on screen) is tried
again shortly.
"""

from __future__ import annotations

import asyncio
import contextlib
import logging
from collections.abc import Awaitable, Callable, Sequence
from datetime import UTC, datetime, timedelta
from typing import Any, Protocol

from busylib import types
from busylib.exceptions import BusyBarAPIError

from . import render, schedule
from .config import Config, ReminderSettings, StatusSettings
from .events import Event
from .sources import FatalSourceError, Source

logger = logging.getLogger(__name__)

# Upper bound on a sleep, so a laptop waking up catches up within a minute.
MAX_SLEEP = timedelta(seconds=60)
RETRY = timedelta(seconds=15)
# A sound plays only for a screen that has just opened: restarting this
# service in the middle of a meeting must not chime again.
SOUND_GRACE = timedelta(seconds=60)
# Events still running were in the past when they started; look back far
# enough to see any of them (longer ones count as all-day and are skipped).
LOOKBACK = timedelta(hours=24)

_UNKNOWN = object()


class Bar(Protocol):
    async def api_request(self, method: str, path: str, **kwargs: Any) -> Any: ...

    async def display_clear(self, **kwargs: Any) -> types.SuccessResponse: ...

    async def audio_play(self, *, stock_path: str | None = None, **kwargs: Any) -> Any: ...


class Runner:
    def __init__(
        self,
        config: Config,
        bar: Bar,
        sources: Sequence[Source],
        now: Callable[[], datetime] = lambda: datetime.now(UTC),
    ) -> None:
        self.config = config
        self.bar = bar
        self.sources = list(sources)
        self.now = now
        self._events: dict[str, list[Event]] = {}
        self._settings: dict[tuple[str, ...], tuple[ReminderSettings, StatusSettings]] = {}
        self._shown: object = _UNKNOWN
        # The element ids this service last left on the bar; None when unknown.
        self._drawn: frozenset[str] | None = None
        self._sounded: set[tuple[str, str]] = set()
        self._next_refresh = datetime.min.replace(tzinfo=UTC)

    @property
    def app(self) -> str:
        return self.config.bar.application_name

    @property
    def events(self) -> list[Event]:
        return [event for events in self._events.values() for event in events]

    def settings_for(self, event: Event) -> tuple[ReminderSettings, StatusSettings]:
        key = (event.calendar.source, event.calendar.name, event.calendar.id, event.title)
        if key not in self._settings:
            self._settings[key] = self.config.settings_for(*key)
        return self._settings[key]

    async def refresh(self, now: datetime) -> None:
        self._settings.clear()  # keyed by title too: keep only the current events'
        start = now - LOOKBACK
        end = now + timedelta(hours=self.config.sync.lookahead_hours)
        results = await asyncio.gather(
            *(source.fetch(start, end) for source in self.sources), return_exceptions=True
        )
        for result in results:
            if isinstance(result, FatalSourceError):
                raise result  # wrong credentials or URL: say so and stop
        for source, result in zip(self.sources, results, strict=True):
            if isinstance(result, BaseException):
                kept = len(self._events.get(source.name, []))
                logger.warning(
                    "%s: refresh failed (%s); keeping %d events", source.name, result, kept
                )
                continue
            self._events[source.name] = result
            logger.debug("%s: %d events", source.name, len(result))
        # Forget the chimes of events that are gone, or a service that runs for
        # months keeps every screen it ever showed.
        current = {event.key for event in self.events}
        self._sounded = {moment for moment in self._sounded if moment[1] in current}

    def reconfigure(self, config: Config, sources: Sequence[Source]) -> None:
        """
        Carry on with an edited config: read the calendars again at once, and
        redraw what is up - without a second chime for a screen that had one.
        Events already read are kept for the sources still configured, should
        the first read with the new config fail.
        """
        names = {source.name for source in sources}
        self._events = {name: found for name, found in self._events.items() if name in names}
        self.config = config
        self.sources = list(sources)
        self._settings.clear()
        self._next_refresh = datetime.min.replace(tzinfo=UTC)
        self._shown = _UNKNOWN

    def _opened_at(self, screen: schedule.Screen) -> datetime:
        if screen.kind == "status":
            return screen.event.start
        return screen.event.start - timedelta(minutes=screen.reminder.minutes_before)

    async def clear(self) -> None:
        """Remove this service's drawing, and only this service's."""
        try:
            await self.bar.display_clear(application_name=self.app)
        except BusyBarAPIError as error:
            # The bar answers 400 when this application has nothing on
            # screen - another app owns the canvas - which is already clear.
            if error.status_code != 400:
                raise
        self._drawn = frozenset()

    async def _draw(self, drawing: render.Drawing) -> None:
        """
        Put a drawing up in place of the last one.

        A draw merges into what the application already shows, by element id:
        it updates the ids it names and leaves the others up. So clear first
        only when something would be left over - a reminder's elements under
        a status animation - and otherwise update in place, which does not
        flicker: a reminder becoming its event only adds BUSY.
        """
        ids = frozenset(element.id for element in drawing.elements)
        if self._drawn is None or not self._drawn <= ids:
            await self.clear()
        self._drawn = None  # unknown until the draw is confirmed
        await render.send(self.bar, drawing)
        self._drawn = ids

    async def show(self, screen: schedule.Screen, *, chime: bool) -> None:
        """Put a screen on the bar, with its sound if `chime` and its settings want one."""
        await self._draw(
            render.drawing(screen, self.config.display, self.app, self.config.bar.priority)
        )
        logger.info(
            "%s: %s (%s, %s) until %s",
            screen.kind,
            screen.event.title,
            screen.event.calendar.name,
            screen.event.response,
            screen.until.isoformat(),
        )
        sound = render.sound(screen, self.config.display) if chime else None
        if sound is None:
            return
        try:
            await self.bar.audio_play(stock_path=sound, application_name=self.app)
        except Exception as error:  # a missing sound is not worth a redraw
            logger.warning("could not play %s: %s", sound, error)

    async def tick(self, now: datetime) -> None:
        screen = schedule.choose(now, self.events, self.settings_for)
        key = screen.key if screen is not None else None
        if key == self._shown:
            return
        try:
            if screen is None:
                await self.clear()
                logger.info("cleared")
            else:
                # Chime once per screen, and only one that has just opened:
                # restarting in the middle of a meeting must not chime again.
                moment = (screen.kind, screen.event.key)
                fresh = now - self._opened_at(screen) <= SOUND_GRACE
                await self.show(screen, chime=fresh and moment not in self._sounded)
                self._sounded.add(moment)
            self._shown = key
        except Exception as error:
            logger.warning("bar refused the update (%s); retrying", error)
            self._shown = _UNKNOWN
            self._drawn = None

    async def run(
        self, stop: asyncio.Event, before_each: Callable[[], Awaitable[None]] | None = None
    ) -> None:
        """Until `stop`; `before_each` runs at every wake, at least once a minute."""
        try:
            while not stop.is_set():
                if before_each is not None:
                    await before_each()
                now = self.now()
                if now >= self._next_refresh:
                    await self.refresh(now)
                    self._next_refresh = now + timedelta(minutes=self.config.sync.poll_minutes)
                await self.tick(now)

                wake = min(now + MAX_SLEEP, self._next_refresh)
                change = schedule.next_change(now, self.events, self.settings_for)
                if change is not None:
                    wake = min(wake, change)
                if self._shown is _UNKNOWN:
                    wake = min(wake, now + RETRY)
                timeout = max(0.2, (wake - self.now()).total_seconds())
                with contextlib.suppress(TimeoutError):
                    await asyncio.wait_for(stop.wait(), timeout)
        finally:
            try:
                await self.clear()
            except Exception as error:
                logger.warning("could not clear the bar on exit: %s", error)
src/busybar_calendar/schedule.py
"""
What the bar should show at a given moment, and when that next changes.

Each event can put up two screens: a reminder from `minutes_before` until it
starts, then its status until it ends. When several want the bar at once:

1. the event you are surest to attend wins: accepted, then tentative, then
   not answered yet;
2. between those, the newest event, as in the Google Calendar integration -
   so a reminder for your next meeting replaces the status of the one still
   running;
3. between events starting together, the calendar configured last.
"""

from __future__ import annotations

from collections.abc import Callable, Iterable
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Literal

from .config import ReminderSettings, StatusSettings
from .events import RESPONSE_RANK, Event

SettingsFor = Callable[[Event], tuple[ReminderSettings, StatusSettings]]


@dataclass(frozen=True)
class Screen:
    kind: Literal["reminder", "status"]
    event: Event
    reminder: ReminderSettings
    status: StatusSettings

    @property
    def until(self) -> datetime:
        return self.event.start if self.kind == "reminder" else self.event.end

    @property
    def key(self) -> tuple:
        """Equal keys draw the same thing, so nothing needs redrawing."""
        return (
            self.kind,
            self.event.key,
            self.event.title,
            self.event.end,
            self.event.response,
            self.reminder,
            self.status,
        )


def shows_status(event: Event, status: StatusSettings) -> bool:
    """Whether the event puts its status up while it is on."""
    return status.enabled and (status.tentative or event.response != "tentative")


def _reminder_from(event: Event, reminder: ReminderSettings) -> datetime:
    return event.start - timedelta(minutes=reminder.minutes_before)


def choose(now: datetime, events: Iterable[Event], settings_for: SettingsFor) -> Screen | None:
    best: Screen | None = None
    best_rank: tuple | None = None
    for event in events:
        reminder, status = settings_for(event)
        if shows_status(event, status) and event.start <= now < event.end:
            screen = Screen("status", event, reminder, status)
        elif reminder.enabled and _reminder_from(event, reminder) <= now < event.start:
            screen = Screen("reminder", event, reminder, status)
        else:
            continue
        rank = (RESPONSE_RANK[event.response], event.start, event.calendar.order, event.key)
        if best_rank is None or rank > best_rank:
            best, best_rank = screen, rank
    return best


def next_change(
    now: datetime, events: Iterable[Event], settings_for: SettingsFor
) -> datetime | None:
    """The first moment after `now` at which `choose` could answer differently."""
    upcoming = []
    for event in events:
        reminder, _ = settings_for(event)
        for moment in (_reminder_from(event, reminder), event.start, event.end):
            if moment > now:
                upcoming.append(moment)
    return min(upcoming, default=None)


def upcoming(
    now: datetime, events: Iterable[Event], settings_for: SettingsFor, horizon: int = 500
) -> tuple[datetime, Screen] | None:
    """The next moment a screen opens or gives way to another, and that screen."""
    events = list(events)
    current = choose(now, events, settings_for)
    moment: datetime | None = now
    for _ in range(horizon):
        moment = next_change(moment, events, settings_for)
        if moment is None:
            return None
        screen = choose(moment, events, settings_for)
        if screen is not None and (current is None or screen.key != current.key):
            return moment, screen
    return None
src/busybar_calendar/service.py
"""
Run the service in the background: a launchd agent on macOS, a systemd user
unit on Linux.

The service is started as `<this python> -m busybar_calendar run`, so it runs
from the same environment `install` was run from - a `uv tool` or a virtualenv
- and with $BUSYBAR_CALENDAR_CONFIG set to the config `install` found, so it
does not depend on a working directory. Restarts are throttled: a calendar
that refuses the password stops the service, and it must not then hammer the
server every few seconds.
"""

from __future__ import annotations

import os
import plistlib
import re
import shlex
import shutil
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path

from . import update
from .config import CONFIG_ENV

LABEL = "app.busybar.calendar"
UNIT = "busybar-calendar.service"
RESTART_AFTER = 300  # seconds between restarts after the service stops
GONE_WITHIN = 10.0  # seconds to wait for an unloaded launch agent to be gone


@dataclass(frozen=True)
class Plan:
    """What install writes and runs, so it can be shown before it is done."""

    path: Path
    content: bytes
    commands: list[list[str]]  # must succeed
    cleanup: tuple[tuple[str, ...], ...] = ()  # run first, may fail: nothing to stop is fine
    log: Path | None = None
    # Succeeds while the cleaned-up service is still there; commands wait until it fails.
    gone: tuple[str, ...] = ()


AUTO_UPDATE = "--auto-update"
# Where a service finds the tools it runs - git for uv, `security` for the
# Keychain - behind the folder uv is in when it updates itself.
SYSTEM_PATH = ("/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin")


def program(auto_update: bool = False) -> list[str]:
    return [sys.executable, "-m", "busybar_calendar", "run", *([AUTO_UPDATE] * auto_update)]


def _environment(config: Path, path: str | None) -> dict[str, str]:
    return {CONFIG_ENV: str(config), **({"PATH": path} if path else {})}


def update_path() -> str:
    """The PATH a self-updating service needs: uv's folder first. Refuses an install
    that cannot update itself, rather than one that fails at every start."""
    uv = shutil.which("uv")
    if uv is None:
        raise OSError(f"{AUTO_UPDATE} runs `uv tool upgrade`, and there is no uv on PATH")
    if not update.from_git(sys.executable):
        raise OSError(
            f"{AUTO_UPDATE} needs the uv tool installed from git - "
            "uv tool install git+https://github.com/kurok/busybar-calendar - "
            f"and this runs from {sys.executable}"
        )
    folders = [str(Path(uv).parent), *SYSTEM_PATH]
    return os.pathsep.join(dict.fromkeys(folders))


def launchd_plist(command: list[str], config: Path, log: Path, path: str | None = None) -> bytes:
    return plistlib.dumps(
        {
            "Label": LABEL,
            "ProgramArguments": command,
            "EnvironmentVariables": _environment(config, path),
            "RunAtLoad": True,
            "KeepAlive": True,
            "ThrottleInterval": RESTART_AFTER,
            "StandardOutPath": str(log),
            "StandardErrorPath": str(log),
        }
    )


def _systemd_quote(value: str) -> str:
    """systemd's own quoting: double quotes, with backslash escapes inside."""
    if value and not any(c.isspace() or c in '"\\' for c in value):
        return value
    return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'


def systemd_unit(command: list[str], config: Path, path: str | None = None) -> bytes:
    environment = "".join(
        f"Environment={_systemd_quote(f'{key}={value}')}\n"
        for key, value in _environment(config, path).items()
    )
    return (
        "[Unit]\n"
        "Description=BUSY Bar calendar reminders\n"
        "After=network-online.target\n\n"
        "[Service]\n"
        f"{environment}"
        f"ExecStart={' '.join(_systemd_quote(arg) for arg in command)}\n"
        "Restart=on-failure\n"
        f"RestartSec={RESTART_AFTER}\n\n"
        "[Install]\n"
        "WantedBy=default.target\n"
    ).encode()


def _home() -> Path:
    return Path.home()


def install_plan(config: Path, platform: str = sys.platform, auto_update: bool = False) -> Plan:
    """What `install` does; with `auto_update`, the service updates itself at each start."""
    config = config.resolve()
    path_env = update_path() if auto_update else None
    command = program(auto_update)
    if platform == "darwin":
        path = _home() / "Library" / "LaunchAgents" / f"{LABEL}.plist"
        log = _home() / "Library" / "Logs" / "busybar-calendar.log"
        domain = f"gui/{os.getuid()}"
        return Plan(
            path,
            launchd_plist(command, config, log, path_env),
            [["launchctl", "bootstrap", domain, str(path)]],
            cleanup=(("launchctl", "bootout", f"{domain}/{LABEL}"),),  # an older one
            log=log,
            gone=("launchctl", "print", f"{domain}/{LABEL}"),
        )
    if platform.startswith("linux"):
        path = _home() / ".config" / "systemd" / "user" / UNIT
        return Plan(
            path,
            systemd_unit(command, config, path_env),
            [
                ["systemctl", "--user", "daemon-reload"],
                ["systemctl", "--user", "enable", "--now", UNIT],
            ],
        )
    raise OSError(f"install supports macOS and Linux, not {platform}; see docs/setup.md")


def uninstall_plan(platform: str = sys.platform) -> Plan:
    if platform == "darwin":
        path = _home() / "Library" / "LaunchAgents" / f"{LABEL}.plist"
        return Plan(
            path, b"", [], cleanup=(("launchctl", "bootout", f"gui/{os.getuid()}/{LABEL}"),)
        )
    if platform.startswith("linux"):
        path = _home() / ".config" / "systemd" / "user" / UNIT
        return Plan(
            path,
            b"",
            [["systemctl", "--user", "daemon-reload"]],
            cleanup=(("systemctl", "--user", "disable", "--now", UNIT),),
        )
    raise OSError(f"uninstall supports macOS and Linux, not {platform}")


def _cleanup(plan: Plan) -> None:
    for command in plan.cleanup:
        subprocess.run(list(command), capture_output=True, check=False)


def _wait_until_gone(gone: tuple[str, ...], within: float = GONE_WITHIN) -> None:
    """
    `launchctl bootout` returns while the agent is still going away, and loading
    it again before it has gone fails ("Bootstrap failed: 5: Input/output
    error"): so a reinstall or restart waits for it to be gone.
    """
    if not gone:
        return
    deadline = time.monotonic() + within
    while subprocess.run(list(gone), capture_output=True, check=False).returncode == 0:
        if time.monotonic() >= deadline:
            return  # loading it says what is wrong
        time.sleep(0.25)


def install(plan: Plan) -> None:
    plan.path.parent.mkdir(parents=True, exist_ok=True)
    if plan.log is not None:
        plan.log.parent.mkdir(parents=True, exist_ok=True)
    _cleanup(plan)
    _wait_until_gone(plan.gone)
    plan.path.write_bytes(plan.content)
    for command in plan.commands:
        subprocess.run(command, check=True)


def uninstall(plan: Plan) -> bool:
    """Stop and remove the service; False if there was none."""
    if not plan.path.exists():
        return False
    _cleanup(plan)
    plan.path.unlink()
    for command in plan.commands:
        subprocess.run(command, check=True)
    return True


@dataclass(frozen=True)
class Status:
    installed: bool  # the agent / unit file is there
    running: bool
    pid: int | None = None
    detail: str = ""  # why it is not running, when that is known
    log: Path | None = None


def parse_launchctl(text: str) -> dict[str, str]:
    """The job's own fields from `launchctl print`: one tab deep; nested blocks go deeper."""
    fields: dict[str, str] = {}
    for line in text.splitlines():
        if line.startswith("\t") and not line.startswith("\t\t") and " = " in line:
            key, _, value = line.strip().partition(" = ")
            fields.setdefault(key, value)
    return fields


def parse_systemctl(text: str) -> dict[str, str]:
    return dict(line.split("=", 1) for line in text.splitlines() if "=" in line)


def status(platform: str = sys.platform) -> Status:
    plan = uninstall_plan(platform)
    installed = plan.path.exists()
    if platform == "darwin":
        log = _home() / "Library" / "Logs" / "busybar-calendar.log"
        done = subprocess.run(
            ["launchctl", "print", f"gui/{os.getuid()}/{LABEL}"],
            capture_output=True,
            text=True,
            check=False,
        )
        if done.returncode != 0:
            return Status(installed, False, detail="not loaded" if installed else "", log=log)
        fields = parse_launchctl(done.stdout)
        running = fields.get("state") == "running"
        pid = int(fields["pid"]) if fields.get("pid", "").isdigit() else None
        exit_code = fields.get("last exit code", "")
        detail = "" if running else f"last exit code {exit_code}" if exit_code else ""
        return Status(installed, running, pid, detail, log)
    done = subprocess.run(
        [
            "systemctl",
            "--user",
            "show",
            UNIT,
            "-p",
            "ActiveState",
            "-p",
            "MainPID",
            "-p",
            "ExecMainStatus",
        ],
        capture_output=True,
        text=True,
        check=False,
    )
    fields = parse_systemctl(done.stdout)
    running = fields.get("ActiveState") == "active"
    pid = int(fields["MainPID"]) if fields.get("MainPID", "0") not in ("", "0") else None
    exit_code = fields.get("ExecMainStatus", "")
    detail = "" if running else f"last exit code {exit_code}" if exit_code not in ("", "0") else ""
    return Status(installed, running, pid, detail)


def recent_log(state: Status, lines: int = 5) -> list[str]:
    """The service's last few log lines, best effort."""
    if state.log is not None:
        try:
            return state.log.read_text(errors="replace").splitlines()[-lines:]
        except OSError:
            return []
    done = subprocess.run(
        ["journalctl", "--user", "-u", UNIT, "-n", str(lines), "--no-pager", "-o", "cat"],
        capture_output=True,
        text=True,
        check=False,
    )
    return done.stdout.splitlines()[-lines:] if done.returncode == 0 else []


def restart(platform: str = sys.platform) -> bool:
    """Restart an installed service, at once; False if none is installed.

    On macOS the agent is unloaded and loaded again: `kickstart -k` would wait
    out ThrottleInterval - five minutes after the last start - before starting
    it, and fails for an agent that is not loaded.
    """
    path = uninstall_plan(platform).path
    if not path.exists():
        return False
    if platform == "darwin":
        domain = f"gui/{os.getuid()}"
        subprocess.run(["launchctl", "bootout", f"{domain}/{LABEL}"], capture_output=True)
        _wait_until_gone(("launchctl", "print", f"{domain}/{LABEL}"))
        command = ["launchctl", "bootstrap", domain, str(path)]
    else:
        command = ["systemctl", "--user", "restart", UNIT]
    return subprocess.run(command, capture_output=True, check=False).returncode == 0


def installed_command(platform: str = sys.platform) -> list[str]:
    """The command the installed service starts, read from its own file; [] if unknown."""
    path = uninstall_plan(platform).path
    try:
        if platform == "darwin":
            return list(plistlib.loads(path.read_bytes())["ProgramArguments"])
        for line in path.read_text().splitlines():
            if line.startswith("ExecStart="):
                return shlex.split(line.removeprefix("ExecStart="))
    except (OSError, ValueError, KeyError, TypeError):
        return []
    return []


def installed_python(platform: str = sys.platform) -> str | None:
    """The interpreter the installed service starts; None if unknown."""
    return next(iter(installed_command(platform)), None)


def auto_updates(platform: str = sys.platform) -> bool:
    """Whether the installed service updates itself as it starts."""
    return AUTO_UPDATE in installed_command(platform)


STARTED = re.compile(r"busybar-calendar (\S+) started")


def running_version(state: Status) -> str | None:
    """The version the service last started as, from its own log; None if unknown."""
    if state.log is not None:
        try:
            text = state.log.read_text(errors="replace")
        except OSError:
            return None
    else:
        done = subprocess.run(
            ["journalctl", "--user", "-u", UNIT, "-n", "2000", "--no-pager", "-o", "cat"],
            capture_output=True,
            text=True,
            check=False,
        )
        text = done.stdout if done.returncode == 0 else ""
    found = STARTED.findall(text)
    return found[-1] if found else None
src/busybar_calendar/sources.py
"""
Configured sources, each turned into a list of events for a time window.

A CalDAV source is one account with several calendars; an ICS source is a
single published feed and counts as one calendar named after the source.
"""

from __future__ import annotations

import asyncio
import logging
from collections.abc import Sequence
from datetime import datetime, tzinfo
from fnmatch import fnmatchcase

import httpx

from . import events
from .caldav import CalDAVAuthError, CalDAVClient, CalDAVError, RemoteCalendar
from .config import SourceConfig

logger = logging.getLogger(__name__)

USER_AGENT = "busybar-calendar"
# An ICS feed is the whole calendar, history included; refuse the absurd.
MAX_FEED_BYTES = 32 * 1024 * 1024
# Calendars are listed again after this many refreshes, to pick up new ones.
REDISCOVER_EVERY = 12


# Answers that mean the source is configured wrong - credentials, URL - so
# asking again will not help; anything else (timeouts, 5xx) is retried.
FATAL_STATUS = frozenset({401, 403, 404, 405, 410})


class FatalSourceError(Exception):
    """A source that cannot work as configured. The service stops on one."""


def matches(calendar: RemoteCalendar, pattern: str) -> bool:
    """
    Whether a filter entry names this calendar.

    An entry is a calendar's display name, its id (the last segment of its
    URL) or its URL, and may use the wildcards * and ?: "Office - Room *".
    """
    values = (calendar.name, calendar.id, calendar.url, calendar.url.rstrip("/"))
    return any(fnmatchcase(value, pattern) for value in values)


def select(
    calendars: Sequence[RemoteCalendar], include: Sequence[str], exclude: Sequence[str]
) -> tuple[list[RemoteCalendar], list[str]]:
    """
    The calendars to use, in overlap order, and the entries that matched nothing.

    With `include`, calendars come in the order their entries are listed, so
    the one listed last wins when two events start together; without it, all
    of them in the server's order. `exclude` then removes calendars.
    """
    if include:
        chosen: list[RemoteCalendar] = []
        for pattern in include:
            chosen += [c for c in calendars if c not in chosen and matches(c, pattern)]
    else:
        chosen = list(calendars)
    chosen = [c for c in chosen if not any(matches(c, pattern) for pattern in exclude)]
    unmatched = [
        pattern
        for pattern in (*include, *exclude)
        if not any(matches(calendar, pattern) for calendar in calendars)
    ]
    return chosen, unmatched


def http_client(source: SourceConfig) -> httpx.AsyncClient:
    auth: httpx.Auth | None = None
    password = source.password.resolve()
    if source.username is not None and password is not None:
        auth = (
            httpx.DigestAuth(source.username, password)
            if source.auth == "digest"
            else httpx.BasicAuth(source.username, password)
        )
    return httpx.AsyncClient(
        auth=auth,
        follow_redirects=True,
        timeout=httpx.Timeout(30.0),
        headers={"User-Agent": USER_AGENT},
    )


class Source:
    def __init__(self, config: SourceConfig, index: int, floating_tz: tzinfo, skip_free: bool):
        self.config = config
        self.name = config.name
        self._index = index
        self._floating_tz = floating_tz
        self._skip_free = skip_free

    def _ref(self, calendar: str, position: int, calendar_id: str = "") -> events.CalendarRef:
        return events.CalendarRef(self.name, calendar, self._index * 1000 + position, calendar_id)

    def _parse(self, data, ref, start, end) -> list[events.Event]:
        return events.parse(
            data,
            ref,
            start,
            end,
            floating_tz=self._floating_tz,
            skip_free=self._skip_free,
            emails=self.config.emails,
        )

    async def fetch(self, start: datetime, end: datetime) -> list[events.Event]:
        raise NotImplementedError

    async def aclose(self) -> None:
        pass


class ICSSource(Source):
    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)
        self._http = http_client(self.config)
        self._etag: str | None = None
        self._body: bytes | None = None

    async def fetch(self, start: datetime, end: datetime) -> list[events.Event]:
        headers = {"If-None-Match": self._etag} if self._etag and self._body else {}
        response = await self._http.get(self.config.url, headers=headers)
        if response.status_code in FATAL_STATUS:
            raise FatalSourceError(
                f"{self.name}: {self.config.url} answered {response.status_code}"
            )
        if response.status_code != 304:
            response.raise_for_status()
            if len(response.content) > MAX_FEED_BYTES:
                raise ValueError(f"{self.name}: feed is over {MAX_FEED_BYTES} bytes")
            self._body = response.content
            self._etag = response.headers.get("ETag")
        assert self._body is not None
        return self._parse(self._body, self._ref(self.name, 0), start, end)

    async def aclose(self) -> None:
        await self._http.aclose()


class CalDAVSource(Source):
    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)
        self._http = http_client(self.config)
        self._client = CalDAVClient(self._http, self.config.url)
        self._calendars: list[RemoteCalendar] = []
        self._since_listed = 0
        self._unmatched: list[str] = []

    async def calendars(self) -> list[RemoteCalendar]:
        """Every event calendar on the account, before `include` and `exclude`."""
        return await self._client.discover()

    def _keep_known(self, error: Exception) -> None:
        """Listing the calendars failed for now: go on with the ones known, if any."""
        if not self._calendars:
            raise error
        logger.warning(
            "%s: could not list the calendars (%s); going on with the ones it knows",
            self.name,
            error,
        )

    async def _selected(self) -> list[tuple[int, RemoteCalendar]]:
        if not self._calendars or self._since_listed >= REDISCOVER_EVERY:
            try:
                self._calendars = await self.calendars()
                self._since_listed = 0
            except CalDAVError as error:
                # No status: discovery found no calendar where it was pointed.
                if error.status is None or error.status in FATAL_STATUS:
                    raise FatalSourceError(f"{self.name}: {error}") from error
                self._keep_known(error)
            except httpx.HTTPError as error:
                self._keep_known(error)
        self._since_listed += 1
        selected, unmatched = select(self._calendars, self.config.include, self.config.exclude)
        if unmatched != self._unmatched:
            if unmatched:
                logger.warning("%s: no calendar matches %s", self.name, ", ".join(unmatched))
            self._unmatched = unmatched
        return list(enumerate(selected))

    async def fetch(self, start: datetime, end: datetime) -> list[events.Event]:
        selected = await self._selected()

        async def one(position: int, calendar: RemoteCalendar) -> list[events.Event]:
            try:
                documents = await self._client.events(calendar, start, end)
            except CalDAVAuthError as error:
                raise FatalSourceError(f"{self.name}: {error}") from error
            ref = self._ref(calendar.name, position, calendar.id)
            return self._parse(documents, ref, start, end)

        results = await asyncio.gather(*(one(i, c) for i, c in selected))
        return [event for result in results for event in result]

    async def aclose(self) -> None:
        await self._http.aclose()


def build(config: SourceConfig, index: int, floating_tz: tzinfo, skip_free: bool) -> Source:
    kind = CalDAVSource if config.type == "caldav" else ICSSource
    return kind(config, index, floating_tz, skip_free)
src/busybar_calendar/update.py
"""
Updating the install the service runs from, as it starts: `run --auto-update`.

Only a uv tool installed from git moves forward by itself: `uv tool upgrade`
fetches its branch again, while one installed from a wheel file would
reinstall that same file. The update runs before the config is read, so a
config that uses something newer gets the version that understands it.

After an update the process replaces itself with the same command, so the
new code runs at once; it does not update again in that second run.
"""

from __future__ import annotations

import contextlib
import importlib
import json
import logging
import os
import shutil
import subprocess
import sys
import tomllib
from importlib import metadata
from pathlib import Path
from typing import NoReturn

logger = logging.getLogger(__name__)

DIST = "busybar-calendar"
# At login the network can take a while to come up; past this, start as is.
TIMEOUT = 120
# Set for the process an update restarts into, so it does not update again.
UPDATED_ENV = "BUSYBAR_CALENDAR_UPDATED"


def upgrade_command(uv: str = "uv") -> list[str]:
    return [uv, "tool", "upgrade", DIST]


def from_git(python: str) -> bool:
    """Whether `python` is a uv tool installed from git, which `uv tool upgrade` moves forward."""
    receipt = Path(python).parent.parent / "uv-receipt.toml"
    with contextlib.suppress(OSError, ValueError, LookupError, TypeError):
        return "git" in tomllib.loads(receipt.read_text())["tool"]["requirements"][0]
    return False


def installed() -> str:
    """The version on disk now, with its git commit when it came from git; not
    necessarily the code this process runs."""
    importlib.invalidate_caches()
    try:
        dist = metadata.distribution(DIST)
    except metadata.PackageNotFoundError:
        return "unknown"
    commit = ""
    with contextlib.suppress(TypeError, ValueError, LookupError):  # no direct_url.json: None
        commit = json.loads(dist.read_text("direct_url.json"))["vcs_info"]["commit_id"]
    return f"{dist.version} ({commit[:7]})" if commit else dist.version


def update() -> bool:
    """`uv tool upgrade` the install this runs from; True when it now holds other code."""
    before = installed()
    if not from_git(sys.executable):
        logger.warning(
            "auto-update: only a uv tool installed from git updates itself; staying on %s",
            before,
        )
        return False
    uv = shutil.which("uv")
    if uv is None:
        logger.warning("auto-update: no uv on PATH; staying on %s", before)
        return False
    try:
        done = subprocess.run(
            upgrade_command(uv), capture_output=True, text=True, timeout=TIMEOUT, check=False
        )
    except (OSError, subprocess.TimeoutExpired) as error:
        logger.warning("auto-update failed (%s); staying on %s", error, before)
        return False
    if done.returncode != 0:
        lines = done.stderr.strip().splitlines() or [f"exit {done.returncode}"]
        logger.warning("auto-update failed (%s); staying on %s", lines[-1], before)
        return False
    after = installed()
    if after == before:
        logger.info("auto-update: %s is the latest", after)
        return False
    logger.info("auto-update: %s -> %s", before, after)
    return True


def restart_into_update() -> NoReturn:
    """Replace this process with the same command, which now runs the updated code."""
    os.environ[UPDATED_ENV] = "1"
    sys.stdout.flush()
    sys.stderr.flush()
    os.execv(sys.executable, [sys.executable, *sys.orig_argv[1:]])
src/busybar_calendar/wizard.py
"""
`busybar-calendar init`: a few questions instead of editing a TOML file.

It asks how to reach the bar and which calendar to read, signs in to list
the calendars, and writes the config. Passwords never pass through this
program: on macOS the Keychain's own prompt (`security ... -w`) reads them,
on Linux libsecret's (`secret-tool store`), and the config only names the
command that reads them back. Without either, the config names an
environment variable to set instead.
"""

from __future__ import annotations

import asyncio
import json
import shutil
import subprocess
import sys
import tomllib
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from zoneinfo import ZoneInfo

from . import sources
from .caldav import CalDAVError, RemoteCalendar
from .config import Config, ConfigError, SourceConfig, load

Ask = Callable[[str], str]
USB_HOST = "10.0.4.20"


# Servers to suggest by the address's domain; anything else is asked for.
@dataclass(frozen=True)
class Provider:
    label: str
    url: str = ""  # empty: asked for
    ask_for: str = ""  # what to ask, when the URL depends on the user's server
    note: str = ""  # said before the password is asked for
    domains: tuple[str, ...] = ()  # email domains that suggest it


PROVIDERS = (
    Provider(
        "iCloud",
        "https://caldav.icloud.com",
        note="iCloud needs an app-specific password: account.apple.com -> Sign-In and "
        "Security -> App-Specific Passwords.",
        domains=("icloud.com", "me.com", "mac.com"),
    ),
    Provider(
        "Fastmail",
        "https://caldav.fastmail.com",
        note="Fastmail needs an app password: Settings -> Privacy & Security.",
        domains=("fastmail.com", "fastmail.fm"),
    ),
    Provider("PrivateEmail", "https://dav.privateemail.com"),
    Provider("Nextcloud", ask_for="Your Nextcloud's address, e.g. https://cloud.example.com"),
    Provider("Another server", ask_for="The CalDAV server's URL"),
)
NAMES = {
    "PrivateEmail": "PrivateEmail (Namecheap)",
    "Nextcloud": "Nextcloud: asks for your server",
    "Another server": "Another server: Radicale, Baikal, Synology, Zoho, ... asks for its URL",
}


def _server(ask: Ask, domain: str) -> tuple[str, Provider]:
    """Pick the provider - guessed from the address - and the server URL it implies."""
    guess = next((n for n, p in enumerate(PROVIDERS, 1) if domain in p.domains), len(PROVIDERS))
    number = _choose(
        ask, "Which provider?", [NAMES.get(p.label, p.label) for p in PROVIDERS], guess
    )
    provider = PROVIDERS[number - 1]
    if provider.url:
        return provider.url, provider
    answer = _text(ask, provider.ask_for).rstrip("/")
    if not answer.startswith(("http://", "https://")):
        answer = "https://" + answer
    if provider.label == "Nextcloud" and not answer.endswith("/remote.php/dav"):
        answer += "/remote.php/dav"
    return answer, provider


@dataclass
class Secret:
    """How the config gets a secret back: a command, or an environment variable."""

    command: list[str] | None = None
    env: str | None = None

    def toml(self, key: str) -> str:
        if self.command:
            return f"{key}_command = {json.dumps(self.command, ensure_ascii=False)}"
        return f"{key}_env = {json.dumps(self.env)}"


class SecretStore:
    """Saves a secret with the system's own prompt, so it never passes through here."""

    def __init__(self, platform: str = sys.platform) -> None:
        self.platform = platform

    def save(self, service: str, account: str, env: str) -> Secret:
        if self.platform == "darwin" and shutil.which("security"):
            print("The Keychain asks for it twice (nothing shows as you type):")
            self._store(
                ["security", "add-generic-password", "-U", "-s", service, "-a", account, "-w"]
            )
            return Secret(command=["security", "find-generic-password", "-s", service, "-w"])
        if self.platform.startswith("linux") and shutil.which("secret-tool"):
            label = f"busybar-calendar {service}"
            self._store(["secret-tool", "store", "--label", label, "service", service])
            return Secret(command=["secret-tool", "lookup", "service", service])
        print(f"No keychain here: set {env} in the environment the service runs in.")
        return Secret(env=env)

    @staticmethod
    def _store(command: list[str], attempts: int = 3) -> None:
        """Run the keychain's own prompt; ask again when it fails, say a mistyped retype."""
        for attempt in range(1, attempts + 1):
            if subprocess.run(command, check=False).returncode == 0:
                return
            if attempt < attempts:
                print("That was not stored (did the two entries differ?). Once more:")
        raise ConfigError(f"{command[0]} did not store the password; run the command again")


def _choose(ask: Ask, question: str, options: list[str], default: int = 1) -> int:
    print(question)
    for number, option in enumerate(options, 1):
        print(f"  {number}  {option}")
    while True:
        answer = ask(f"Choose 1-{len(options)} [{default}]: ").strip() or str(default)
        if answer.isdigit() and 1 <= int(answer) <= len(options):
            return int(answer)
        print("  Please type one of the numbers.")


def _text(ask: Ask, question: str, default: str = "") -> str:
    while True:
        prompt = f"{question} [{default}]: " if default else f"{question}: "
        answer = ask(prompt).strip() or default
        if answer:
            return answer


@dataclass
class Answers:
    bar_lines: list[str] = field(default_factory=list)
    source_lines: list[str] = field(default_factory=list)

    def toml(self) -> str:
        return "\n".join(
            [
                "# Written by `busybar-calendar init`. Every setting: config.example.toml.",
                "",
                "[bar]",
                *self.bar_lines,
                "",
                "[[sources]]",
                *self.source_lines,
                "",
            ]
        )


def ask_bar(ask: Ask, store: SecretStore, answers: Answers) -> None:
    how = _choose(
        ask,
        "How should it reach the BUSY Bar?",
        [
            "BUSY Cloud: works from anywhere; needs a token from your BUSY account "
            '("BUSY Bar" access scope)',
            "Wi-Fi: the bar's address; needs its HTTP API enabled, with a password",
            f"USB cable: {USB_HOST}, no password",
        ],
    )
    if how == 3:
        answers.bar_lines.append(f"address = {json.dumps(USB_HOST)}")
        return
    if how == 2:
        address = _text(ask, "The bar's address (Settings -> Wi-Fi -> View IP Address)")
        answers.bar_lines.append(f"address = {json.dumps(address)}")
        print("Its HTTP API password (32 characters at most):")
    else:
        print("The BUSY Cloud token:")
    secret = store.save("busybar-calendar-bar", "busybar-calendar", "BUSYBAR_TOKEN")
    answers.bar_lines.append(secret.toml("token"))


def _unique(name: str, taken: frozenset[str]) -> str:
    number, candidate = 2, name
    while candidate in taken:
        candidate, number = f"{name} {number}", number + 1
    return candidate


def ask_calendar(
    ask: Ask,
    store: SecretStore,
    answers: Answers,
    discover: Callable[[SourceConfig], list[RemoteCalendar]],
    taken: frozenset[str] = frozenset(),
) -> str:
    """Ask for one calendar source, add its lines to `answers`, and return its name."""
    kind = _choose(
        ask,
        "Which calendar?",
        [
            "A CalDAV account: iCloud, Fastmail, Nextcloud, PrivateEmail, Radicale, ...",
            "An ICS / webcal link: Outlook's published calendar, Google's secret address, ...",
        ],
    )
    if kind == 2:
        url = _text(ask, "The link")
        name = _unique("Calendar", taken)
        answers.source_lines += [
            f"name = {json.dumps(name)}",
            'type = "ics"',
            f"url = {json.dumps(url)}",
        ]
        return name

    username = _text(ask, "Your username, usually the email address")
    domain = username.rpartition("@")[2].lower()
    url, provider = _server(ask, domain)
    if provider.note:
        print(provider.note)
    print("The calendar password:")
    # One Keychain entry per account: two accounts on one domain must not share it.
    secret = store.save(f"busybar-calendar-{username}", username, "CALDAV_PASSWORD")
    label = provider.label if provider.url else ""  # a fixed provider names the source
    name = _unique(label or domain or "CalDAV", taken)
    lines = [
        f"name = {json.dumps(name)}",
        'type = "caldav"',
        f"url = {json.dumps(url)}",
        f"username = {json.dumps(username)}",
        secret.toml("password"),
    ]
    fields: dict = {"name": name, "type": "caldav", "url": url, "username": username}
    if secret.command:
        fields["password_command"] = secret.command
    else:
        fields["password_env"] = secret.env
    source = SourceConfig.model_validate(fields)
    try:
        found = discover(source)
    except (CalDAVError, sources.FatalSourceError, OSError) as error:
        print(f"Could not sign in: {error}")
        print("The config is written anyway; fix it, then run: busybar-calendar check")
        answers.source_lines += lines
        return name
    print(f"Signed in: {len(found)} calendars.")
    for number, calendar in enumerate(found, 1):
        print(f"  {number:>2}  {calendar.name}  (id {calendar.id})")
    picked = _pick(ask, len(found))
    if picked and len(picked) < len(found):
        names = [found[i - 1].name for i in picked]
        lines.append(f"include = {json.dumps(names, ensure_ascii=False)}")
    answers.source_lines += lines
    return name


def _pick(ask: Ask, count: int) -> list[int]:
    while True:
        answer = ask(
            "Which to show? Numbers separated by commas, Enter for all "
            "(shared calendars such as meeting rooms are better left out): "
        ).strip()
        if not answer:
            return []
        numbers = [part.strip() for part in answer.split(",") if part.strip()]
        if all(n.isdigit() and 1 <= int(n) <= count for n in numbers):
            return sorted({int(n) for n in numbers})
        print(f"  Use numbers from 1 to {count}.")


def _discover(source: SourceConfig) -> list[RemoteCalendar]:
    async def run() -> list[RemoteCalendar]:
        item = sources.CalDAVSource(source, 0, ZoneInfo("UTC"), True)
        try:
            return await item.calendars()
        finally:
            await item.aclose()

    return asyncio.run(run())


def run(
    path: Path,
    *,
    ask: Ask = input,
    store: SecretStore | None = None,
    discover: Callable[[SourceConfig], list[RemoteCalendar]] = _discover,
    overwrite: bool = False,
) -> int:
    if path.exists() and not overwrite:
        answer = ask(f"{path} exists. Replace it? [y/N]: ").strip().lower()
        if answer not in ("y", "yes"):
            print("Left as it is.")
            return 1
    store = store or SecretStore()
    answers = Answers()
    ask_bar(ask, store, answers)
    print()
    ask_calendar(ask, store, answers, discover)
    text = answers.toml()
    Config.model_validate(tomllib.loads(text))  # what load() will read, checked before saving
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(text)
    path.chmod(0o600)
    print(f"\nWrote {path}.")
    print("Next: busybar-calendar check, then busybar-calendar install to keep it running.")
    return 0


def add(
    path: Path,
    *,
    ask: Ask = input,
    store: SecretStore | None = None,
    discover: Callable[[SourceConfig], list[RemoteCalendar]] = _discover,
) -> int:
    """Append one more calendar source to an existing config, keeping the rest as it is."""
    current = load(path)  # also says how to make one, when there is none
    existing = path.read_text()
    answers = Answers()
    taken = frozenset(source.name for source in current.sources)
    name = ask_calendar(ask, store or SecretStore(), answers, discover, taken)
    text = existing.rstrip("\n") + "\n\n[[sources]]\n" + "\n".join(answers.source_lines) + "\n"
    Config.model_validate(tomllib.loads(text))  # the whole file, checked before saving
    path.write_text(text)
    print(f"\nAdded {name!r} to {path}. Next: busybar-calendar check")
    return 0
tests/conftest.py
import pytest

from busybar_calendar import service


@pytest.fixture(autouse=True)
def no_real_service_files(tmp_path_factory, monkeypatch):
    """Keep tests off this machine's own launchd agent or systemd unit."""
    home = tmp_path_factory.mktemp("home")
    monkeypatch.setattr(service, "_home", lambda: home)
tests/test_app.py
"""app.py, the community-gallery entrypoint at the repository root."""

import importlib.util
from pathlib import Path

import pytest

from busybar_calendar.config import CONFIG_ENV, ConfigError

ROOT = Path(__file__).resolve().parent.parent
spec = importlib.util.spec_from_file_location("gallery_app", ROOT / "app.py")
app = importlib.util.module_from_spec(spec)
spec.loader.exec_module(app)

ICS = '[[sources]]\nname = "Feed"\ntype = "ics"\nurl = "https://example.com/a.ics"\n'


@pytest.fixture
def no_config(tmp_path, monkeypatch):
    monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
    monkeypatch.delenv(CONFIG_ENV, raising=False)
    monkeypatch.delenv("BUSYBAR_TOKEN", raising=False)
    monkeypatch.chdir(tmp_path)
    return tmp_path


def config_for(argv):
    args = app.parse_args(argv)
    path = app.config_file(args)
    return path, app.build_config(args, path)


def test_gallery_defaults_without_a_config(no_config):
    path, config = config_for([])
    assert path is None
    assert config.bar.address == "10.0.4.20"  # the USB bar
    assert config.bar.application_name == "busybar-calendar"
    assert config.sources == []  # so main() plays the demo


def test_the_config_init_wrote_is_used(no_config, monkeypatch):
    written = no_config / "xdg" / "busybar-calendar" / "config.toml"
    written.parent.mkdir(parents=True)
    written.write_text(ICS)
    path, config = config_for(["--host", "192.168.1.50"])
    assert path == written
    assert config.sources[0].name == "Feed"
    assert config.bar.address == "192.168.1.50"  # --host still wins


def test_calendar_flags_win_over_a_config(no_config):
    (no_config / "config.toml").write_text(ICS)
    path, config = config_for(["--ics", "webcal://example.com/b.ics", "--minutes", "10"])
    assert path is None
    assert config.sources[0].url == "https://example.com/b.ics"
    assert config.settings_for("ics", "ics")[0].minutes_before == 10


def test_caldav_flags_read_the_password_from_the_environment(no_config):
    _, config = config_for(
        ["--caldav", "https://dav.example.com", "--user", "me@example.com", "--include", "Work"]
    )
    source = config.sources[0]
    assert (source.password.env, source.include) == ("CALDAV_PASSWORD", ["Work"])


def test_cloud_needs_a_token(no_config, monkeypatch):
    with pytest.raises(ConfigError, match="BUSYBAR_TOKEN"):
        config_for(["--cloud", "--ics", "https://example.com/a.ics"])
    monkeypatch.setenv("BUSYBAR_TOKEN", "t")
    _, config = config_for(["--cloud", "--ics", "https://example.com/a.ics"])
    assert config.bar.address is None and config.bar.token.resolve() == "t"


# Runs app.py, and sends SIGINT to itself the moment app.py imports its package:
# mid-import, before any of the app's own handling exists - whatever the
# machine's speed, unlike a timed signal.
INTERRUPT_MID_IMPORT = """
import builtins, os, runpy, signal, sys
sys.argv = [sys.argv[1], "--demo", "--host", "127.0.0.1:9"]
real_import = builtins.__import__
def interrupting_import(name, *args, **kwargs):
    if name == "busybar_calendar":
        builtins.__import__ = real_import
        os.kill(os.getpid(), signal.SIGINT)
    return real_import(name, *args, **kwargs)
builtins.__import__ = interrupting_import
runpy.run_path(sys.argv[0], run_name="__main__")
"""


def _interrupt(command, after):
    import signal
    import subprocess
    import time

    proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
    if after:
        time.sleep(after)
        proc.send_signal(signal.SIGINT)
    _, err = proc.communicate(timeout=10)
    return proc.returncode, err


def test_ctrl_c_while_starting_ends_it_cleanly(no_config):
    """The gallery's checker sends SIGINT and expects exit 0 and no traceback, also
    when it lands mid-import, as on the slow first start after an install."""
    import sys

    code, err = _interrupt([sys.executable, "-c", INTERRUPT_MID_IMPORT, str(ROOT / "app.py")], 0)
    assert code == 0, err
    assert "Traceback" not in err


def test_ctrl_c_while_running_clears_and_ends_it_cleanly(no_config):
    import sys

    # The demo against a bar that is not there keeps trying until stopped.
    command = [sys.executable, str(ROOT / "app.py"), "--demo", "--host", "127.0.0.1:9"]
    code, err = _interrupt(command, 1.5)
    assert code == 0, err
    assert "Traceback" not in err


def test_bad_flags_fail_at_start(no_config):
    from pydantic import ValidationError

    with pytest.raises(ValidationError, match="minutes_before"):
        config_for(["--ics", "https://example.com/a.ics", "--minutes", "0"])
tests/test_caldav.py
"""CalDAV discovery and queries against a fake server shaped like iCloud's."""

from datetime import UTC, datetime

import httpx
import pytest

from busybar_calendar.caldav import CalDAVAuthError, CalDAVClient, CalDAVError, RemoteCalendar

EVENT = (
    "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:x\r\nSUMMARY:Sync &amp; plan\r\n"
    "DTSTART:20261013T090000Z\r\nDTEND:20261013T093000Z\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
)


def multistatus(*responses: str) -> str:
    body = "".join(f"<d:response>{r}</d:response>" for r in responses)
    return (
        '<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" '
        f'xmlns:c="urn:ietf:params:xml:ns:caldav">{body}</d:multistatus>'
    )


def ok(href: str, props: str, missing: str = "") -> str:
    out = f"<d:href>{href}</d:href><d:propstat><d:prop>{props}</d:prop>"
    out += "<d:status>HTTP/1.1 200 OK</d:status></d:propstat>"
    if missing:
        out += f"<d:propstat><d:prop>{missing}</d:prop>"
        out += "<d:status>HTTP/1.1 404 Not Found</d:status></d:propstat>"
    return out


class FakeServer:
    """caldav.example redirects /.well-known; the account lives on p01-caldav.example."""

    def __init__(self, status: int = 207) -> None:
        self.status = status
        self.reports: list[str] = []

    def __call__(self, request: httpx.Request) -> httpx.Response:
        url = str(request.url)
        if self.status != 207:
            return httpx.Response(self.status)
        if url == "https://caldav.example/":
            return httpx.Response(405)
        if url == "https://caldav.example/.well-known/caldav":
            # A 302, as PrivateEmail and many others send: an HTTP client that
            # turns it into a GET would get this server's 405 below.
            return httpx.Response(302, headers={"Location": "/dav/"})
        if url == "https://caldav.example/dav/" and request.method != "PROPFIND":
            return httpx.Response(405)
        if url == "https://caldav.example/dav/":
            return httpx.Response(
                207,
                text=multistatus(
                    ok(
                        "/dav/",
                        "<d:current-user-principal><d:href>https://p01-caldav.example/123/principal/"
                        "</d:href></d:current-user-principal>",
                    )
                ),
            )
        if url == "https://p01-caldav.example/123/principal/":
            return httpx.Response(
                207,
                text=multistatus(
                    ok(
                        "/123/principal/",
                        "<c:calendar-home-set><d:href>/123/calendars/</d:href></c:calendar-home-set>",
                    )
                ),
            )
        if url == "https://p01-caldav.example/123/calendars/" and request.method == "PROPFIND":
            return httpx.Response(
                207,
                text=multistatus(
                    ok("/123/calendars/", "<d:resourcetype><d:collection/></d:resourcetype>"),
                    ok(
                        "/123/calendars/work/",
                        "<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>"
                        "<d:displayname>Work</d:displayname><c:supported-calendar-component-set>"
                        '<c:comp name="VEVENT"/></c:supported-calendar-component-set>',
                    ),
                    ok(
                        "/123/calendars/tasks/",
                        "<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>"
                        "<d:displayname>Reminders</d:displayname><c:supported-calendar-component-set>"
                        '<c:comp name="VTODO"/></c:supported-calendar-component-set>',
                    ),
                    ok(
                        "/123/calendars/home/",
                        "<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>",
                        missing="<d:displayname/>",
                    ),
                ),
            )
        if request.method == "REPORT" and url == "https://p01-caldav.example/123/calendars/work/":
            assert request.headers["Depth"] == "1"
            self.reports.append(request.content.decode())
            return httpx.Response(
                207,
                text=multistatus(
                    ok("/123/calendars/work/x.ics", f"<c:calendar-data>{EVENT}</c:calendar-data>"),
                    ok("/123/calendars/work/empty.ics", "<c:calendar-data/>"),
                ),
            )
        if url == "https://caldav.example/direct/" and request.method == "PROPFIND":
            return httpx.Response(
                207,
                text=multistatus(
                    ok(
                        "/direct/",
                        "<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>"
                        "<d:displayname>Direct</d:displayname>",
                    )
                ),
            )
        return httpx.Response(404)


def client(server: FakeServer, url: str) -> CalDAVClient:
    http = httpx.AsyncClient(transport=httpx.MockTransport(server), follow_redirects=True)
    return CalDAVClient(http, url)


async def test_discovers_through_well_known_and_another_host():
    calendars = await client(FakeServer(), "https://caldav.example/").discover()
    assert calendars == [
        RemoteCalendar("https://p01-caldav.example/123/calendars/work/", "Work"),
        RemoteCalendar(
            "https://p01-caldav.example/123/calendars/home/",
            "https://p01-caldav.example/123/calendars/home/",
        ),
    ]


async def test_direct_calendar_url():
    calendars = await client(FakeServer(), "https://caldav.example/direct/").discover()
    assert calendars == [RemoteCalendar("https://caldav.example/direct/", "Direct")]


async def test_report_with_time_range():
    server = FakeServer()
    work = RemoteCalendar("https://p01-caldav.example/123/calendars/work/", "Work")
    documents = await client(server, "https://caldav.example/").events(
        work, datetime(2026, 10, 12, tzinfo=UTC), datetime(2026, 10, 13, 12, tzinfo=UTC)
    )
    assert len(documents) == 1
    assert "SUMMARY:Sync & plan" in documents[0]
    assert 'start="20261012T000000Z" end="20261013T120000Z"' in server.reports[0]


async def test_auth_failure_is_reported_not_retried_elsewhere():
    with pytest.raises(CalDAVAuthError):
        await client(FakeServer(status=401), "https://caldav.example/").discover()


async def test_nothing_to_discover():
    with pytest.raises(CalDAVError, match="current-user-principal"):
        await client(FakeServer(), "https://nowhere.example/").discover()


async def test_errors_carry_their_status():
    with pytest.raises(CalDAVAuthError) as caught:
        await client(FakeServer(status=401), "https://caldav.example/").discover()
    assert caught.value.status == 401
tests/test_cli.py
import asyncio
import json
import sys
from datetime import UTC, datetime
from pathlib import Path

import pytest
from busylib import types
from busylib.exceptions import BusyBarAPIError

from busybar_calendar import cli, connect, diagnose
from busybar_calendar import config as config_module
from busybar_calendar.config import CONFIG_ENV, Config, ConfigError, find
from busybar_calendar.events import CalendarRef, Event

SOURCES = {"sources": [{"name": "Feed", "type": "ics", "url": "https://x"}]}


def test_find_prefers_flag_then_env_then_cwd_then_config_dir(tmp_path, monkeypatch):
    home = tmp_path / "xdg"
    (home / "busybar-calendar").mkdir(parents=True)
    in_home = home / "busybar-calendar" / "config.toml"
    in_home.write_text("")
    monkeypatch.setenv("XDG_CONFIG_HOME", str(home))
    monkeypatch.delenv(CONFIG_ENV, raising=False)
    monkeypatch.chdir(tmp_path)

    assert find() == in_home
    (tmp_path / "config.toml").write_text("")
    assert find() == Path("config.toml")
    monkeypatch.setenv(CONFIG_ENV, "/elsewhere.toml")
    assert find() == Path("/elsewhere.toml")
    assert find("given.toml") == Path("given.toml")


def test_find_says_where_it_looked(tmp_path, monkeypatch):
    monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "none"))
    monkeypatch.delenv(CONFIG_ENV, raising=False)
    monkeypatch.chdir(tmp_path)
    with pytest.raises(ConfigError, match="busybar-calendar init"):
        find()


class FakeBar:
    def __init__(self, refuse=None, mode="disabled"):
        self.refuse, self.mode = refuse, mode

    async def version(self):
        return types.VersionInfo(api_semver="27.5.0")

    async def status_firmware(self):
        if self.refuse:
            raise BusyBarAPIError("Forbidden", status_code=self.refuse)
        return types.StatusFirmware(version="1.2.4")

    async def access(self):
        return types.HttpAccessInfo(mode=self.mode, key_valid=False)

    async def aclose(self):
        pass


class FakeSource:
    name = "Feed"

    async def fetch(self, start, end):
        return []

    async def aclose(self):
        pass


async def check(monkeypatch, capsys, bar, data=None):
    config = Config.model_validate(data or SOURCES)
    monkeypatch.setattr(connect, "make_bar", lambda _config: bar)
    monkeypatch.setattr(connect, "make_sources", lambda _config: [FakeSource()])
    code = await diagnose.check(config)
    return code, capsys.readouterr().out


async def test_check_all_good(monkeypatch, capsys):
    code, out = await check(monkeypatch, capsys, FakeBar())
    assert code == 0
    assert "firmware 1.2.4, API 27.5.0, token accepted" in out
    assert "Feed: no events in the next 7 days" in out
    assert "All good." in out


async def test_check_explains_a_refused_lan_call(monkeypatch, capsys):
    lan = {**SOURCES, "bar": {"address": "192.168.1.50", "token": "x" * 43}}
    code, out = await check(monkeypatch, capsys, FakeBar(refuse=403), lan)
    assert code == 1
    assert "HTTP API over Wi-Fi is disabled" in out

    code, out = await check(monkeypatch, capsys, FakeBar(refuse=403, mode="key"), lan)
    assert "43 characters" in out and "cloud token" in out


def test_a_missing_config_says_how_to_make_one(tmp_path):
    from busybar_calendar.config import load

    with pytest.raises(ConfigError, match="busybar-calendar init"):
        load(tmp_path / "nope.toml")


async def test_status_without_a_config(monkeypatch, capsys):
    from busybar_calendar import service

    monkeypatch.setattr(service, "status", lambda: service.Status(False, False))
    monkeypatch.setattr(service, "recent_log", lambda state: [])
    assert await diagnose.status(None) == 0
    out = capsys.readouterr().out
    assert "not installed" in out and "busybar-calendar install" in out
    assert "busybar-calendar init" in out


async def test_status_shows_now_and_next(monkeypatch, capsys, tmp_path):
    from datetime import UTC, datetime, timedelta

    from busybar_calendar import service
    from busybar_calendar.events import CalendarRef, Event

    log = tmp_path / "busybar-calendar.log"
    log.write_text("16:40:19 reminder: Design review\n")
    monkeypatch.setattr(service, "status", lambda: service.Status(True, True, pid=4321, log=log))
    now = datetime.now(UTC)
    ref = CalendarRef("Feed", "Feed", 0)
    running = Event(
        "a", "Design review", now - timedelta(minutes=5), now + timedelta(minutes=25), ref
    )
    later = Event("b", "Retro", now + timedelta(hours=2), now + timedelta(hours=3), ref)

    class Source:
        name = "Feed"

        async def fetch(self, start, end):
            return [running, later]

        async def aclose(self):
            pass

    monkeypatch.setattr(connect, "make_sources", lambda config: [Source()])
    monkeypatch.setattr(connect, "make_bar", lambda config: FakeBar())
    assert await diagnose.status(Config.model_validate(SOURCES)) == 0
    out = capsys.readouterr().out
    assert "running, pid 4321" in out
    assert "reminder: Design review" in out  # the log tail
    assert "now status: Design review" in out
    assert "next reminder: Retro" in out


def test_version(capsys):
    from busybar_calendar import __version__

    with pytest.raises(SystemExit) as done:
        cli.main(["--version"])
    assert done.value.code == 0
    assert capsys.readouterr().out.strip() == f"busybar-calendar {__version__}"
    assert __version__[0].isdigit()  # read from the installed package


async def test_check_as_json(monkeypatch, capsys):
    import argparse

    config = Config.model_validate(SOURCES)
    monkeypatch.setattr(connect, "make_bar", lambda _config: FakeBar())
    monkeypatch.setattr(connect, "make_sources", lambda _config: [FakeSource()])
    code = await cli.cmd_check(config, argparse.Namespace(json=True))  # through the CLI
    result = json.loads(capsys.readouterr().out)  # nothing else on stdout
    assert code == 0
    assert result["ok"] is True
    bar, calendar = result["findings"]
    assert (bar["area"], bar["firmware"], bar["api"]) == ("bar", "1.2.4", "27.5.0")
    assert (calendar["area"], calendar["source"], calendar["events"]) == ("calendar", "Feed", 0)


async def test_status_as_json(monkeypatch, capsys):
    from datetime import UTC, datetime, timedelta

    from busybar_calendar import service
    from busybar_calendar.events import CalendarRef, Event

    monkeypatch.setattr(
        service, "status", lambda: service.Status(True, False, detail="last exit code 1")
    )
    monkeypatch.setattr(service, "recent_log", lambda state: ["Feed: 401, check the credentials"])
    monkeypatch.setattr(service, "installed_python", lambda: "/opt/venv/bin/python")
    now = datetime.now(UTC)
    later = Event(
        "b",
        "Retro",
        now + timedelta(hours=2),
        now + timedelta(hours=3),
        CalendarRef("Feed", "Feed", 0),
        "tentative",
    )

    class Source:
        name = "Feed"

        async def fetch(self, start, end):
            return [later]

        async def aclose(self):
            pass

    monkeypatch.setattr(connect, "make_sources", lambda config: [Source()])
    monkeypatch.setattr(connect, "make_bar", lambda config: FakeBar())
    await diagnose.status(Config.model_validate(SOURCES), as_json=True)
    data = json.loads(capsys.readouterr().out)
    assert data["service"] == {
        "installed": True,
        "running": False,
        "pid": None,
        "detail": "last exit code 1",
        "log": None,
        "recent_log": ["Feed: 401, check the credentials"],
        "version": None,
        "python": "/opt/venv/bin/python",
        "auto_update": False,
    }
    assert data["calendar"]["now"] is None
    nxt = data["calendar"]["next"]
    assert (nxt["kind"], nxt["title"], nxt["response"]) == ("reminder", "Retro", "tentative")
    assert datetime.fromisoformat(nxt["at"]) < datetime.fromisoformat(nxt["start"])


async def test_check_tells_a_refused_token_from_an_unreachable_bar(monkeypatch, capsys):
    class RefusingCloud(FakeBar):
        async def version(self):  # the cloud wants a valid token for every call
            raise BusyBarAPIError("Forbidden", status_code=403)

    code, out = await check(monkeypatch, capsys, RefusingCloud())
    assert code == 1
    assert "refused (403)" in out and "cloud token is wrong" in out
    assert "unreachable" not in out

    class Offline(FakeBar):
        async def version(self):
            raise ConnectionError("no route to host")

    code, out = await check(monkeypatch, capsys, Offline())
    assert "unreachable (no route to host)" in out


async def test_check_names_the_calendars_a_typo_could_have_meant(monkeypatch, capsys):
    from zoneinfo import ZoneInfo

    from busybar_calendar import sources
    from busybar_calendar.caldav import RemoteCalendar

    data = {
        "sources": [{"name": "Work", "type": "caldav", "url": "https://x", "include": ["Wrok"]}]
    }
    config = Config.model_validate(data)
    item = sources.CalDAVSource(config.sources[0], 0, ZoneInfo("UTC"), True)
    found = [RemoteCalendar("https://x/1/", "Team"), RemoteCalendar("https://x/2/", "Work")]

    async def calendars():
        return found

    async def fetch(start, end):
        raise AssertionError("no calendar is used, so nothing should be fetched")

    item.calendars, item.fetch = calendars, fetch
    monkeypatch.setattr(connect, "make_bar", lambda _config: FakeBar())
    monkeypatch.setattr(connect, "make_sources", lambda _config: [item])
    assert await diagnose.check(config) == 1
    out = capsys.readouterr().out
    assert "no calendar matches 'Wrok'; there are: Team, Work" in out
    assert "no events" not in out  # no false all-clear for a source that reads nothing
    await item.aclose()


def test_restart(monkeypatch, capsys):
    from busybar_calendar import service

    monkeypatch.setattr(service, "restart", lambda: True)
    assert cli.main(["restart"]) == 0
    assert "Restarted" in capsys.readouterr().out
    monkeypatch.setattr(service, "restart", lambda: False)
    assert cli.main(["restart"]) == 1
    assert "Not installed" in capsys.readouterr().out


def test_bare_command_suggests_first_steps(tmp_path, monkeypatch, capsys):
    monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "none"))
    monkeypatch.delenv("BUSYBAR_CALENDAR_CONFIG", raising=False)
    monkeypatch.chdir(tmp_path)
    assert cli.main([]) == 0
    out = capsys.readouterr().out
    assert "No config yet" in out and "busybar-calendar init" in out


def test_bare_command_shows_status_once_configured(tmp_path, monkeypatch, capsys):
    config = tmp_path / "config.toml"
    config.write_text('[[sources]]\nname = "Feed"\ntype = "ics"\nurl = "https://x"\n')
    seen = []

    async def fake_status(loaded, as_json=False, **where):
        seen.append(loaded.sources[0].name)
        return 0

    monkeypatch.setattr(diagnose, "status", fake_status)
    assert cli.main(["-c", str(config)]) == 0
    assert seen == ["Feed"]
    assert "--help lists every command" in capsys.readouterr().out


def test_a_failing_launchctl_is_one_line_not_a_traceback(tmp_path, monkeypatch, capsys):
    import subprocess

    from busybar_calendar import service

    config = tmp_path / "config.toml"
    config.write_text('[[sources]]\nname = "Feed"\ntype = "ics"\nurl = "https://x"\n')
    monkeypatch.setattr(service, "_home", lambda: tmp_path)

    def refuse(plan):
        raise subprocess.CalledProcessError(5, ["launchctl", "bootstrap", "gui/501", "x.plist"])

    monkeypatch.setattr(service, "install", refuse)
    assert cli.main(["-c", str(config), "install", "--force"]) == 1
    err = capsys.readouterr().err
    assert "launchctl bootstrap gui/501 x.plist failed (exit 5)" in err


def _config_file(tmp_path, text):
    path = tmp_path / "config.toml"
    path.write_text(text)
    return str(path)


def test_config_mistakes_are_one_line_each(tmp_path, capsys):
    path = _config_file(
        tmp_path,
        '[bar]\nadress = "x"\n[sync]\ntimezone = "Europe/Kiyv"\n'
        '[[sources]]\nname = "Feed"\ntype = "ics"\nurl = "https://x"\n',
    )
    assert cli.main(["-c", path, "upcoming"]) == 1
    err = capsys.readouterr().err.strip().splitlines()
    assert "busybar-calendar: bar.adress: Extra inputs are not permitted" in err
    assert any(line.startswith("busybar-calendar: sync.timezone: 'Europe/Kiyv'") for line in err)
    assert not any("pydantic.dev" in line for line in err)


def test_an_unreachable_server_is_one_line(tmp_path, monkeypatch, capsys):
    import httpx

    path = _config_file(
        tmp_path,
        '[[sources]]\nname = "Gone"\ntype = "caldav"\nurl = "https://dav.invalid/"\n',
    )

    async def unreachable(self):
        request = httpx.Request("PROPFIND", "https://dav.invalid/")
        raise httpx.ConnectError("name or service not known", request=request)

    from busybar_calendar import sources

    monkeypatch.setattr(sources.CalDAVSource, "calendars", unreachable)
    assert cli.main(["-c", path, "calendars"]) == 1
    assert capsys.readouterr().err.strip() == (
        "busybar-calendar: cannot reach https://dav.invalid/: name or service not known"
    )


async def test_status_says_when_the_service_runs_another_version(monkeypatch, capsys, tmp_path):
    from busybar_calendar import __version__, service

    log = tmp_path / "busybar-calendar.log"
    log.write_text(
        "2026-10-01 INFO busybar_calendar: busybar-calendar 0.0.9 started: 1 calendar source(s)\n"
        "2026-10-12 INFO busybar_calendar: busybar-calendar 0.1.0 started: 1 calendar source(s)\n"
        "2026-10-12 INFO busybar_calendar.runner: cleared\n"
    )
    monkeypatch.setattr(service, "status", lambda: service.Status(True, True, pid=7, log=log))
    monkeypatch.setattr(service, "installed_python", lambda: sys.executable)  # this install
    assert service.running_version(service.Status(True, True, log=log)) == "0.1.0"  # the last
    await diagnose.status(None)
    out = capsys.readouterr().out
    assert "Service   running 0.1.0, pid 7" in out
    assert f"that is 0.1.0, but this is {__version__}" in out and "restart` runs this one" in out


async def test_status_names_the_install_the_service_runs_from(monkeypatch, capsys, tmp_path):
    from busybar_calendar import __version__, service

    log = tmp_path / "busybar-calendar.log"
    log.write_text("x busybar-calendar 0.1.0 started: 1 calendar source(s)\n")
    monkeypatch.setattr(service, "status", lambda: service.Status(True, True, pid=7, log=log))
    # A checkout run with `uv run`, while the service runs the `uv tool` install:
    # a restart would start 0.1.0 again, so it must not be offered as the fix.
    monkeypatch.setattr(service, "installed_python", lambda: "/opt/tools/old/bin/python")
    await diagnose.status(None)
    out = capsys.readouterr().out
    assert f"that is 0.1.0, from /opt/tools/old, not this {__version__}" in out
    assert "upgrade that one, then `busybar-calendar restart`" in out
    assert "runs this one" not in out


def test_upgrade_command_only_for_a_uv_tool_from_git(tmp_path):
    tool = tmp_path / "tools" / "busybar-calendar"
    (tool / "bin").mkdir(parents=True)
    python = str(tool / "bin" / "python")
    assert diagnose.upgrade_command(python) is None  # not a uv tool
    receipt = tool / "uv-receipt.toml"
    receipt.write_text(
        '[tool]\nrequirements = [{ name = "busybar-calendar", '
        'git = "https://github.com/kurok/busybar-calendar" }]\n'
    )
    assert diagnose.upgrade_command(python) == "uv tool upgrade busybar-calendar"
    # From a wheel file, `uv tool upgrade` would reinstall the same version.
    receipt.write_text(
        '[tool]\nrequirements = [{ name = "busybar-calendar", path = "/d/x-0.3.0.whl" }]\n'
    )
    assert diagnose.upgrade_command(python) is None
    receipt.write_text("not toml [")
    assert diagnose.upgrade_command(python) is None


async def test_status_quiet_when_versions_match(monkeypatch, capsys, tmp_path):
    from busybar_calendar import __version__, service

    log = tmp_path / "busybar-calendar.log"
    log.write_text(f"x busybar-calendar {__version__} started: 1 calendar source(s)\n")
    monkeypatch.setattr(service, "status", lambda: service.Status(True, True, pid=7, log=log))
    await diagnose.status(None)
    out = capsys.readouterr().out
    assert f"running {__version__}, pid 7" in out and "but this is" not in out


async def test_status_says_when_the_bar_does_not_answer(monkeypatch, capsys):
    import asyncio

    from busybar_calendar import service

    monkeypatch.setattr(service, "status", lambda: service.Status(True, True, pid=7))
    monkeypatch.setattr(service, "recent_log", lambda state: [])
    monkeypatch.setattr(connect, "make_sources", lambda config: [FakeSource()])

    class Hung(FakeBar):
        async def version(self):
            await asyncio.sleep(60)  # a bar whose HTTP API hangs

    monkeypatch.setattr(connect, "make_bar", lambda config: Hung())
    monkeypatch.setattr(diagnose, "BAR_TIMEOUT", 0.05)
    health = await diagnose.bar_health(Config.model_validate(SOURCES), timeout=0.05)
    assert health == {"where": "BUSY Cloud", "answering": False, "detail": "no answer"}

    data = {
        "version": "x",
        "service": {
            "installed": True,
            "running": True,
            "pid": 7,
            "detail": None,
            "log": None,
            "recent_log": [],
            "version": None,
        },
        "config": {"path": None, "problems": []},
        "bar": health,
        "calendar": None,
    }
    diagnose.print_status(data)
    out = capsys.readouterr().out
    assert "Bar       BUSY Cloud: NOT answering (no answer)" in out
    assert "unplug it and plug it back in" in out


async def test_bar_health_cases(monkeypatch):
    config = Config.model_validate(SOURCES)
    monkeypatch.setattr(connect, "make_bar", lambda config: FakeBar())
    assert (await diagnose.bar_health(config))["answering"] is True

    class Refusing(FakeBar):
        async def version(self):
            raise BusyBarAPIError("Forbidden", status_code=403)

    monkeypatch.setattr(connect, "make_bar", lambda config: Refusing())
    health = await diagnose.bar_health(config)
    assert health["answering"] is True and "refused (403)" in health["detail"]

    def no_bar(config):
        raise ConfigError("give bar.address, or a bar-scope cloud token as bar.token")

    monkeypatch.setattr(connect, "make_bar", no_bar)
    assert (await diagnose.bar_health(config))["answering"] is False


WATCHED = """\
[bar]
address = "10.0.4.20"

[[sources]]
name = "Work"
type = "ics"
url = "https://example.com/work.ics"
"""


async def test_the_service_follows_config_edits(tmp_path, caplog):
    from busybar_calendar.runner import Runner

    now = datetime.now(UTC)
    path = tmp_path / "config.toml"
    path.write_text(WATCHED)
    config = config_module.load(path)
    runner = Runner(config, bar=None, sources=connect.make_sources(config))
    watch = cli.ConfigWatch(path, runner)
    try:
        await watch()  # unchanged: nothing to do
        assert runner.config is config

        path.write_text(
            WATCHED + '[[sources]]\nname = "Home"\ntype = "ics"\nurl = "https://example.com/h.ics"'
            '\n\n[[titles]]\nmatch = "Lunch*"\nstatus.show = "lunch"\n'
        )
        await watch()
        assert [s.name for s in runner.sources] == ["Work", "Home"]
        assert (
            runner.settings_for(Event("u", "Lunch", now, now, CalendarRef("Work", "Work", 0)))[
                1
            ].show
            == "lunch"
        )

        applied = runner.config
        path.write_text(WATCHED + '[[titles]]\nmatch = "Lunch"\nstatus.show = "partying"\n')
        await watch()
        assert runner.config is applied  # a broken edit is not applied...
        assert "keeps the config it has" in caplog.text  # ...and the log says why
        assert "titles.0.status: show:" in caplog.text

        path.write_text(WATCHED.replace("10.0.4.20", "192.168.1.50"))
        await watch()
        assert runner.config.bar.address == "10.0.4.20"  # [bar] waits for a restart
        assert "restart" in caplog.text
        assert [s.name for s in runner.sources] == ["Work"]  # the rest is applied

        path.unlink()
        await watch()
        assert [s.name for s in runner.sources] == ["Work"]
    finally:
        await connect.close_all(runner.sources)


def test_status_reports_a_config_that_does_not_load(tmp_path, monkeypatch, capsys):
    from busybar_calendar import service

    monkeypatch.setattr(service, "status", lambda: service.Status(True, True, pid=7))
    monkeypatch.setattr(service, "recent_log", lambda state: [])
    monkeypatch.setattr(service, "running_version", lambda state: None)
    monkeypatch.setattr(service, "installed_python", lambda: None)
    path = tmp_path / "config.toml"
    path.write_text(WATCHED + '[[titles]]\nmatch = "Lunch"\nstatus.show = "partying"\n')

    assert cli.main(["-c", str(path), "status"]) == 1
    out = capsys.readouterr().out
    assert "Service   running, pid 7" in out  # the service part still shows
    assert f"Config    does not load: {path}" in out
    assert "the running service keeps the config it last loaded" in out
    assert "titles.0.status: show: show must be" in out
    assert "no config yet" not in out

    assert cli.main(["-c", str(path), "status", "--json"]) == 1
    data = json.loads(capsys.readouterr().out)
    assert data["config"]["path"] == str(path)
    assert data["config"]["problems"][0].startswith("titles.0.status: show:")
    assert data["service"]["running"] is True

    assert cli.main(["-c", str(path)]) == 1  # the bare command too, not first steps
    assert "does not load" in capsys.readouterr().out


def test_status_with_no_config_file_still_says_how_to_make_one(tmp_path, monkeypatch, capsys):
    from busybar_calendar import service

    monkeypatch.setattr(service, "status", lambda: service.Status(False, False))
    monkeypatch.setattr(service, "recent_log", lambda state: [])
    assert cli.main(["-c", str(tmp_path / "none.toml"), "status"]) == 0
    assert "no config yet" in capsys.readouterr().out
    assert cli.main(["-c", str(tmp_path / "none.toml"), "status", "--json"]) == 0
    assert json.loads(capsys.readouterr().out)["config"] == {"path": None, "problems": []}


def test_upcoming_notes_say_what_the_bar_does_and_why():
    config = Config.model_validate(
        {
            **SOURCES,
            "defaults": {"status": {"tentative": False}},
            "titles": [
                {"match": "Focus*", "reminder": {"enabled": False}, "status": {"enabled": False}},
                {"match": ["Lunch", "Обід*"], "status": {"show": "lunch"}},
                {"match": "*", "reminder": {"minutes_before": 2}},
            ],
        }
    )
    now = datetime.now(UTC)
    ref = CalendarRef("Feed", "Feed", 0)

    def notes(title, response="accepted"):
        return cli.upcoming_notes(config, Event("u", title, now, now, ref, response))

    assert notes("Sync") == "reminder 2m, status name, titles '*'"
    # Tentative, with status.tentative off: the reminder only, and the note says why.
    assert notes("Sync", "tentative") == "reminder 2m, tentative, titles '*'"
    assert notes("обід з командою") == "reminder 2m, status lunch, titles 'Обід*' '*'"
    config.titles.pop()  # without the catch-all, Focus time keeps nothing
    assert notes("Focus time") == "hidden, titles 'Focus*'"


def test_run_auto_update_updates_before_reading_the_config(tmp_path, monkeypatch):
    from busybar_calendar import update

    order = []

    class Restarted(Exception):
        pass

    def restart():
        order.append("restart")
        raise Restarted  # os.execv does not return

    monkeypatch.setattr(update, "restart_into_update", restart)
    monkeypatch.setattr(update, "update", lambda: order.append("update") or True)
    monkeypatch.setenv(update.UPDATED_ENV, "")
    command = ["-c", str(tmp_path / "missing.toml"), "run", "--auto-update"]
    with pytest.raises(Restarted):  # even a config this version cannot read waits
        cli.main(command)
    assert order == ["update", "restart"]

    order.clear()
    monkeypatch.setenv(update.UPDATED_ENV, "1")  # the process the update restarted into
    assert cli.main(command) == 1  # straight on to the config, which is missing
    assert order == []

    monkeypatch.setenv(update.UPDATED_ENV, "")
    monkeypatch.setattr(update, "update", lambda: order.append("update") or False)
    assert cli.main(command) == 1  # nothing new: on with the version it has
    assert order == ["update"]
    assert cli.main(command[:-1]) == 1  # plain `run` does not update
    assert order == ["update"]


def test_status_with_a_self_updating_service(monkeypatch, capsys):
    from busybar_calendar import service

    monkeypatch.setattr(service, "status", lambda: service.Status(True, True, pid=7))
    monkeypatch.setattr(service, "recent_log", lambda state: [])
    monkeypatch.setattr(service, "running_version", lambda state: "0.3.1")
    monkeypatch.setattr(service, "installed_python", lambda: "/opt/tools/old/bin/python")
    monkeypatch.setattr(service, "auto_updates", lambda: True)
    data = asyncio.run(diagnose.status_data(None))
    assert data["service"]["auto_update"] is True
    diagnose.print_status(data)
    out = capsys.readouterr().out
    assert "running 0.3.1, pid 7, updates itself at each start" in out
    assert "`busybar-calendar restart` updates it" in out
    assert "uv tool upgrade" not in out
tests/test_config.py
import pytest
from pydantic import ValidationError

from busybar_calendar.config import Config, ConfigError, load


def base(**source):
    return {"sources": [{"name": "Work", "type": "caldav", "url": "https://dav.example", **source}]}


def test_settings_layer_defaults_source_calendar():
    config = Config.model_validate(
        {
            "defaults": {"reminder": {"minutes_before": 3, "sound": False}},
            "sources": [
                {
                    "name": "Work",
                    "type": "caldav",
                    "url": "https://dav.example",
                    "reminder": {"minutes_before": 10},
                    "calendars": {
                        "Team": {"status": {"show": "meeting"}, "reminder": {"sound": True}}
                    },
                }
            ],
        }
    )
    reminder, status = config.settings_for("Work", "Personal")
    assert (reminder.minutes_before, reminder.sound, status.show) == (10, False, "name")
    reminder, status = config.settings_for("Work", "Team")
    assert (reminder.minutes_before, reminder.sound, status.show) == (10, True, "meeting")


def test_rejects_unknown_status_and_typos():
    with pytest.raises(ValidationError, match="show must be"):
        Config.model_validate(base(status={"show": "partying"})).settings_for("Work", "x")
    with pytest.raises(ValidationError):
        Config.model_validate(base(urll="x"))


def test_load_validates_every_calendar(tmp_path):
    path = tmp_path / "c.toml"
    path.write_text(
        '[[sources]]\nname = "W"\ntype = "caldav"\nurl = "https://x"\n'
        "[sources.calendars.Team.reminder]\nminutes_before = 0\n"
    )
    with pytest.raises(ValidationError, match="minutes_before"):
        load(path)


def test_webcal_and_emails():
    config = Config.model_validate(
        {
            "sources": [
                {"name": "Feed", "type": "ics", "url": "webcal://cal.example/a.ics"},
                {
                    "name": "Mail",
                    "type": "caldav",
                    "url": "https://x",
                    "username": "me@example.com",
                },
            ]
        }
    )
    assert config.sources[0].url == "https://cal.example/a.ics"
    assert config.sources[1].emails == ["me@example.com"]


def test_ics_has_no_calendars():
    with pytest.raises(ValidationError, match="caldav only"):
        Config.model_validate(
            {"sources": [{"name": "Feed", "type": "ics", "url": "https://x", "include": ["a"]}]}
        )


def test_unique_source_names():
    with pytest.raises(ValidationError, match="unique"):
        Config.model_validate(
            {
                "sources": [
                    {"name": "A", "type": "ics", "url": "https://x"},
                    {"name": "A", "type": "ics", "url": "https://y"},
                ]
            }
        )


def test_secrets(monkeypatch):
    monkeypatch.setenv("APP_PW", "from-env")
    config = Config.model_validate(base(username="u", password_env="APP_PW"))
    assert config.sources[0].password.resolve() == "from-env"
    config = Config.model_validate(base(password_command=["echo", "from-cmd"]))
    assert config.sources[0].password.resolve() == "from-cmd"
    config = Config.model_validate({**base(), "bar": {"token": "inline"}})
    assert config.bar.token.resolve() == "inline"
    assert Config.model_validate(base()).bar.token.resolve() is None

    monkeypatch.delenv("APP_PW")
    config = Config.model_validate(base(password_env="APP_PW"))
    with pytest.raises(ConfigError, match="APP_PW"):
        config.sources[0].password.resolve()
    with pytest.raises(ValidationError, match="only one of"):
        Config.model_validate(base(password="a", password_env="APP_PW"))


def test_display_options_are_validated():
    from busybar_calendar.config import DisplayConfig

    assert DisplayConfig(busy_color="#ff0000").busy_color == "#FF0000FF"
    for bad in ({"busy_color": "red"}, {"busy_label": "ЗАЙНЯТО"}, {"event_icon": "blue"}):
        with pytest.raises(ValidationError):
            DisplayConfig(**bad)


def test_tentative_status_can_be_turned_off():
    config = Config.model_validate(base(status={"tentative": False}))
    _, status = config.settings_for("Work", "Work")
    assert status.tentative is False


def test_yellow_for_tentative_events_is_on_unless_turned_off(tmp_path):
    path = tmp_path / "c.toml"
    path.write_text(
        '[[sources]]\nname = "Work"\ntype = "ics"\nurl = "https://x"\n'
        "[display]\ntentative_yellow = false\n"
    )
    assert load(path).display.tentative_yellow is False
    assert Config.model_validate(base()).display.tentative_yellow is True


def test_a_config_file_needs_a_source(tmp_path):
    path = tmp_path / "c.toml"
    path.write_text('[bar]\naddress = "10.0.4.20"\n')
    with pytest.raises(ConfigError, match="no"):
        load(path)


def test_events_of_no_source_get_the_defaults():
    config = Config.model_validate({"defaults": {"reminder": {"minutes_before": 2}}})
    reminder, status = config.settings_for("demo", "demo")
    assert (reminder.minutes_before, status.show) == (2, "name")


def test_timezone_is_checked_when_loading():
    with pytest.raises(ValidationError, match="not a timezone name"):
        Config.model_validate({**base(), "sync": {"timezone": "Europe/Kiyv"}})
    assert Config.model_validate({**base(), "sync": {"timezone": "Europe/Kyiv"}})


def test_title_rules_layer_over_the_calendar_in_order():
    config = Config.model_validate(
        {
            **base(calendars={"Team": {"status": {"show": "meeting"}}}),
            "titles": [
                {"match": "lunch*", "status": {"show": "lunch"}, "reminder": {"sound": False}},
                {"match": ["Focus time", "Commute ?"], "reminder": {"enabled": False}},
                {"match": "*", "reminder": {"minutes_before": 2}},
            ],
        }
    )
    reminder, status = config.settings_for("Work", "Team", title="Lunch with Alex")
    assert (status.show, reminder.sound, reminder.minutes_before) == ("lunch", False, 2)
    reminder, status = config.settings_for("Work", "Team", title="focus TIME")
    assert (reminder.enabled, status.show) == (False, "meeting")
    reminder, _ = config.settings_for("Work", "Team", title="Commute home")
    assert reminder.enabled is True  # `?` is one character
    _, status = config.settings_for("Work", "Team", title="Sync")
    assert status.show == "meeting"


def test_title_rules_need_a_match():
    with pytest.raises(ValidationError, match="match"):
        Config.model_validate({**base(), "titles": [{"reminder": {"enabled": False}}]})
    with pytest.raises(ValidationError, match="match"):
        Config.model_validate({**base(), "titles": [{"match": []}]})


def test_a_bad_setting_says_where_it_is(tmp_path):
    path = tmp_path / "c.toml"
    path.write_text(
        '[[sources]]\nname = "Work"\ntype = "ics"\nurl = "https://x"\n'
        '[[titles]]\nmatch = "Lunch"\nstatus.show = "partying"\n'
    )
    with pytest.raises(ValidationError) as caught:
        load(path)
    (problem,) = caught.value.errors()
    assert problem["loc"] == ("titles", 0, "status")
    assert "show: show must be" in problem["msg"]
    with pytest.raises(ValidationError) as caught:
        Config.model_validate(base(calendars={"Team": {"reminder": {"minutes_before": 0}}}))
    assert caught.value.errors()[0]["loc"] == ("sources", 0, "calendars", "Team", "reminder")
tests/test_docs.py
"""The docs that are generated from the code, or have to stay valid against it."""

from pathlib import Path

import pytest

from busybar_calendar import config, reference

ROOT = Path(__file__).resolve().parent.parent
MODELS = [
    config.BarConfig,
    config.SyncConfig,
    config.DisplayConfig,
    config.ReminderSettings,
    config.StatusSettings,
    config.SourceConfig,
    config.TitleRule,
]


def test_configuration_reference_is_current():
    committed = (ROOT / "docs" / "configuration.md").read_text()
    assert committed == reference.markdown(), (
        "docs/configuration.md is out of date: "
        "python -m busybar_calendar.reference > docs/configuration.md"
    )


@pytest.mark.parametrize("model", MODELS, ids=lambda m: m.__name__)
def test_every_option_is_described(model):
    overrides = model in (config.SourceConfig, config.TitleRule)
    inherited = {"reminder", "status"} if overrides else set()
    missing = [
        name
        for name, field in model.model_fields.items()
        if not field.description and name not in inherited
    ]
    assert not missing, f"{model.__name__}: describe {missing} - it goes into the reference"


def test_example_config_loads():
    loaded = config.load(ROOT / "config.example.toml")
    assert loaded.sources


DOCS = [ROOT / "README.md", ROOT / "CHANGELOG.md", *sorted((ROOT / "docs").glob("*.md"))]


@pytest.mark.parametrize("doc", DOCS, ids=lambda path: path.name)
def test_relative_links_resolve(doc):
    import re

    targets = re.findall(r"\]\(([^)#\s]+)(?:#[^)]*)?\)", doc.read_text())
    broken = [
        target
        for target in targets
        if not target.startswith(("http://", "https://", "mailto:"))
        and not (doc.parent / target).exists()
    ]
    assert not broken, f"{doc.name} links to missing files: {broken}"
tests/test_events.py
from datetime import UTC, datetime
from zoneinfo import ZoneInfo

from busybar_calendar.events import CalendarRef, parse

REF = CalendarRef("src", "cal", 0)
KYIV = ZoneInfo("Europe/Kyiv")
WINDOW = (datetime(2026, 10, 12, tzinfo=UTC), datetime(2026, 10, 20, tzinfo=UTC))


def ics(*events: str) -> str:
    body = "\n".join(f"BEGIN:VEVENT\n{e.strip()}\nEND:VEVENT" for e in events)
    return f"BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:test\n{body}\nEND:VCALENDAR\n".replace(
        "\n", "\r\n"
    )


def run(data, **kwargs):
    kwargs.setdefault("floating_tz", KYIV)
    return parse(data, REF, *WINDOW, **kwargs)


def test_timed_event_in_utc():
    [event] = run(ics("UID:a\nSUMMARY:Standup\nDTSTART:20261013T090000Z\nDTEND:20261013T091500Z"))
    assert event.title == "Standup"
    assert event.start == datetime(2026, 10, 13, 9, 0, tzinfo=UTC)
    assert event.end == datetime(2026, 10, 13, 9, 15, tzinfo=UTC)


def test_tzid_and_floating_times():
    data = ics(
        "UID:a\nSUMMARY:Zoned\nDTSTART;TZID=America/New_York:20261013T090000\n"
        "DTEND;TZID=America/New_York:20261013T100000",
        "UID:b\nSUMMARY:Floating\nDTSTART:20261013T090000\nDTEND:20261013T100000",
    )
    zoned, floating = sorted(run(data), key=lambda e: e.title, reverse=True)
    assert zoned.start == datetime(2026, 10, 13, 13, 0, tzinfo=UTC)
    # Kyiv is UTC+3 in October.
    assert floating.start == datetime(2026, 10, 13, 6, 0, tzinfo=UTC)


def test_duration_and_no_end():
    data = ics(
        "UID:a\nSUMMARY:Dur\nDTSTART:20261013T090000Z\nDURATION:PT45M",
        "UID:b\nSUMMARY:Point\nDTSTART:20261013T120000Z",
    )
    by_title = {e.title: e for e in run(data)}
    assert (by_title["Dur"].end - by_title["Dur"].start).total_seconds() == 45 * 60
    assert by_title["Point"].end == by_title["Point"].start


def test_skips_like_google_and_more():
    data = ics(
        "UID:allday\nSUMMARY:Holiday\nDTSTART;VALUE=DATE:20261014\nDTEND;VALUE=DATE:20261015",
        "UID:long\nSUMMARY:Offsite\nDTSTART:20261014T080000Z\nDTEND:20261015T080000Z",
        "UID:cancel\nSUMMARY:Gone\nSTATUS:CANCELLED\nDTSTART:20261014T090000Z\n"
        "DTEND:20261014T100000Z",
        "UID:free\nSUMMARY:Lunch\nTRANSP:TRANSPARENT\nDTSTART:20261014T110000Z\n"
        "DTEND:20261014T120000Z",
        "UID:declined\nSUMMARY:Nope\nDTSTART:20261014T130000Z\nDTEND:20261014T140000Z\n"
        "ATTENDEE;PARTSTAT=DECLINED:mailto:Me@Example.com\n"
        "ATTENDEE;PARTSTAT=ACCEPTED:mailto:other@example.com",
        "UID:keep\nSUMMARY:Kept\nDTSTART:20261014T150000Z\nDTEND:20261014T160000Z\n"
        "ATTENDEE;PARTSTAT=DECLINED:mailto:other@example.com",
    )
    titles = [e.title for e in run(data, emails=["me@example.com"])]
    assert titles == ["Kept"]
    # Free events are shown when asked for.
    titles = [e.title for e in run(data, emails=["me@example.com"], skip_free=False)]
    assert titles == ["Lunch", "Kept"]


def test_recurrence_with_exdate_and_override():
    data = ics(
        "UID:r\nSUMMARY:Daily\nDTSTART:20261012T080000Z\nDTEND:20261012T081500Z\n"
        "RRULE:FREQ=DAILY;COUNT=5\nEXDATE:20261014T080000Z",
        "UID:r\nRECURRENCE-ID:20261015T080000Z\nSUMMARY:Daily (moved)\n"
        "DTSTART:20261015T100000Z\nDTEND:20261015T101500Z",
    )
    events = run(data)
    assert [(e.title, e.start.day, e.start.hour) for e in events] == [
        ("Daily", 12, 8),
        ("Daily", 13, 8),
        ("Daily (moved)", 15, 10),
        ("Daily", 16, 8),
    ]
    assert len({e.key for e in events}) == 4


def test_several_documents_and_a_broken_one():
    good = ics("UID:a\nSUMMARY:One\nDTSTART:20261013T090000Z\nDTEND:20261013T100000Z")
    other = ics("UID:b\nSUMMARY:Two\nDTSTART:20261013T080000Z\nDTEND:20261013T083000Z")
    events = run([good, "not a calendar", other])
    assert [e.title for e in events] == ["Two", "One"]


def test_untitled_and_whitespace():
    data = ics(
        "UID:a\nDTSTART:20261013T090000Z\nDTEND:20261013T100000Z",
        "UID:b\nSUMMARY:  Sprint\\n  review \nDTSTART:20261013T110000Z\nDTEND:20261013T120000Z",
    )
    assert [e.title for e in run(data)] == ["Busy", "Sprint review"]


ME = ["me@example.com"]


def responses(*attendee_lines, organizer=None, emails=ME):
    extra = "\n".join(attendee_lines)
    if organizer:
        extra += f"\nORGANIZER:mailto:{organizer}"
    data = ics(f"UID:a\nSUMMARY:X\nDTSTART:20261013T090000Z\nDTEND:20261013T100000Z\n{extra}")
    events = run(data, emails=emails)
    return events[0].response if events else None


def test_response_follows_my_attendee_entry():
    other = "ATTENDEE;PARTSTAT=ACCEPTED:mailto:other@example.com"
    assert responses(other, "ATTENDEE;PARTSTAT=ACCEPTED:mailto:me@example.com") == "accepted"
    assert responses(other, "ATTENDEE;PARTSTAT=TENTATIVE:mailto:ME@example.com") == "tentative"
    assert responses(other, "ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:me@example.com") == (
        "needs-action"
    )
    # RFC 5545: no PARTSTAT means no answer yet.
    assert responses(other, "ATTENDEE:mailto:me@example.com") == "needs-action"
    assert responses(other, "ATTENDEE;PARTSTAT=DECLINED:mailto:me@example.com") is None
    assert responses(other, "ATTENDEE;PARTSTAT=DELEGATED:mailto:me@example.com") is None


def test_my_own_entry_wins_even_when_i_organise():
    mine = "ATTENDEE;PARTSTAT=TENTATIVE:mailto:me@example.com"
    assert responses(mine, organizer="me@example.com") == "tentative"


def test_response_without_my_entry():
    other = "ATTENDEE;PARTSTAT=ACCEPTED:mailto:other@example.com"
    assert responses() == "accepted"  # no attendees: an event of my own
    assert responses(other, organizer="me@example.com") == "accepted"
    assert responses(other, organizer="boss@example.com") == "needs-action"  # via a group
    assert responses(other, emails=[]) == "accepted"  # no address to tell by


# Shapes of the published feeds the docs promise to read.
OUTLOOK_FEED = """BEGIN:VCALENDAR
METHOD:PUBLISH
PRODID:Microsoft Exchange Server 2010
VERSION:2.0
BEGIN:VTIMEZONE
TZID:W. Europe Standard Time
BEGIN:STANDARD
DTSTART:16010101T030000
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=10
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:16010101T020000
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VEVENT
UID:040000008200E00074C5B7101A82E008
SUMMARY:Weekly planning
DTSTART;TZID=W. Europe Standard Time:20261013T100000
DTEND;TZID=W. Europe Standard Time:20261013T110000
RRULE:FREQ=WEEKLY;BYDAY=TU
END:VEVENT
END:VCALENDAR
"""

GOOGLE_FEED = """BEGIN:VCALENDAR
PRODID:-//Google Inc//Google Calendar 70.9054//EN
VERSION:2.0
X-WR-TIMEZONE:Europe/Kyiv
BEGIN:VEVENT
DTSTART:20261013T060000Z
DTEND:20261013T063000Z
UID:abc123@google.com
SUMMARY:Standup
STATUS:CONFIRMED
TRANSP:OPAQUE
END:VEVENT
END:VCALENDAR
"""


def test_outlook_windows_timezones_across_the_dst_change():
    data = OUTLOOK_FEED.replace("\n", "\r\n")
    window = (datetime(2026, 10, 12, tzinfo=UTC), datetime(2026, 11, 4, tzinfo=UTC))
    # The fallback zone is wrong on purpose: the feed's own VTIMEZONE must win.
    events = parse(data, REF, *window, floating_tz=ZoneInfo("America/New_York"))
    berlin = ZoneInfo("Europe/Berlin")
    assert [e.start.astimezone(berlin).strftime("%d %H:%M") for e in events] == [
        "13 10:00",
        "20 10:00",
        "27 10:00",
        "03 10:00",  # 10:00 local, before and after DST
    ]
    assert [e.start.hour for e in events] == [8, 8, 9, 9]  # in UTC


def test_google_secret_address_feed():
    [event] = run(GOOGLE_FEED.replace("\n", "\r\n"))
    assert (event.title, event.start) == ("Standup", datetime(2026, 10, 13, 6, 0, tzinfo=UTC))


def test_tentative_marking_counts_when_there_is_no_attendee_entry():
    outlook = "X-MICROSOFT-CDO-BUSYSTATUS:TENTATIVE"
    status = "STATUS:TENTATIVE"
    busy = "X-MICROSOFT-CDO-BUSYSTATUS:BUSY"
    assert responses(outlook) == "tentative"  # a published Outlook feed
    assert responses(status) == "tentative"
    assert responses(busy) == "accepted"
    assert responses(outlook, emails=[]) == "tentative"  # no address of mine to look for
    # My own attendee entry still decides when there is one.
    mine = "ATTENDEE;PARTSTAT=ACCEPTED:mailto:me@example.com"
    assert responses(mine, status) == "accepted"
tests/test_render.py
import dataclasses
from datetime import UTC, datetime, timedelta

import pytest

from busybar_calendar import icons
from busybar_calendar.config import DisplayConfig, ReminderSettings, StatusSettings
from busybar_calendar.events import CalendarRef, Event
from busybar_calendar.render import drawing, sound, title_text
from busybar_calendar.schedule import Screen

START = datetime(2026, 10, 13, 9, 0, tzinfo=UTC)
EVENT = Event(
    "u", "Планування спринту ☕", START, START + timedelta(minutes=30), CalendarRef("s", "c", 0)
)


def screen(kind="reminder", reminder=None, status=None, response="accepted"):
    event = dataclasses.replace(EVENT, response=response)
    return Screen(kind, event, reminder or ReminderSettings(), status or StatusSettings())


def by_id(d):
    return {e.id: e for e in d.elements}


def test_title_text():
    assert title_text("Café ☕️ sync 🚀", DisplayConfig()) == "Cafe sync"
    assert title_text("Планування", DisplayConfig()) == "Planuvannya"
    assert title_text("Планування", DisplayConfig(transliterate=False)) == "Busy"
    assert title_text("Планування\tспринту", DisplayConfig(title_font="global")) == (
        "Планування спринту"
    )


def test_reminder_two_lines():
    d = drawing(screen(), DisplayConfig(), "busybar-calendar", 91)
    assert (d.application_name, d.priority) == ("busybar-calendar", 91)
    elements = by_id(d)
    assert elements["cal_xpm"].data == icons.RED_CALENDAR
    assert "cal_busy" not in elements  # BUSY is for the event itself, not before it
    title = elements["cal_title"]
    assert title.text == "Planuvannya sprintu"
    assert (title.font, title.align, title.x, title.y) == ("small", "top_left", 18, 0)
    assert title.scroll_rate  # 19 characters do not fit beside the icon
    countdown = elements["cal_time"]
    assert countdown.timestamp == str(int(START.timestamp()))
    assert (countdown.direction, countdown.align, countdown.y) == ("time_left", "bottom_left", 19)
    # The bar takes the reminder down by itself, a grace minute after the start.
    assert {e.display_until for e in d.elements} == {str(int(START.timestamp()) + 60)}


def test_short_title_does_not_scroll_and_hidden_name():
    short = Screen(
        "reminder",
        Event("u", "1:1", START, START, CalendarRef("s", "c", 0)),
        ReminderSettings(),
        StatusSettings(),
    )
    assert by_id(drawing(short, DisplayConfig(), "a", 91))["cal_title"].scroll_rate is None
    hidden = drawing(screen(reminder=ReminderSettings(show_name=False)), DisplayConfig(), "a", 91)
    assert by_id(hidden)["cal_title"].text == "Next event"


def test_global_font_shares_one_line():
    elements = by_id(drawing(screen(), DisplayConfig(title_font="global"), "a", 91))
    assert elements["cal_title"].text == "Планування спринту"
    assert (elements["cal_title"].align, elements["cal_title"].y) == ("mid_left", 8)
    assert (elements["cal_time"].align, elements["cal_time"].x, elements["cal_time"].y) == (
        "mid_right",
        71,
        9,
    )


def test_status_name_counts_to_the_end():
    d = drawing(screen("status"), DisplayConfig(), "a", 91)
    elements = by_id(d)
    end = int(EVENT.end.timestamp())
    assert elements["cal_time"].timestamp == str(end)
    assert {e.display_until for e in d.elements} == {str(end + 60)}


def test_status_has_the_red_calendar_and_busy():
    elements = by_id(drawing(screen("status"), DisplayConfig(), "a", 91))
    assert list(elements) == ["cal_xpm", "cal_title", "cal_time", "cal_busy"]
    icon = elements["cal_xpm"]
    assert (icon.type, icon.data) == ("xpmbitmap", icons.RED_CALENDAR)
    busy = elements["cal_busy"]
    assert (busy.text, busy.color, busy.font) == ("BUSY", "#FF0000FF", "small")
    # Right-aligned on the countdown's line.
    assert (busy.align, busy.x, busy.y) == ("top_right", 72, 8)


def test_icons_and_label_follow_the_config():
    display = DisplayConfig(event_icon="animation", busy_label="", reminder_icon="animation")
    status = by_id(drawing(screen("status"), display, "a", 91))
    assert list(status) == ["cal_icon", "cal_title", "cal_time"]
    assert status["cal_icon"].stock_path == "shared/animations/calendar_event_16x16.anim"
    reminder = by_id(drawing(screen(), display, "a", 91))
    assert reminder["cal_icon"].stock_path == "shared/animations/calendar_reminder_16x16.anim"
    custom = by_id(
        drawing(screen("status"), DisplayConfig(busy_label="IN MTG", busy_color="#FF8800"), "a", 91)
    )
    assert (custom["cal_busy"].text, custom["cal_busy"].color) == ("IN MTG", "#FF8800FF")


def test_no_busy_label_with_the_global_font():
    elements = by_id(drawing(screen("status"), DisplayConfig(title_font="global"), "a", 91))
    assert "cal_busy" not in elements


def test_tentative_event_gets_the_yellow_calendar_and_busy():
    for kind in ("reminder", "status"):
        elements = by_id(drawing(screen(kind, response="tentative"), DisplayConfig(), "a", 91))
        assert "cal_xpm" not in elements
        icon = elements["cal_xpm_maybe"]
        assert (icon.type, icon.data) == ("xpmbitmap", icons.YELLOW_CALENDAR)
        assert icon.data != icons.RED_CALENDAR
        # The same place and size as the red one: only the colours differ.
        red = by_id(drawing(screen(kind), DisplayConfig(), "a", 91))["cal_xpm"]
        assert (icon.x, icon.y, icon.align) == (red.x, red.y, red.align)
        assert list(elements) == (
            ["cal_xpm_maybe", "cal_title", "cal_time", "cal_busy"]
            if kind == "status"
            else ["cal_xpm_maybe", "cal_title", "cal_time"]
        )
    busy = by_id(drawing(screen("status", response="tentative"), DisplayConfig(), "a", 91))
    assert (busy["cal_busy"].text, busy["cal_busy"].color) == ("BUSY", "#FFC800FF")


def test_yellow_is_for_tentative_only():
    # Answered "yes", or not at all: the red calendar and the configured BUSY colour.
    for response in ("accepted", "needs-action"):
        elements = by_id(drawing(screen("status", response=response), DisplayConfig(), "a", 91))
        assert elements["cal_xpm"].data == icons.RED_CALENDAR
        assert elements["cal_busy"].color == "#FF0000FF"
        assert "cal_xpm_maybe" not in elements


def test_yellow_can_be_turned_off():
    display = DisplayConfig(tentative_yellow=False)
    elements = by_id(drawing(screen("status", response="tentative"), display, "a", 91))
    assert elements["cal_xpm"].data == icons.RED_CALENDAR
    assert elements["cal_busy"].color == "#FF0000FF"
    assert "cal_xpm_maybe" not in elements


def test_yellow_busy_keeps_its_place_and_ignores_the_busy_colour():
    display = DisplayConfig(busy_color="#00FF00", busy_label="IN MTG")
    busy = by_id(drawing(screen("status", response="tentative"), display, "a", 91))["cal_busy"]
    assert (busy.text, busy.color, busy.align, busy.x, busy.y) == (
        "IN MTG",
        "#FFC800FF",
        "top_right",
        72,
        8,
    )


def test_the_bars_own_animations_have_no_yellow_version():
    display = DisplayConfig(event_icon="animation", reminder_icon="animation")
    for kind, animation in (("reminder", "reminder"), ("status", "event")):
        elements = by_id(drawing(screen(kind, response="tentative"), display, "a", 91))
        assert (
            elements["cal_icon"].stock_path == f"shared/animations/calendar_{animation}_16x16.anim"
        )
    # The label is still yellow beside the animation.
    assert elements["cal_busy"].color == "#FFC800FF"


def test_stock_status_animation_ignores_tentative():
    status = StatusSettings(show="meeting")
    d = drawing(screen("status", status=status, response="tentative"), DisplayConfig(), "a", 91)
    assert [e.id for e in d.elements] == ["cal_status"]


@pytest.mark.parametrize("calendar", ["RED_CALENDAR", "YELLOW_CALENDAR"])
def test_calendars_are_valid_xpm2(calendar):
    lines = getattr(icons, calendar).splitlines()
    assert lines[0] == "! XPM2"
    width, height, colours, cpp = map(int, lines[1].split())
    assert (width, height, cpp) == (16, 16, 1)
    assert colours <= 32  # the firmware's limit
    palette = {line[0] for line in lines[2 : 2 + colours]}
    rows = lines[2 + colours :]
    assert len(rows) == height
    assert all(len(row) == width and set(row) <= palette for row in rows)


def test_status_stock_animation():
    d = drawing(screen("status", status=StatusSettings(show="meeting")), DisplayConfig(), "a", 91)
    [element] = d.elements
    assert element.stock_path == "shared/animations/meeting_72x16.anim"
    assert element.loop


def test_sounds_follow_settings():
    display = DisplayConfig()
    assert sound(screen(), display) == "shared/sounds/calendar_reminder_ends.snd"
    assert sound(screen("status"), display) == "shared/sounds/calendar_event_starts.snd"
    assert sound(screen(reminder=ReminderSettings(sound=False)), display) is None
    assert sound(screen("status", status=StatusSettings(sound=False)), display) is None


def test_payload_matches_the_wire_format():
    payload = drawing(screen("status"), DisplayConfig(), "a", 91).payload()
    assert {e["type"] for e in payload["elements"]} == {"xpmbitmap", "text", "countdown"}
    for element in payload["elements"]:
        assert "timeout" not in element  # mutually exclusive with display_until
        assert element["display_until"].isdigit()


def test_titles_are_capped():
    from busybar_calendar.render import MAX_TITLE

    long = "Quarterly planning " * 300  # 5700 characters
    for font in ("small", "global"):
        text = title_text(long, DisplayConfig(title_font=font))
        assert len(text) <= MAX_TITLE and text.endswith("...")
    assert title_text("Standup", DisplayConfig()) == "Standup"  # short ones untouched
tests/test_runner.py
import asyncio
import itertools
from datetime import UTC, datetime, timedelta

from busylib.exceptions import BusyBarAPIError

from busybar_calendar.config import Config
from busybar_calendar.events import CalendarRef, Event
from busybar_calendar.runner import Runner

T0 = datetime(2026, 10, 13, 9, 0, tzinfo=UTC)
REF = CalendarRef("Work", "Work", 0)
CONFIG = Config.model_validate({"sources": [{"name": "Work", "type": "ics", "url": "https://x"}]})
TITLED = ("draw", ["cal_xpm", "cal_title", "cal_time"])  # the reminder
BUSY = ("draw", ["cal_xpm", "cal_title", "cal_time", "cal_busy"])  # the status
MAYBE = ("draw", ["cal_xpm_maybe", "cal_title", "cal_time"])  # a tentative reminder
MAYBE_BUSY = ("draw", ["cal_xpm_maybe", "cal_title", "cal_time", "cal_busy"])  # its status
CLEAR = ("clear", "busybar-calendar")


class FakeBar:
    def __init__(self) -> None:
        self.calls: list[tuple] = []
        self.fail = 0
        self.someone_else_on_screen = False

    async def api_request(self, method, path, json_payload=None, **kwargs):
        assert (method, path) == ("POST", "/api/display/draw")
        if self.fail:
            self.fail -= 1
            raise ConnectionError("unreachable")
        self.calls.append(("draw", [e["id"] for e in json_payload["elements"]]))

    async def display_clear(self, **kwargs):
        if self.someone_else_on_screen:
            raise BusyBarAPIError("Bad Request", status_code=400, method="DELETE")
        self.calls.append(("clear", kwargs.get("application_name")))

    async def audio_play(self, *, stock_path=None, **kwargs):
        self.calls.append(("sound", stock_path.rsplit("/", 1)[-1]))


class FakeSource:
    name = "Work"

    def __init__(self, events) -> None:
        self.events = events
        self.fail = False

    async def fetch(self, start, end):
        if self.fail:
            raise OSError("server down")
        return list(self.events)

    async def aclose(self):
        pass


def make(events, config=CONFIG):
    bar, source = FakeBar(), FakeSource(events)
    return Runner(config, bar, [source]), bar, source


def at(minutes):
    return T0 + timedelta(minutes=minutes)


def meeting(start_min=10, length=30, title="Sync", response="accepted", uid="m"):
    start = at(start_min)
    return Event(uid, title, start, start + timedelta(minutes=length), REF, response)


async def test_full_life_of_an_event():
    runner, bar, _ = make([meeting()])
    await runner.refresh(at(0))
    await runner.tick(at(0))
    await runner.tick(at(5))
    await runner.tick(at(6))  # unchanged, no redraw
    await runner.tick(at(10))
    await runner.tick(at(40))
    assert bar.calls == [
        CLEAR,  # whatever an earlier run left behind
        TITLED,
        ("sound", "calendar_reminder_ends.snd"),
        BUSY,  # the reminder's elements plus BUSY: updated in place, no clear, no flicker
        ("sound", "calendar_event_starts.snd"),
        CLEAR,
    ]


async def test_tentative_event_is_drawn_yellow_and_updated_in_place():
    runner, bar, _ = make([meeting(response="tentative")])
    await runner.refresh(at(5))
    await runner.tick(at(5))
    await runner.tick(at(10))
    # Yellow for the reminder and the status alike; the status only adds BUSY.
    assert [c for c in bar.calls if c[0] != "sound"] == [CLEAR, MAYBE, MAYBE_BUSY]


async def test_accepted_event_takes_the_bar_from_a_tentative_one_and_gives_it_back():
    # Maybe 0-60 is on; Sure 20-50 starts in the middle of it.
    runner, bar, _ = make(
        [
            meeting(0, 60, "Maybe", "tentative", uid="t"),
            meeting(20, 30, "Sure", "accepted", uid="a"),
        ]
    )
    await runner.refresh(at(10))
    for minute in (10, 14, 16, 21, 51, 61):
        await runner.tick(at(minute))
    assert [c for c in bar.calls if c[0] != "sound"] == [
        CLEAR,
        MAYBE_BUSY,  # 10: the tentative event is on, in yellow
        CLEAR,  # 15: Sure's reminder takes over - red, so its own element ids
        TITLED,
        BUSY,  # 20: Sure's status
        CLEAR,  # 50: Sure is over, and the yellow one is back
        MAYBE_BUSY,
        CLEAR,  # 60: nothing left
    ]


async def test_changing_your_answer_redraws_the_screen():
    # Marked "maybe" from the phone while the reminder is up: yellow without waiting.
    runner, bar, source = make([meeting(response="accepted")])
    await runner.refresh(at(5))
    await runner.tick(at(5))
    source.events = [meeting(response="tentative")]
    await runner.refresh(at(6))
    await runner.tick(at(6))
    assert [c for c in bar.calls if c[0] != "sound"] == [CLEAR, TITLED, CLEAR, MAYBE]
    # Not a second chime for the same reminder.
    assert [c for c in bar.calls if c[0] == "sound"] == [("sound", "calendar_reminder_ends.snd")]


async def test_same_elements_update_in_place():
    config = Config.model_validate(
        {
            "display": {"busy_label": ""},
            "sources": [{"name": "Work", "type": "ics", "url": "https://x"}],
        }
    )
    runner, bar, _ = make([meeting()], config)
    await runner.refresh(at(5))
    await runner.tick(at(5))
    await runner.tick(at(10))
    # Reminder and status share their element ids: no clear, so no flicker.
    assert [c for c in bar.calls if c[0] != "sound"] == [CLEAR, TITLED, TITLED]


async def test_first_draw_after_start_clears_leftovers():
    runner, bar, _ = make([meeting()])
    await runner.refresh(at(20))
    await runner.tick(at(20))
    # Restarted mid-meeting: no chime, and the old run's drawing goes first.
    assert bar.calls == [CLEAR, BUSY]


async def test_layout_change_clears_before_drawing():
    config = Config.model_validate(
        {
            "sources": [
                {
                    "name": "Work",
                    "type": "ics",
                    "url": "https://x",
                    "status": {"show": "meeting", "sound": False},
                    "reminder": {"sound": False},
                }
            ]
        }
    )
    runner, bar, _ = make([meeting()], config)
    await runner.refresh(at(5))
    await runner.tick(at(5))
    await runner.tick(at(10))
    # The meeting animation has other ids than the reminder, which a draw
    # would otherwise leave underneath it.
    assert bar.calls == [CLEAR, TITLED, CLEAR, ("draw", ["cal_status"])]


async def test_clear_when_another_app_owns_the_screen():
    runner, bar, _ = make([meeting()])
    bar.someone_else_on_screen = True
    await runner.refresh(at(0))
    await runner.tick(at(0))
    await runner.tick(at(1))
    assert bar.calls == []  # 400 means nothing of ours is there: done, no retry loop
    bar.someone_else_on_screen = False
    await runner.tick(at(5))
    assert bar.calls[0] == TITLED  # known clear already, so no second clear


async def test_failed_draw_is_retried():
    runner, bar, _ = make([meeting()])
    bar.fail = 1
    await runner.refresh(at(5))
    await runner.tick(at(5))
    assert bar.calls == [CLEAR]
    await runner.tick(at(5.5))
    assert bar.calls == [
        CLEAR,
        CLEAR,  # the failed draw left the screen unknown
        TITLED,
        ("sound", "calendar_reminder_ends.snd"),
    ]


async def test_failed_refresh_keeps_events():
    runner, bar, source = make([meeting()])
    await runner.refresh(at(0))
    source.fail = True
    await runner.refresh(at(9))
    await runner.tick(at(12))
    assert BUSY in bar.calls


async def test_renamed_event_redraws_without_chime():
    runner, bar, source = make([meeting()])
    await runner.refresh(at(10))
    await runner.tick(at(10))
    source.events = [meeting(title="Sync (moved room)")]
    await runner.refresh(at(12))
    await runner.tick(at(12))
    assert [c[0] for c in bar.calls] == ["clear", "draw", "sound", "draw"]


async def test_run_wakes_on_time_and_clears_on_stop():
    clock = [at(4.999)]
    runner, bar, _ = make([meeting()])
    runner.now = lambda: clock[0]
    stop = asyncio.Event()
    task = asyncio.create_task(runner.run(stop))
    await asyncio.sleep(0.05)
    clock[0] = at(5)  # the reminder opens; the loop wakes within its 0.2s floor
    await asyncio.sleep(0.4)
    stop.set()
    await task
    assert [c[0] for c in bar.calls] == ["clear", "draw", "sound", "clear"]


async def test_a_source_configured_wrong_stops_the_service():
    from busybar_calendar.sources import FatalSourceError

    runner, _, source = make([meeting()])
    await runner.refresh(at(0))

    async def wrong_password(start, end):
        raise FatalSourceError("Work: 401, check the credentials")

    source.fetch = wrong_password
    try:
        await runner.refresh(at(1))
    except FatalSourceError as error:
        assert "401" in str(error)
    else:
        raise AssertionError("a 401 must stop the service, not be retried forever")


async def test_play_carries_on_when_the_bar_refuses_a_draw(monkeypatch):
    from busybar_calendar import playback as cli
    from busybar_calendar.playback import play

    async def no_wait(stop, moment):
        return False

    monkeypatch.setattr(cli, "_wait", no_wait)

    class Busy409Bar(FakeBar):
        async def api_request(self, method, path, json_payload=None, **kwargs):
            title = next(e["text"] for e in json_payload["elements"] if e["id"] == "cal_title")
            if title == "Second":
                raise BusyBarAPIError("Not drawn due to low priority", status_code=409)
            return await super().api_request(method, path, json_payload=json_payload, **kwargs)

    bar = Busy409Bar()
    runner = Runner(CONFIG, bar, [])
    events = [meeting(title="First"), meeting(title="Second"), meeting(title="Third")]
    await play(runner, events, 1, asyncio.Event(), chime=False)
    drawn = [c for c in bar.calls if c[0] == "draw"]
    # Second's two screens were refused; First and Third still played in full.
    assert len(drawn) == 4


async def test_left_over_elements_are_cleared_first():
    # A meeting's status, then the next meeting's reminder: BUSY must not stay up.
    runner, bar, _ = make([meeting(start_min=0, length=60, title="Long"), meeting(start_min=30)])
    await runner.refresh(at(10))
    await runner.tick(at(10))
    await runner.tick(at(26))
    # (No chime for Long: its status opened ten minutes before the first tick.)
    assert bar.calls == [CLEAR, BUSY, CLEAR, TITLED, ("sound", "calendar_reminder_ends.snd")]


async def test_chimes_of_events_that_are_gone_are_forgotten():
    runner, _, source = make([meeting()])
    await runner.refresh(at(5))
    await runner.tick(at(5))
    assert runner._sounded == {("reminder", meeting().key)}
    source.events = []  # the meeting is over and out of the window
    await runner.refresh(at(2000))
    assert runner._sounded == set()


async def test_title_rules_keep_an_event_off_the_bar():
    config = Config.model_validate(
        {
            "sources": [{"name": "Work", "type": "ics", "url": "https://x"}],
            "titles": [
                {"match": "Focus*", "reminder": {"enabled": False}, "status": {"enabled": False}}
            ],
        }
    )
    runner, bar, _ = make([meeting(title="Focus time"), meeting(60, title="Sync")], config)
    await runner.refresh(at(0))
    for minute in (6, 20):  # the focus block's reminder, then its middle
        await runner.tick(at(minute))
    assert bar.calls == [CLEAR]
    await runner.tick(at(56))  # the Sync reminder still shows, with its sound
    assert bar.calls[1:] == [TITLED, ("sound", "calendar_reminder_ends.snd")]


async def test_an_edited_config_redraws_at_once_without_a_second_chime():
    runner, bar, _ = make([meeting()])
    await runner.refresh(at(10))
    await runner.tick(at(10))
    assert bar.calls[1:] == [BUSY, ("sound", "calendar_event_starts.snd")]

    edited = Config.model_validate(
        {
            "defaults": {"status": {"show": "meeting"}},
            "sources": [{"name": "Work", "type": "ics", "url": "https://x"}],
        }
    )
    fresh = FakeSource([meeting()])
    runner.reconfigure(edited, [fresh])
    assert runner.sources == [fresh] and runner.config is edited
    await runner.refresh(at(10.5))
    await runner.tick(at(10.5))
    # The bar's own status in place of the titled screen: cleared first, no chime.
    assert bar.calls[3:] == [CLEAR, ("draw", ["cal_status"])]


async def test_reconfigure_keeps_the_events_of_sources_still_configured():
    runner, _, _ = make([meeting()])
    await runner.refresh(at(0))
    runner.reconfigure(CONFIG, [FakeSource([])])  # same name: events kept until re-read
    assert len(runner.events) == 1
    renamed = FakeSource([])
    renamed.name = "Home"
    runner.reconfigure(CONFIG, [renamed])
    assert runner.events == []


async def test_run_calls_its_hook_at_every_wake():
    runner, _, _ = make([])
    clock = itertools.count()
    runner.now = lambda: at(2 * next(clock))  # time flies: every sleep is the 0.2s floor
    stop = asyncio.Event()
    calls = []

    async def hook():
        calls.append(1)
        if len(calls) == 2:
            stop.set()

    await asyncio.wait_for(runner.run(stop, hook), 2)
    assert calls == [1, 1]
tests/test_schedule.py
from datetime import UTC, datetime, timedelta

from busybar_calendar.config import ReminderSettings, StatusSettings
from busybar_calendar.events import CalendarRef, Event
from busybar_calendar.schedule import choose, next_change, upcoming

T0 = datetime(2026, 10, 13, 9, 0, tzinfo=UTC)
WORK = CalendarRef("icloud", "Work", 0)
HOME = CalendarRef("icloud", "Home", 1)


def event(title, start_min, length_min=30, calendar=WORK):
    start = T0 + timedelta(minutes=start_min)
    return Event(title, title, start, start + timedelta(minutes=length_min), calendar)


def settings(reminder=None, status=None):
    reminder = reminder or ReminderSettings()
    status = status or StatusSettings()
    return lambda _event: (reminder, status)


def at(minutes):
    return T0 + timedelta(minutes=minutes)


def test_reminder_then_status_then_nothing():
    events = [event("Sync", 10)]
    s = settings()
    assert choose(at(0), events, s) is None
    assert choose(at(5), events, s).kind == "reminder"
    assert choose(at(9.99), events, s).kind == "reminder"
    assert choose(at(10), events, s).kind == "status"
    assert choose(at(39.99), events, s).kind == "status"
    assert choose(at(40), events, s) is None


def test_disabled_settings():
    events = [event("Sync", 10)]
    assert choose(at(6), events, settings(ReminderSettings(enabled=False))) is None
    assert choose(at(12), events, settings(status=StatusSettings(enabled=False))) is None


def test_next_reminder_replaces_running_status():
    # The newest event wins: the reminder for 09:30 over the 09:00 meeting.
    events = [event("Long", 0, 90), event("Next", 30)]
    screen = choose(at(26), events, settings())
    assert (screen.kind, screen.event.title) == ("reminder", "Next")
    screen = choose(at(31), events, settings())
    assert (screen.kind, screen.event.title) == ("status", "Next")
    screen = choose(at(70), events, settings())
    assert (screen.kind, screen.event.title) == ("status", "Long")


def test_same_start_later_calendar_wins():
    events = [event("Home thing", 0, calendar=HOME), event("Work thing", 0, calendar=WORK)]
    assert choose(at(1), events, settings()).event.title == "Home thing"


def test_screen_keys_change_with_what_is_drawn():
    events = [event("Sync", 10)]
    s = settings()
    assert choose(at(5), events, s).key == choose(at(6), events, s).key
    assert choose(at(5), events, s).key != choose(at(10), events, s).key
    renamed = [Event("Sync", "Renamed", events[0].start, events[0].end, WORK)]
    assert choose(at(5), events, s).key != choose(at(5), renamed, s).key


def test_next_change():
    events = [event("Sync", 10), event("Later", 120)]
    s = settings(ReminderSettings(minutes_before=10))
    assert next_change(at(-30), events, s) == at(0)
    assert next_change(at(0), events, s) == at(10)
    assert next_change(at(10), events, s) == at(40)
    assert next_change(at(40), events, s) == at(110)
    assert next_change(at(200), events, s) is None


def answered(title, start_min, length_min, response, calendar=WORK):
    start = T0 + timedelta(minutes=start_min)
    end = start + timedelta(minutes=length_min)
    return Event(title, title, start, end, calendar, response)


def test_accepted_beats_tentative_whichever_is_newer():
    # Tentative, newer: the accepted meeting keeps the bar.
    events = [answered("Accepted", 0, 60, "accepted"), answered("Maybe", 30, 30, "tentative")]
    assert choose(at(26), events, settings()).event.title == "Accepted"  # no reminder for Maybe
    assert choose(at(40), events, settings()).event.title == "Accepted"
    # Accepted, newer: it wins, as it would anyway.
    events = [answered("Maybe", 0, 60, "tentative"), answered("Accepted", 30, 30, "accepted")]
    assert (
        choose(at(26), events, settings()).kind,
        choose(at(26), events, settings()).event.title,
    ) == (
        "reminder",
        "Accepted",
    )
    assert choose(at(40), events, settings()).event.title == "Accepted"
    assert choose(at(61), events, settings()) is None


def test_same_start_accepted_wins_over_calendar_order():
    events = [
        answered("Focus time", 0, 115, "tentative", calendar=HOME),
        answered("Sync with assistant", 0, 25, "accepted", calendar=WORK),
    ]
    assert choose(at(1), events, settings()).event.title == "Sync with assistant"
    # Once it ends, the tentative one is all that is left.
    assert choose(at(26), events, settings()).event.title == "Focus time"


def shown(minute, events):
    screen = choose(at(minute), events, settings())
    return None if screen is None else (screen.kind, screen.event.title)


def test_accepted_wins_over_tentative_at_the_same_time():
    # Same start and end; the tentative one is on the earlier calendar and listed first.
    events = [
        answered("Maybe", 0, 60, "tentative", calendar=HOME),
        answered("Sure", 0, 60, "accepted", calendar=WORK),
    ]
    assert shown(30, events) == ("status", "Sure")
    assert shown(30, events[::-1]) == ("status", "Sure")  # and not by list order


def test_accepted_wins_when_it_starts_after_the_tentative_one():
    # Maybe 0-60, Sure 20-80: the accepted reminder (15) and status (20) take the
    # bar, and the tentative event does not come back while Sure is still on.
    events = [answered("Maybe", 0, 60, "tentative"), answered("Sure", 20, 60, "accepted")]
    assert shown(10, events) == ("status", "Maybe")
    assert shown(16, events) == ("reminder", "Sure")
    assert shown(30, events) == ("status", "Sure")
    assert shown(65, events) == ("status", "Sure")  # Maybe is over by now
    assert shown(81, events) is None


def test_accepted_wins_when_it_starts_before_the_tentative_one():
    # Sure 0-40, Maybe 20-80: Maybe's status does not cut in at 20, and takes the
    # bar when Sure ends.
    events = [answered("Sure", 0, 40, "accepted"), answered("Maybe", 20, 60, "tentative")]
    assert shown(10, events) == ("status", "Sure")
    assert shown(25, events) == ("status", "Sure")
    assert shown(39.9, events) == ("status", "Sure")
    assert shown(40, events) == ("status", "Maybe")
    assert shown(81, events) is None


def test_accepted_inside_a_longer_tentative_event():
    # Maybe 0-120 with Sure 30-60 in the middle: Sure for its slot, Maybe around it.
    events = [answered("Maybe", 0, 120, "tentative"), answered("Sure", 30, 30, "accepted")]
    assert [shown(m, events) for m in (10, 26, 45, 60, 90)] == [
        ("status", "Maybe"),
        ("reminder", "Sure"),
        ("status", "Sure"),
        ("status", "Maybe"),
        ("status", "Maybe"),
    ]


def test_a_tentative_reminder_does_not_interrupt_an_accepted_event():
    # Maybe starts at 40 and would remind from 35, while Sure runs until 60.
    events = [answered("Sure", 0, 60, "accepted"), answered("Maybe", 40, 30, "tentative")]
    assert shown(36, events) == ("status", "Sure")
    assert shown(45, events) == ("status", "Sure")
    assert shown(61, events) == ("status", "Maybe")


def test_a_chain_of_shifted_events_always_shows_the_accepted_one():
    events = [
        answered("Maybe 1", 0, 50, "tentative"),
        answered("Sure", 20, 50, "accepted"),
        answered("Maybe 2", 40, 50, "tentative"),
    ]
    assert [shown(m, events) for m in (10, 30, 55, 69, 71, 85)] == [
        ("status", "Maybe 1"),
        ("status", "Sure"),
        ("status", "Sure"),
        ("status", "Sure"),
        ("status", "Maybe 2"),
        ("status", "Maybe 2"),
    ]


def test_an_accepted_event_without_a_status_leaves_the_bar_to_the_tentative_one():
    # The settings decide what an event shows; an accepted event that puts nothing
    # up (a rule turned its status and reminder off) cannot take the bar from anyone.
    events = [answered("Quiet", 0, 60, "accepted"), answered("Maybe", 0, 60, "tentative")]

    def for_event(event):
        if event.title == "Quiet":
            return ReminderSettings(enabled=False), StatusSettings(enabled=False)
        return ReminderSettings(), StatusSettings()

    assert choose(at(30), events, for_event).event.title == "Maybe"


def test_screen_key_follows_the_response():
    # The look depends on it, so an answer changed from the phone redraws the screen.
    s = settings()
    accepted = [answered("Sync", 0, 60, "accepted")]
    maybe = [answered("Sync", 0, 60, "tentative")]
    assert choose(at(5), accepted, s).key != choose(at(5), maybe, s).key
    assert choose(at(5), maybe, s).key == choose(at(6), maybe, s).key


def test_tentative_beats_unanswered_and_ties_fall_back_to_newest():
    events = [answered("No reply", 0, 60, "needs-action"), answered("Maybe", 10, 30, "tentative")]
    assert choose(at(20), events, settings()).event.title == "Maybe"
    events = [answered("Maybe A", 0, 60, "tentative"), answered("Maybe B", 30, 30, "tentative")]
    assert choose(at(40), events, settings()).event.title == "Maybe B"


def test_tentative_events_can_stay_off_the_busy_status():
    events = [answered("Maybe", 10, 30, "tentative"), answered("Sure", 60, 30, "accepted")]
    quiet = settings(status=StatusSettings(tentative=False))
    # The reminder still comes, the status does not; accepted events are unaffected.
    assert choose(at(6), events, quiet).kind == "reminder"
    assert choose(at(20), events, quiet) is None
    assert choose(at(70), events, quiet).kind == "status"
    # By default a tentative event shows its status like any other.
    assert choose(at(20), events, settings()).kind == "status"


def test_upcoming_finds_the_next_screen_change():
    events = [event("Sync", 10), event("Later", 120)]
    s = settings(ReminderSettings(minutes_before=5))
    assert upcoming(at(0), events, s)[0] == at(5)  # Sync's reminder opens
    assert upcoming(at(0), events, s)[1].kind == "reminder"
    moment, screen = upcoming(at(6), events, s)
    assert (moment, screen.kind, screen.event.title) == (at(10), "status", "Sync")
    moment, screen = upcoming(at(20), events, s)  # Sync ends at 40: Later's reminder at 115
    assert (moment, screen.event.title) == (at(115), "Later")
    assert upcoming(at(200), events, s) is None
tests/test_service.py
import plistlib
from pathlib import Path

import pytest

from busybar_calendar import service


@pytest.fixture
def home(tmp_path, monkeypatch):
    monkeypatch.setattr(service, "_home", lambda: tmp_path)
    return tmp_path


@pytest.fixture
def ran(monkeypatch):
    calls = []

    def fake_run(command, **kwargs):
        calls.append((list(command), kwargs.get("check", False)))
        gone = command[:2] == ["launchctl", "print"]  # 113: no such service
        return service.subprocess.CompletedProcess(command, 113 if gone else 0, "", "")

    monkeypatch.setattr(service.subprocess, "run", fake_run)
    return calls


def test_launchd_agent(home):
    plan = service.install_plan(Path("cfg/config.toml"), "darwin")
    assert plan.path == home / "Library/LaunchAgents/app.busybar.calendar.plist"
    agent = plistlib.loads(plan.content)
    assert agent["ProgramArguments"][1:] == ["-m", "busybar_calendar", "run"]
    # An absolute config path, so the agent does not depend on a working directory.
    assert Path(agent["EnvironmentVariables"]["BUSYBAR_CALENDAR_CONFIG"]).is_absolute()
    assert agent["RunAtLoad"] and agent["KeepAlive"]
    assert agent["ThrottleInterval"] == service.RESTART_AFTER
    assert plan.commands[0][:2] == ["launchctl", "bootstrap"]


def test_systemd_unit(home):
    config = (home / "cal.toml").resolve()
    plan = service.install_plan(config, "linux")
    assert plan.path == home / ".config/systemd/user/busybar-calendar.service"
    unit = plan.content.decode()
    assert f"Environment=BUSYBAR_CALENDAR_CONFIG={config}" in unit
    assert "-m busybar_calendar run" in unit
    assert f"RestartSec={service.RESTART_AFTER}" in unit
    assert plan.commands[-1] == ["systemctl", "--user", "enable", "--now", service.UNIT]


def test_other_platforms_are_refused():
    with pytest.raises(OSError, match="macOS and Linux"):
        service.install_plan(Path("c.toml"), "win32")


def test_install_writes_then_loads(home, ran):
    plan = service.install_plan(Path("c.toml"), "darwin")
    service.install(plan)
    assert plan.path.read_bytes() == plan.content
    assert plan.log.parent.is_dir()
    # Unloading an older agent may fail; loading the new one must not.
    assert ran == [
        (list(plan.cleanup[0]), False),
        (list(plan.gone), False),  # gone already: no wait
        (plan.commands[0], True),
    ]


def test_a_reinstall_waits_for_the_old_agent_to_be_gone(home, monkeypatch):
    calls, naps = [], []
    still_there = iter([0, 0, 113])  # launchctl print: loaded, loaded, gone

    def fake_run(command, **kwargs):
        calls.append(command[1])
        code = next(still_there) if command[1] == "print" else 0
        return service.subprocess.CompletedProcess(command, code, "", "")

    monkeypatch.setattr(service.subprocess, "run", fake_run)
    monkeypatch.setattr(service.time, "sleep", naps.append)
    service.install(service.install_plan(Path("c.toml"), "darwin"))
    # Bootstrapping before the old agent is gone fails with exit 5.
    assert calls == ["bootout", "print", "print", "print", "bootstrap"]
    assert len(naps) == 2


def test_the_wait_for_the_old_agent_is_bounded(home, monkeypatch):
    clock = iter(range(100))
    monkeypatch.setattr(service.time, "monotonic", lambda: next(clock))
    monkeypatch.setattr(service.time, "sleep", lambda seconds: None)
    calls = []

    def fake_run(command, **kwargs):
        calls.append(command[1])
        return service.subprocess.CompletedProcess(command, 0, "", "")  # never gone

    monkeypatch.setattr(service.subprocess, "run", fake_run)
    service.install(service.install_plan(Path("c.toml"), "darwin"))
    assert calls[-1] == "bootstrap"  # tried anyway after GONE_WITHIN, to say what is wrong
    assert calls.count("print") <= service.GONE_WITHIN + 2


def test_uninstall(home, ran):
    plan = service.uninstall_plan("linux")
    assert service.uninstall(plan) is False  # nothing installed
    plan.path.parent.mkdir(parents=True)
    plan.path.write_text("[Unit]\n")
    assert service.uninstall(plan) is True
    assert not plan.path.exists()
    assert [c for c, _ in ran] == [list(plan.cleanup[0]), plan.commands[0]]


def test_systemd_quotes_paths_with_spaces():
    unit = service.systemd_unit(["/opt/my tools/python", "-m", "x"], Path("/a b/c.toml"))
    text = unit.decode()
    assert 'Environment="BUSYBAR_CALENDAR_CONFIG=/a b/c.toml"' in text
    assert 'ExecStart="/opt/my tools/python" -m x' in text


def test_installed_python_reads_the_service_file(home):
    for platform in ("darwin", "linux"):
        assert service.installed_python(platform) is None  # not installed
        plan = service.install_plan(Path("c.toml"), platform)
        plan.path.parent.mkdir(parents=True)
        command = ["/opt/my tools/bin/python", "-m", "busybar_calendar", "run"]
        plan.path.write_bytes(
            service.launchd_plist(command, Path("/c.toml"), Path("/l.log"))
            if platform == "darwin"
            else service.systemd_unit(command, Path("/c.toml"))
        )
        assert service.installed_python(platform) == "/opt/my tools/bin/python"
        plan.path.write_text("garbage")
        assert service.installed_python(platform) is None


# Shapes taken from `launchctl print` on macOS: the job's fields one tab deep,
# nested blocks (which repeat `state`) deeper.
RUNNING = """gui/501/app.busybar.calendar = {
\tactive count = 1
\tpath = /Users/me/Library/LaunchAgents/app.busybar.calendar.plist
\tstate = running
\tprogram = /usr/bin/python3
\truns = 1
\tpid = 20464
\tlast exit code = (never exited)
\tendpoints = {
\t\tstate = active
\t}
}
"""
STOPPED = (
    RUNNING.replace("state = running", "state = not running")
    .replace("\tpid = 20464\n", "")
    .replace("(never exited)", "1")
)


def test_parse_launchctl_takes_only_the_jobs_own_fields():
    fields = service.parse_launchctl(RUNNING)
    assert (fields["state"], fields["pid"]) == ("running", "20464")


class Done:
    def __init__(self, returncode=0, stdout=""):
        self.returncode, self.stdout = returncode, stdout


@pytest.mark.parametrize(
    ("output", "expected"),
    [
        (Done(0, RUNNING), (True, 20464, "")),
        (Done(0, STOPPED), (False, None, "last exit code 1")),
        (Done(113, ""), (False, None, "not loaded")),
    ],
)
def test_status_on_macos(home, monkeypatch, output, expected):
    agent = home / "Library/LaunchAgents/app.busybar.calendar.plist"
    agent.parent.mkdir(parents=True)
    agent.write_text("<plist/>")
    monkeypatch.setattr(service.subprocess, "run", lambda *a, **k: output)
    state = service.status("darwin")
    assert state.installed
    assert (state.running, state.pid, state.detail) == expected
    assert state.log == home / "Library/Logs/busybar-calendar.log"


def test_status_not_installed(home, monkeypatch):
    monkeypatch.setattr(service.subprocess, "run", lambda *a, **k: Done(113))
    state = service.status("darwin")
    assert (state.installed, state.running, state.detail) == (False, False, "")


def test_status_on_linux(home, monkeypatch):
    monkeypatch.setattr(
        service.subprocess,
        "run",
        lambda *a, **k: Done(0, "ActiveState=failed\nMainPID=0\nExecMainStatus=1\n"),
    )
    state = service.status("linux")
    assert (state.running, state.pid, state.detail) == (False, None, "last exit code 1")
    monkeypatch.setattr(
        service.subprocess,
        "run",
        lambda *a, **k: Done(0, "ActiveState=active\nMainPID=4321\nExecMainStatus=0\n"),
    )
    assert service.status("linux").pid == 4321


def test_recent_log_reads_the_tail(tmp_path):
    log = tmp_path / "busybar-calendar.log"
    log.write_text("\n".join(f"line {n}" for n in range(20)))
    state = service.Status(True, True, log=log)
    assert service.recent_log(state, 3) == ["line 17", "line 18", "line 19"]
    assert service.recent_log(service.Status(True, True, log=tmp_path / "none"), 3) == []


def test_restart_only_an_installed_service(home, ran):
    assert service.restart("darwin") is False
    assert ran == []
    agent = home / "Library/LaunchAgents/app.busybar.calendar.plist"
    agent.parent.mkdir(parents=True)
    agent.write_text("<plist/>")
    assert service.restart("darwin") is True
    # Unloaded and loaded again: `kickstart -k` waits out the 5-minute throttle.
    assert [command[:2] for command, _ in ran] == [
        ["launchctl", "bootout"],
        ["launchctl", "print"],  # gone: load it
        ["launchctl", "bootstrap"],
    ]
    assert ran[-1][0][-1] == str(agent)


@pytest.fixture
def uv_from_git(monkeypatch):
    monkeypatch.setattr(service.shutil, "which", lambda name: "/opt/uv/bin/uv")
    monkeypatch.setattr(service.update, "from_git", lambda python: True)


def test_a_self_updating_launchd_agent(home, uv_from_git):
    plan = service.install_plan(Path("c.toml"), "darwin", auto_update=True)
    agent = plistlib.loads(plan.content)
    assert agent["ProgramArguments"][1:] == ["-m", "busybar_calendar", "run", "--auto-update"]
    env = agent["EnvironmentVariables"]
    # launchd's own PATH has neither uv nor its folder; git and `security` are in /usr/bin.
    assert env["PATH"].split(":")[:2] == ["/opt/uv/bin", "/usr/local/bin"]
    assert "/usr/bin" in env["PATH"].split(":")
    assert Path(env["BUSYBAR_CALENDAR_CONFIG"]).is_absolute()
    assert agent["RunAtLoad"] and agent["KeepAlive"]
    assert not service.auto_updates("darwin")  # not installed yet
    plan.path.parent.mkdir(parents=True)
    plan.path.write_bytes(plan.content)
    assert service.auto_updates("darwin")


def test_a_self_updating_systemd_unit(home, uv_from_git):
    plan = service.install_plan(Path("c.toml"), "linux", auto_update=True)
    unit = plan.content.decode()
    assert "-m busybar_calendar run --auto-update" in unit
    assert "Environment=PATH=/opt/uv/bin:/usr/local/bin:/usr/bin:" in unit
    plan.path.parent.mkdir(parents=True)
    plan.path.write_bytes(plan.content)
    assert service.auto_updates("linux")


def test_auto_update_needs_uv_and_an_install_from_git(home, monkeypatch):
    monkeypatch.setattr(service.shutil, "which", lambda name: None)
    with pytest.raises(OSError, match="no uv on PATH"):
        service.install_plan(Path("c.toml"), "darwin", auto_update=True)
    monkeypatch.setattr(service.shutil, "which", lambda name: "/opt/uv/bin/uv")
    monkeypatch.setattr(service.update, "from_git", lambda python: False)
    with pytest.raises(OSError, match="installed from git"):
        service.install_plan(Path("c.toml"), "darwin", auto_update=True)
    # Without it, neither is needed, and the agent is as before.
    agent = plistlib.loads(service.install_plan(Path("c.toml"), "darwin").content)
    assert "PATH" not in agent["EnvironmentVariables"]
    assert agent["ProgramArguments"][-1] == "run"
tests/test_sources.py
from datetime import UTC, datetime, timedelta
from zoneinfo import ZoneInfo

from busybar_calendar.caldav import RemoteCalendar
from busybar_calendar.config import Config
from busybar_calendar.sources import CalDAVSource, select

BASE = "https://dav.example.com/dav.php/calendars/me@example.com"
MINE = RemoteCalendar(f"{BASE}/4711/", "Doe, Jane")
OPS = RemoteCalendar(f"{BASE}/5001/", "Ops Maintenances")
SRE = RemoteCalendar(f"{BASE}/5002/", "Infra Maintenances")
ROOM_A = RemoteCalendar(f"{BASE}/6001/", "Office - Room 1 (6 people)")
ROOM_B = RemoteCalendar(f"{BASE}/6002/", "Office - Room 2 (8 people)")
ALL = [MINE, OPS, SRE, ROOM_A, ROOM_B]


def names(calendars):
    return [c.name for c in calendars]


def test_calendar_id_is_the_last_url_segment():
    assert MINE.id == "4711"
    assert RemoteCalendar("https://x/cal/My%20Cal", "My Cal").id == "My Cal"


def test_no_filter_means_all_in_server_order():
    assert select(ALL, [], []) == (ALL, [])


def test_include_by_name_id_url_and_wildcard():
    assert select(ALL, ["Doe, Jane"], []) == ([MINE], [])
    assert select(ALL, ["4711"], []) == ([MINE], [])
    assert select(ALL, [f"{BASE}/4711/"], []) == ([MINE], [])
    assert select(ALL, [f"{BASE}/4711"], []) == ([MINE], [])  # trailing slash optional
    assert names(select(ALL, ["* Maintenances"], [])[0]) == [
        "Ops Maintenances",
        "Infra Maintenances",
    ]


def test_include_order_decides_overlaps_and_matches_once():
    chosen, _ = select(ALL, ["500*", "4711", "Infra Maintenances"], [])
    # Listed order, each calendar once: the last listed wins an overlap.
    assert names(chosen) == ["Ops Maintenances", "Infra Maintenances", "Doe, Jane"]


def test_exclude_by_wildcard_and_id():
    assert select(ALL, [], ["Office - Room *"]) == ([MINE, OPS, SRE], [])
    assert select(ALL, ["*"], ["Office - Room *", "5001"]) == ([MINE, SRE], [])


def test_entries_that_match_nothing_are_reported():
    chosen, unmatched = select(ALL, ["4711", "Typo"], ["Annex - Room *"])
    assert chosen == [MINE]
    assert unmatched == ["Typo", "Annex - Room *"]


def test_names_are_case_sensitive_and_exact():
    assert select(ALL, ["doe, jane"], [])[0] == []
    assert select(ALL, ["Doe"], [])[0] == []


def test_overrides_by_id_layer_over_name():
    config = Config.model_validate(
        {
            "sources": [
                {
                    "name": "Mail",
                    "type": "caldav",
                    "url": "https://x",
                    "reminder": {"minutes_before": 3},
                    "calendars": {
                        "Doe, Jane": {"reminder": {"minutes_before": 7, "sound": False}},
                        "4711": {"reminder": {"minutes_before": 10}},
                    },
                }
            ]
        }
    )
    reminder, _ = config.settings_for("Mail", "Doe, Jane", "4711")
    assert (reminder.minutes_before, reminder.sound) == (10, False)
    reminder, _ = config.settings_for("Mail", "Other", "99")
    assert (reminder.minutes_before, reminder.sound) == (3, True)


class FakeClient:
    def __init__(self, calendars, documents):
        self.calendars = calendars
        self.documents = documents

    async def discover(self):
        return self.calendars

    async def events(self, calendar, start, end):
        return self.documents.get(calendar.id, [])


def ics(uid, start):
    stamp = start.strftime("%Y%m%dT%H%M%SZ")
    end = (start + timedelta(minutes=30)).strftime("%Y%m%dT%H%M%SZ")
    return (
        "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n"
        f"UID:{uid}\r\nSUMMARY:{uid}\r\nDTSTART:{stamp}\r\nDTEND:{end}\r\n"
        "END:VEVENT\r\nEND:VCALENDAR\r\n"
    )


async def test_source_fetches_only_selected_calendars_with_ids():
    config = Config.model_validate(
        {
            "sources": [
                {
                    "name": "Mail",
                    "type": "caldav",
                    "url": "https://x",
                    "include": ["Infra Maintenances", "4711"],
                    "exclude": ["Office - Room *"],
                }
            ]
        }
    )
    start = datetime(2026, 10, 13, 9, 0, tzinfo=UTC)
    source = CalDAVSource(config.sources[0], 0, ZoneInfo("UTC"), True)
    source._client = FakeClient(
        ALL,
        {
            "4711": [ics("mine", start)],
            "5002": [ics("sre", start)],
            "6001": [ics("room", start)],
        },
    )
    events = await source.fetch(start - timedelta(hours=1), start + timedelta(hours=1))
    by_title = {e.title: e.calendar for e in events}
    assert set(by_title) == {"mine", "sre"}
    assert (by_title["mine"].id, by_title["mine"].name) == ("4711", "Doe, Jane")
    # Listed last, so it outranks Infra Maintenances when both start together.
    assert by_title["mine"].order > by_title["sre"].order
    await source.aclose()


class FailingClient(FakeClient):
    def __init__(self, error):
        super().__init__([], {})
        self.error = error

    async def discover(self):
        raise self.error


async def test_wrong_url_or_password_is_fatal_but_a_server_error_is_not():
    import pytest

    from busybar_calendar.caldav import CalDAVAuthError, CalDAVError
    from busybar_calendar.sources import FatalSourceError

    config = Config.model_validate(
        {"sources": [{"name": "M", "type": "caldav", "url": "https://x"}]}
    )
    start = datetime(2026, 10, 13, tzinfo=UTC)
    for error, fatal in (
        (CalDAVAuthError("401", 401), True),
        (CalDAVError("404", 404), True),
        (CalDAVError("no current-user-principal"), True),
        (CalDAVError("503", 503), False),
    ):
        source = CalDAVSource(config.sources[0], 0, ZoneInfo("UTC"), True)
        source._client = FailingClient(error)
        with pytest.raises(FatalSourceError if fatal else CalDAVError):
            await source.fetch(start, start + timedelta(hours=1))
        await source.aclose()


async def test_a_failed_relisting_keeps_the_known_calendars(caplog):
    import httpx

    from busybar_calendar.caldav import CalDAVAuthError, CalDAVError
    from busybar_calendar.sources import REDISCOVER_EVERY, FatalSourceError

    config = Config.model_validate(
        {"sources": [{"name": "M", "type": "caldav", "url": "https://x", "include": ["4711"]}]}
    )
    start = datetime(2026, 10, 13, 9, 0, tzinfo=UTC)
    window = (start - timedelta(hours=1), start + timedelta(hours=1))
    source = CalDAVSource(config.sources[0], 0, ZoneInfo("UTC"), True)
    client = FakeClient(ALL, {"4711": [ics("mine", start)]})
    source._client = client
    listed = []

    async def discover():
        listed.append(1)
        return ALL

    client.discover = discover
    for _ in range(REDISCOVER_EVERY):
        await source.fetch(*window)
    assert len(listed) == 1  # listed once, then again only every REDISCOVER_EVERY

    for hiccup in (CalDAVError("503", 503), httpx.ConnectTimeout("timed out")):

        async def failing(error=hiccup):
            raise error

        client.discover = failing
        events = await source.fetch(*window)  # due to list again; the server hiccups
        assert [e.title for e in events] == ["mine"]
        assert "going on with the ones it knows" in caplog.text

    client.discover = discover
    await source.fetch(*window)  # listed again at the very next refresh
    assert len(listed) == 2

    async def refused():
        raise CalDAVAuthError("401", 401)

    client.discover = refused
    for _ in range(REDISCOVER_EVERY):
        try:
            await source.fetch(*window)
        except FatalSourceError:
            break  # a password that stopped working still stops the service
    else:
        raise AssertionError("a 401 while listing again must be fatal")
    await source.aclose()
tests/test_update.py
import os
import subprocess
import sys

import pytest

from busybar_calendar import __version__, update


@pytest.fixture
def git_tool(monkeypatch):
    monkeypatch.setattr(update, "from_git", lambda python: True)
    monkeypatch.setattr(update.shutil, "which", lambda name: "/opt/uv/bin/uv")


def on_disk(monkeypatch, *versions):
    seen = iter(versions)
    monkeypatch.setattr(update, "installed", lambda: next(seen))


def answers(code=0, stderr=""):
    return lambda command, **kwargs: subprocess.CompletedProcess(command, code, "", stderr)


def test_an_update_that_brings_new_code(git_tool, monkeypatch, caplog):
    calls = []

    def run(command, **kwargs):
        calls.append((command, kwargs["timeout"]))
        return subprocess.CompletedProcess(command, 0, "", "")

    monkeypatch.setattr(update.subprocess, "run", run)
    on_disk(monkeypatch, "0.3.1 (2713373)", "0.6.1 (0046d3c)")
    with caplog.at_level("INFO"):
        assert update.update() is True
    assert calls == [(["/opt/uv/bin/uv", "tool", "upgrade", "busybar-calendar"], update.TIMEOUT)]
    assert "0.3.1 (2713373) -> 0.6.1 (0046d3c)" in caplog.text


def test_new_commits_count_even_without_a_new_version(git_tool, monkeypatch):
    monkeypatch.setattr(update.subprocess, "run", answers())
    on_disk(monkeypatch, "0.6.1 (0046d3c)", "0.6.1 (9f8e7d6)")
    assert update.update() is True


def test_already_the_latest(git_tool, monkeypatch, caplog):
    monkeypatch.setattr(update.subprocess, "run", answers())
    on_disk(monkeypatch, "0.6.1 (0046d3c)", "0.6.1 (0046d3c)")
    with caplog.at_level("INFO"):
        assert update.update() is False
    assert "0.6.1 (0046d3c) is the latest" in caplog.text


def test_a_failed_update_starts_what_is_there(git_tool, monkeypatch, caplog):
    def timeout(command, **kwargs):
        raise subprocess.TimeoutExpired(command, kwargs["timeout"])

    def missing(command, **kwargs):
        raise FileNotFoundError(command[0])

    failing = (answers(2, "error: Failed to fetch\n  Caused by: dns error\n"), timeout, missing)
    for run in failing:  # no network yet at login, a hung fetch, uv gone
        monkeypatch.setattr(update.subprocess, "run", run)
        on_disk(monkeypatch, "0.6.1 (0046d3c)")
        assert update.update() is False
    assert caplog.text.count("staying on 0.6.1 (0046d3c)") == 3
    assert "Caused by: dns error" in caplog.text  # uv's last line says why


def test_only_a_uv_tool_from_git_updates_itself(monkeypatch, caplog):
    def never(command, **kwargs):
        raise AssertionError("must not run uv")

    monkeypatch.setattr(update.subprocess, "run", never)
    monkeypatch.setattr(update, "from_git", lambda python: False)
    assert update.update() is False
    assert "only a uv tool installed from git" in caplog.text
    monkeypatch.setattr(update, "from_git", lambda python: True)
    monkeypatch.setattr(update.shutil, "which", lambda name: None)
    assert update.update() is False
    assert "no uv on PATH" in caplog.text


def test_installed_reads_the_version_on_disk():
    assert update.installed().startswith(__version__)


def test_restart_into_update_reruns_the_same_command(monkeypatch):
    seen = []
    monkeypatch.setattr(update.os, "execv", lambda path, argv: seen.append((path, argv)))
    monkeypatch.setenv(update.UPDATED_ENV, "")
    argv = ["/x/bin/python", "-m", "busybar_calendar", "run", "--auto-update"]
    monkeypatch.setattr(update.sys, "orig_argv", argv)
    update.restart_into_update()
    assert seen == [(sys.executable, [sys.executable, *argv[1:]])]
    assert os.environ[update.UPDATED_ENV] == "1"  # the new process does not update again
tests/test_wizard.py
import stat

from busybar_calendar import wizard
from busybar_calendar.caldav import CalDAVAuthError, RemoteCalendar
from busybar_calendar.config import load

FOUND = [
    RemoteCalendar("https://dav.example.com/cal/me/1001/", "Home"),
    RemoteCalendar("https://dav.example.com/cal/me/1002/", "Work"),
    RemoteCalendar("https://dav.example.com/cal/me/2001/", "Room 1 (6 people)"),
]


def script(*answers):
    pending = list(answers)
    asked = []

    def ask(prompt):
        asked.append(prompt)
        return pending.pop(0)

    ask.asked = asked
    return ask


class FakeStore:
    def __init__(self, keychain=True):
        self.saved = []
        self.keychain = keychain

    def save(self, service, account, env):
        self.saved.append((service, account))
        if self.keychain:
            return wizard.Secret(command=["security", "find-generic-password", "-s", service, "-w"])
        return wizard.Secret(env=env)


def test_cloud_and_an_icloud_account(tmp_path):
    path = tmp_path / "busybar-calendar" / "config.toml"
    # cloud (default), CalDAV (default), username, the guessed provider, two calendars
    ask = script("", "", "me@icloud.com", "", "2, 1")
    store = FakeStore()
    assert wizard.run(path, ask=ask, store=store, discover=lambda source: FOUND) == 0

    assert ask.asked[3] == "Choose 1-5 [1]: "  # iCloud, guessed from the address
    assert stat.S_IMODE(path.stat().st_mode) == 0o600
    config = load(path)
    assert config.bar.address is None
    assert config.bar.token.command[-2:] == ["busybar-calendar-bar", "-w"]
    [source] = config.sources
    assert (source.name, source.url, source.username) == (
        "iCloud",
        "https://caldav.icloud.com",
        "me@icloud.com",
    )
    assert source.include == ["Home", "Work"]  # in the server's order
    assert store.saved == [
        ("busybar-calendar-bar", "busybar-calendar"),
        ("busybar-calendar-me@icloud.com", "me@icloud.com"),  # one entry per account
    ]


def test_usb_and_an_ics_link(tmp_path):
    path = tmp_path / "config.toml"
    ask = script("3", "2", "webcal://example.com/team.ics")
    assert wizard.run(path, ask=ask, store=FakeStore(), discover=None) == 0
    config = load(path)
    assert config.bar.address == "10.0.4.20"
    assert config.bar.token.resolve() is None
    assert (config.sources[0].type, config.sources[0].url) == (
        "ics",
        "https://example.com/team.ics",
    )


def test_wifi_without_a_keychain_and_a_failed_sign_in(tmp_path, capsys):
    path = tmp_path / "config.toml"

    def refuse(source):
        raise CalDAVAuthError("PROPFIND https://dav.example.com: 401, check the credentials", 401)

    ask = script("2", "192.168.1.50", "1", "me@example.com", "", "https://dav.example.com")
    assert wizard.run(path, ask=ask, store=FakeStore(keychain=False), discover=refuse) == 0
    out = capsys.readouterr().out
    assert "Could not sign in" in out
    text = path.read_text()
    assert 'token_env = "BUSYBAR_TOKEN"' in text
    assert 'password_env = "CALDAV_PASSWORD"' in text
    assert "include" not in text
    assert load(path).sources[0].name == "example.com"


def test_all_calendars_when_none_are_picked(tmp_path):
    path = tmp_path / "config.toml"
    ask = script("3", "1", "me@example.com", "", "https://dav.example.com", "")
    wizard.run(path, ask=ask, store=FakeStore(), discover=lambda source: FOUND)
    assert load(path).sources[0].include == []


def test_bad_answers_are_asked_again(tmp_path):
    path = tmp_path / "config.toml"
    ask = script("9", "x", "3", "2", "https://example.com/a.ics")
    assert wizard.run(path, ask=ask, store=FakeStore(), discover=None) == 0
    assert len(ask.asked) == 5


def test_an_existing_config_is_kept_unless_confirmed(tmp_path):
    path = tmp_path / "config.toml"
    path.write_text("keep me")
    assert wizard.run(path, ask=script("n"), store=FakeStore(), discover=None) == 1
    assert path.read_text() == "keep me"
    ask = script("y", "3", "2", "https://example.com/a.ics")
    assert wizard.run(path, ask=ask, store=FakeStore(), discover=None) == 0
    assert "keep me" not in path.read_text()


EXISTING = """# my config, with my own comments
[bar]
address = "10.0.4.20"

[[sources]]
name = "iCloud"
type = "caldav"
url = "https://caldav.icloud.com"
username = "me@icloud.com"
password_env = "ICLOUD"

[sources.calendars.Work.status]
show = "meeting"
"""


def test_add_appends_a_source_and_keeps_the_rest(tmp_path):
    path = tmp_path / "config.toml"
    path.write_text(EXISTING)
    # A second iCloud account: the name must not clash with the first.
    ask = script("1", "partner@icloud.com", "", "1")
    store = FakeStore()
    assert wizard.add(path, ask=ask, store=store, discover=lambda source: FOUND) == 0
    text = path.read_text()
    assert text.startswith(EXISTING.rstrip("\n"))  # comments and layout untouched
    config = load(path)
    assert [s.name for s in config.sources] == ["iCloud", "iCloud 2"]
    assert config.sources[1].include == ["Home"]
    assert config.settings_for("iCloud", "Work")[1].show == "meeting"  # still applies
    assert store.saved == [("busybar-calendar-partner@icloud.com", "partner@icloud.com")]


def test_add_an_ics_link(tmp_path):
    path = tmp_path / "config.toml"
    path.write_text(EXISTING)
    assert (
        wizard.add(
            path, ask=script("2", "webcal://example.com/team.ics"), store=FakeStore(), discover=None
        )
        == 0
    )
    assert load(path).sources[1].url == "https://example.com/team.ics"


def test_add_needs_a_config(tmp_path):
    import pytest

    from busybar_calendar.config import ConfigError

    with pytest.raises(ConfigError, match="busybar-calendar init"):
        wizard.add(tmp_path / "none.toml", ask=script(), store=FakeStore(), discover=None)


def test_keychain_prompt_is_asked_again_then_gives_up_cleanly(monkeypatch, capsys):
    import subprocess

    import pytest

    from busybar_calendar.config import ConfigError

    codes = [1, 0]  # a mistyped retype, then right
    monkeypatch.setattr(wizard.shutil, "which", lambda name: "/usr/bin/security")
    monkeypatch.setattr(
        wizard.subprocess,
        "run",
        lambda command, **kw: subprocess.CompletedProcess(command, codes.pop(0)),
    )
    secret = wizard.SecretStore("darwin").save("svc", "me", "ENV")
    assert secret.command[-2:] == ["svc", "-w"]
    assert "Once more" in capsys.readouterr().out

    monkeypatch.setattr(
        wizard.subprocess, "run", lambda command, **kw: subprocess.CompletedProcess(command, 1)
    )
    with pytest.raises(ConfigError, match="did not store the password"):
        wizard.SecretStore("darwin").save("svc", "me", "ENV")


def caldav_source(tmp_path, *answers):
    path = tmp_path / "config.toml"
    ask = script("3", "1", *answers)  # USB, CalDAV, then the answers given
    wizard.run(path, ask=ask, store=FakeStore(), discover=lambda source: FOUND)
    return load(path).sources[0], ask


def test_providers_fill_in_the_server(tmp_path):
    source, ask = caldav_source(tmp_path, "me@mydomain.com", "3", "")  # PrivateEmail
    assert (source.name, source.url) == ("PrivateEmail", "https://dav.privateemail.com")
    assert ask.asked[3] == "Choose 1-5 [5]: "  # an unknown domain: another server by default


def test_nextcloud_needs_only_the_host(tmp_path):
    source, _ = caldav_source(tmp_path, "me@example.com", "4", "cloud.example.com/", "")
    assert source.url == "https://cloud.example.com/remote.php/dav"
    assert source.name == "example.com"


def test_fastmail_is_guessed(tmp_path):
    source, ask = caldav_source(tmp_path, "me@fastmail.com", "", "")
    assert (source.name, source.url) == ("Fastmail", "https://caldav.fastmail.com")
    assert ask.asked[3] == "Choose 1-5 [2]: "

Source and Provenance

View This Version on GitHub → · Author’s Project →

Source commit: 2580e9aa0b4c667ae7906474b6a767d72b3f4fe0
Content SHA-256: 62357ac5b2d66ec3fa09fddcafbf583bda25ac22ea2d980b54d19e9cb5d1341d

The Community Workbench

Rate and Discuss

Comments and reactions live in this site’s GitHub repository. Sign in with GitHub to participate.