Skip to content
thkleinertPublic

About

πŸ—“ Mirror personal appointments into your work calendar as opaque busy blocks β€” macOS-native via EventKit, so no OAuth client, no server and no credentials to hold

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

Β 

History

17 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

AutoBlock logo

AutoBlock

Nobody books over your private time.

Getting a personal appointment into a work calendar has never been smooth. The two systems do not talk to each other, so you type a placeholder in by hand β€” then again when the time shifts, and again when it is cancelled. Miss one and a colleague books straight over your afternoon.

AutoBlock does the typing. A personal appointment appears in your work calendar as an opaque busy block β€” no title, no details, no attendees, just time that is already taken. Move the appointment and the block moves with it; delete it and the block goes. Colleagues see that you are busy. They never see why.

Runs entirely on your Mac, through the calendar accounts macOS has already been authorised for. No OAuth client, no API keys, no server, no administrator in the loop.

TypeScript Swift macOS Node SQLite Dependencies License: MIT


The connections list, each with an enable switch and a source to target summary



Preview naming the appointment behind every blockΒ  The same connections list in dark mode


Contents


Why AutoBlock

Work calendars assume every hour you have not defended is fair game. A dentist appointment, the school run, a physio slot β€” none of it exists as far as scheduling software is concerned, so a colleague books straight over it and you find out the evening before.

So you block the time by hand. A placeholder at eleven on Tuesday, typed into a second calendar in a second app, because copying the real appointment across would tell the company where you are and why.

That works right up until the appointment moves and the block does not. Or it is cancelled and the block sits there, holding an hour you could have used. Or you are on your phone, in a hurry, and simply do not bother. Manual blocks drift out of step with reality, and reality lives in the other calendar.

AutoBlock keeps them in step. It copies only the shape of an appointment β€” this hour is taken β€” and it keeps copying it, every fifteen minutes, as things move and shorten and get cancelled. The title, the location, the notes and the attendees stay where they were.

It is deliberately narrow. No availability negotiation, no meeting scheduling, no team features. One direction of one thing, done carefully.

Why not just subscribe to the calendar?

Every calendar app will happily subscribe to an .ics URL, so the fair question is why this needs software at all. Two reasons.

A subscribed calendar is a read-only overlay. It shows your appointments β€” titles and all β€” to anyone who can see your calendar, which is the opposite of what you wanted. And Google refreshes a subscribed URL roughly every 12 to 24 hours, so an appointment you add this morning may not surface there until tomorrow.

Importing the file instead creates real events that do hold time β€” but it is a one-shot copy, complete with the titles, and nothing updates when the appointment moves.

What actually holds an hour is a real event in the work calendar, opaque, kept current. That is the whole of what AutoBlock does.

Feature Tour

πŸ”’ Opaque by Design

Preview showing blocks and the appointments that caused them

A block carries a title you choose β€” Private by default β€” and nothing else. The source event's title never crosses over, so the work calendar learns that Tuesday at eleven is unavailable and stops there.

Each block is marked internally with an x-autoblock:// URL. That is how the tool recognises its own work later, and how two connections pointing at each other avoid mirroring each other forever.


🏷 Titles That Say As Much As You Want

A block is titled Private and nothing more, unless you ask for otherwise. Two placeholders are available if you do:

Template Produces
Private Private
{{calendar_name}} Personal
Blocked: {{event_name}} Blocked: Zahnarzt

{{calendar_name}} is the useful middle ground β€” colleagues learn the block is personal rather than a hidden meeting, without learning what it is. {{event_name}} discloses everything, which is why neither is the default.

A merged block names every distinct source rather than picking one, and a template that resolves to nothing falls back to Private instead of writing a blank title. A mistyped placeholder is rejected when the config is saved, rather than appearing verbatim on every block in your calendar.

⏱ Clipped to Your Hours

The weekly hours grid with a lunch break carved out

Only time inside your configured hours is ever mirrored. A 20:00 dinner produces nothing at all. A 16:00–20:00 commitment produces a block from 16:00 to the end of your working day and no further.

Hours are a weekly grid with as many ranges per day as you like, so a lunch break is two ranges rather than a compromise. Days you leave empty are not synced. Everything is expressed in a time zone you pick, and the grid follows the wall clock across daylight saving rather than drifting an hour twice a year.


πŸ›Ÿ Buffers Around Every Block

Appointments do not end the moment they finish β€” there is a tram to catch, a conversation that overruns. Each connection pads its blocks, fifteen minutes on each side by default.

The padding is applied before the block is trimmed to your hours, so a buffer can never place busy time outside them, and before overlapping blocks are merged, so two appointments twenty minutes apart become one block rather than two with a useless gap between them.

πŸ” Connections, in Both Directions

A connection expanded: calendar pickers, hours grid and block settings

A connection is one relationship: some calendars read, some written, with its own hours, buffers, title and filters. Direction is only which calendars sit on which side β€” so mirroring work commitments back into your personal calendar, with different hours and a different label, is another connection rather than a different mode.

Connections are independent. Two of them may write into the same calendar without fighting over each other's blocks, and any of them can be switched off without being deleted.


🩹 Self-Healing

The calendar is the truth, not AutoBlock's records. Delete a block by hand and the next run notices it is gone and puts it back. Lose the state file and the blocks already in your calendar are adopted rather than written a second time.

Excluding one appointment needs no configuration at all: mark it Free in any calendar app, or put #noblock in its title. Both work from your phone.

πŸ‘ Preview Before It Writes

Every plan can be rehearsed. Preview reads your calendars, works out exactly which blocks it would create, move and delete, and writes nothing at all β€” naming the appointment behind each one, so a block is never a mystery.

The configuration UI is preview-only by design. Real syncs come from the background agent or the command line.

πŸ–₯ Runs Quietly in the Background

A launchd agent syncs on an interval you choose, from five minutes to an hour, installable and removable from the UI. It writes to a log you can tail, and its last exit status is reported back on the page.

How It's Built

The sync core is platform-free TypeScript: an iCalendar parser, a daylight-saving-correct time layer, hour clipping, block planning, and a reconciler that diffs what should exist against what was written last time. It has no dependencies at all β€” not one runtime package, and only TypeScript and @types/node to develop against.

Calendar access is a 280-line Swift helper that speaks newline-delimited JSON on stdin and stdout. One process serves an entire sync, so macOS asks for calendar permission once rather than once per event, and a run writing forty blocks costs forty messages rather than forty process spawns.

Blocks belonging to a connection are found in the calendar two independent ways: by an x-autoblock:// URL naming that connection, and by identifiers held in the state store. Either alone is enough, which matters because nothing obliges a CalDAV or Exchange server to preserve a custom URL through a round-trip.

Blocks are content-addressed: a block's key is its start and end. An unchanged block matches by key and costs nothing. A block whose time moved has no key match, so it is paired with the nearest orphan and patched in place β€” which keeps the calendar event's identity stable, rather than having entries blink out of existence and return whenever you nudge an appointment by ten minutes.

State lives in a local SQLite file, written only after a calendar confirms the write, so a failure is retried on the next run rather than recorded as done. It is a cache, not an authority: every run checks it against the calendar and corrects it.

Setup Guide

1. Prerequisites

  • macOS 14 or newer
  • Node 22 or newer
  • Xcode Command Line Tools, for swiftc β€” xcode-select --install
  • The calendars you want to sync, added under System Settings β†’ Internet Accounts

2. Clone and Build

git clone https://github.com/thkleinert/autoblock.git
cd autoblock
npm install
npm run build:bridge

build:bridge compiles the Swift helper and ad-hoc signs it. The embedded Info.plist is what lets macOS attribute the calendar permission prompt to it.

3. Pick Your Calendars

npm run ui        # β†’ http://localhost:4747

Add a connection, tick the calendars to read on the left and the calendars to write into on the right, set your hours, and save. macOS will ask for calendar access the first time β€” approve it.

Prefer a file? cp config.example.json config.json, then run npm run calendars to list every calendar with the identifier the config needs.

4. Preview, Then Sync

DRY_RUN=1 npm run sync     # prints every create, move and delete it would make
npm run sync               # does it

Read the preview before the first real run. Blocks are ordinary calendar events β€” deleting one by hand is always safe, and the next sync re-derives everything from your source calendars.

5. Install the Background Agent

./tools/install-agent.sh              # every 15 minutes
INTERVAL_SECONDS=300 ./tools/install-agent.sh
./tools/install-agent.sh uninstall

Or use the Background agent card in the UI. Afterwards:

tail -f ~/Library/Logs/autoblock.log
launchctl kickstart -k gui/$(id -u)/local.autoblock

The agent runs whenever your Mac is awake. To narrow the gap, turn on Prevent automatic sleeping on power adapter in System Settings β†’ Battery β†’ Options, and optionally wake the machine on a schedule β€” launchd will not do this itself:

sudo pmset repeat wakeorpoweron MTWRFSU 07:00

Configuration Reference

{
  "connections": [
    {
      "id": "personal-to-work",
      "name": "Personal β†’ Work",
      "enabled": true,
      "workHours": {
        "timezone": "Europe/Berlin",
        "week": { "mon": ["09:00-12:30", "13:30-18:00"], "fri": ["09:00-15:00"] },
        "holidays": ["2026-12-24"]
      },
      "window": { "pastDays": 1, "futureDays": 28 },
      "block": {
        "title": "Private",
        "mergeOverlapping": true,
        "minDurationMinutes": 15,
        "paddingBeforeMinutes": 15,
        "paddingAfterMinutes": 15,
        "allDay": "workday"
      },
      "filters": {
        "includeTentative": true,
        "includeTransparent": false,
        "skipTags": ["#noblock"],
        "skipSummaryPatterns": []
      },
      "sources": [{ "id": "personal", "type": "eventkit", "calendarIds": ["…"] }],
      "targets": [{ "id": "work", "type": "eventkit", "calendarId": "…" }]
    }
  ]
}
Key Meaning
workHours.week Per weekday "HH:MM-HH:MM" ranges. Several ranges carve out a break; a missing day is not synced.
workHours.holidays YYYY-MM-DD dates treated as non-working.
window.pastDays How far back to look. Floored at one day so an event in progress is seen whole. Blocks that already ended are left alone.
window.futureDays How far ahead to mirror. Narrowing it removes blocks beyond the new horizon; they return as the window rolls forward.
block.title Written into the target calendar. Nothing else is. May use {{calendar_name}} or {{event_name}} β€” see below.
block.mergeOverlapping Collapse overlapping events into one block.
block.minDurationMinutes Discard slivers shorter than this after clipping.
block.paddingBeforeMinutes Β· paddingAfterMinutes Buffer either side of each appointment.
block.allDay "workday" blocks the whole active day; "ignore" skips all-day events.
filters.includeTentative Β· includeTransparent Whether tentative events, and events marked free, are mirrored.
filters.skipTags Case-insensitive markers, matched anywhere in a title. Defaults to ["#noblock"].
filters.skipSummaryPatterns Case-insensitive regexes; a matching title is skipped.
sources[].type eventkit (calendarIds) or caldav (url, username, passwordSecret).
targets[].type eventkit (calendarId).

A config written before connections existed β€” one relationship at the top level β€” is lifted into a single connection named default, so older files keep working.

Why Not the Cloud

The obvious design is a small server that syncs around the clock. It cannot be built, and the reason is worth writing down.

Writing to a Google or Microsoft work calendar through their APIs needs an OAuth client belonging to a verified publisher: a domain you own, a privacy policy hosted on it, and a review by Google. Registering the app inside your employer's tenant avoids that, but the app then belongs to the employer and dies with the job. Registering it under your own account means publishing it, which means the verification you were trying to avoid.

Your Mac, meanwhile, is already authorised. Adding those accounts in Internet Accounts ran exactly that OAuth flow as Apple's verified client, and the resulting token lives in the Keychain. AutoBlock writes through EventKit, riding an authorisation that already exists instead of establishing a competing one.

The trade is explicit: syncing only happens while your Mac is awake. The authorisation cannot leave the machine, so neither can the sync. In practice the hole is small β€” colleagues book during working hours, when the Mac is generally open β€” and an always-on Mac closes it entirely.

Security Model

  • No credentials exist. Nothing to store, rotate or leak. Calendar access comes from the accounts macOS already holds; there is no token in this repository's reach.
  • Nothing leaves your machine. No network calls at all unless you configure a CalDAV source. Event titles are read locally and never written anywhere.
  • The UI binds to 127.0.0.1 and has nothing else. No authentication, because the loopback binding is the boundary. Do not put it behind a tunnel or a reverse proxy.
  • The UI cannot write to a calendar. It plans and reports; only the agent and the CLI write. A stray request to it can, at worst, read your calendars.
  • Config is validated before it is saved, so a typo cannot silently break the agent.
  • config.json and state.sqlite are git-ignored: your calendar identifiers and sync state stay out of version control.

Development

npm test          # 64 tests, no network, no calendar access
npm run typecheck
npm run calendars # list what EventKit can see

Tests run on Node's type stripping, which does not support TypeScript parameter properties β€” write constructors out longhand.

Rebuilding the helper resets its permission. It is ad-hoc signed, so every rebuild is a new code identity and macOS treats it as a different program. If the prompt does not reappear β€” which happens when launchd starts it rather than your terminal β€” remove the stale entry under System Settings β†’ Privacy & Security β†’ Calendars and run npm run calendars once from a terminal.

A calendar missing from npm run calendars is one the account is not syncing to the Mac. For Google, tick it at calendar.google.com/calendar/syncselect. For Exchange, check it is enabled in Calendar.app's sidebar.

Project Structure

native/eventkit-bridge.swift   Calendar access, JSON over stdin/stdout
src/core/                      Parsing, time, clipping, planning, reconciling, sync
src/sources/                   eventkit, caldav
src/targets/                   eventkit
src/state/                     SQLite store and schema
src/native/bridge.ts           Client for the Swift helper
src/ui/                        Loopback server, launchd control, the page
src/platform/node.ts           CLI entry point
tools/                         calendars, install-agent, ui
test/                          64 tests, no I/O

License

MIT Β© Thomas Kleinert

About

πŸ—“ Mirror personal appointments into your work calendar as opaque busy blocks β€” macOS-native via EventKit, so no OAuth client, no server and no credentials to hold

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages