TreeMap
A free, open-source disk-space visualizer. Squarified treemap, duplicate hunting, disk-full forecasting, and cleanup that only ever moves files to the Trash.
A copy of a calendar you can see but can't share. One direction, nothing leaves your device. iPhone, iPad and Mac.
ProductivitySwift · HTMLGitHub @mattbaylorOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Mirror any Mac calendar into any other — one-way, idempotent, with a menu-bar UI
Taken from the repository README
Loading the full README. The saved excerpt is shown below.
One-way mirror between any two of your calendars — on Mac, iPhone and iPad.
Point a source calendar at a destination calendar and cal-mirror keeps the
destination in sync: idempotent, one-directional, and scheduled. No servers, no
credentials — it works entirely through the calendars already on your device, and
lets the OS sync the destination up to iCloud / CalDAV / Exchange for you.Read the full README ↗One-way mirror between any two of your calendars — on Mac, iPhone and iPad.
Point a source calendar at a destination calendar and cal-mirror keeps the destination in sync: idempotent, one-directional, and scheduled. No servers, no credentials — it works entirely through the calendars already on your device, and lets the OS sync the destination up to iCloud / CalDAV / Exchange for you.
Some calendars you can see but not reshare — a subscribed work calendar, a read-only team feed, an account you don't control. cal-mirror makes an editable, re-shareable copy on a calendar you do control. Because the copy lives in a normal account, macOS then pushes it wherever that account syncs.
| Built from | Runs as | |
|---|---|---|
Standalone macOS (standalone/) | ./standalone/install.sh — free, MIT | A launchd daemon + CalMirrorMenu.app in the menu bar |
App Store apps (apple/) | Xcode / release-appstore.sh — $2.99 universal | Sandboxed apps on iPhone, iPad and Mac; no LaunchAgent |
Two names, one product. The App Store listing is Calendar Mirror, and that is the name the apps show in their own UI.
cal-mirrorstays the name of this repository, the standalonecal-mirror.appandCalMirrorMenu.appbundles, the bundle identifiers,~/.local/cal-mirror/, and thex-calmirror:marker — none of which are user-facing, and all of which would break existing installs if renamed.
Both run the same sync engine, CalMirrorKit —
standalone/main.swift is a thin daemon wrapper around it. Everything below about configuration,
projection and tags applies to both, since they read the same config.json shape. How to build,
run and release each product is in its own directory.
standalone/ | The free macOS pair — sources, scripts, LaunchAgents, and how to install and release them |
apple/ | CalMirrorKit (the shared engine), the shared SwiftUI, the two App Store shells, and the tools that drive the request-page UI in a simulator |
appstore/ | The App Store listing: its text, its screenshots, the tools that generate and check them, and the scripts that talk to App Store Connect |
askwhen/ | AskWhen.me — the request-page service, its web app, its own infrastructure, and its design record |
site/ | The marketing website, calendarmirror.com — the pages themselves, in English and German |
infra/ | How the website and the DC edge are served |
notes/ | Working notes: about now, and stale on purpose |
include/reject tag rule — e.g. pull just your #ref events into a shared availability calendar.flowchart LR
subgraph Mac["Your Mac (EventKit)"]
SRC["Source calendar<br/>(read)"]
DST["Destination calendar<br/>(write)"]
end
ENG["cal-mirror.app<br/>(engine, LaunchAgent every N min)"]
UI["CalMirrorMenu.app<br/>(menu-bar UI)"]
CFG[("config.json")]
ST[("status.json")]
SRC -- occurrences --> ENG
ENG -- tagged copies --> DST
CFG -- pairs & settings --> ENG
ENG -- run results --> ST
UI -- writes --> CFG
ST -- reads --> UI
DST -. macOS syncs .-> Cloud["iCloud / CalDAV / Exchange"]
The engine runs on a schedule: for each enabled pair it reads the source over
a rolling window, expands occurrences, and upserts them into the destination
tagged with a per-mirror marker (x-calmirror:<id>~<key>) so its delete-sweep
only ever touches its own copies. The UI is a thin window onto status.json
and an editor for config.json — it never touches the calendar itself beyond
listing them for the pickers.
Code lives in the checkout; runtime data lives in ~/.local/cal-mirror/
(config.json, status.json, logs). Copy the example to start:
{
"paused": false,
"intervalSeconds": 900, // engine schedule
"mirrors": [
{
"id": "work", // stable, unique
"name": "Work → Personal",
"source": { "title": "Work", "account": "[email protected]" },
"dest": { "title": "Work (Copy)", "account": "[email protected]" },
"enabled": true,
"showHeartbeat": true, // warn in-calendar if this mirror breaks
"windowPastDays": 30,
"windowFutureDays": 365
}
]
}
account is optional (matching falls back to the calendar title). Start/end,
all-day, and timezone are always copied — they define the block. Everything else
is governed by projection (below).
projection)Each mirror decides, field by field, how much of the source event to replicate.
An absent projection block keeps the historical behavior (real title +
location, no notes/alarms). The menu bar exposes this as three presets plus
Custom; in config.json it's:
"projection": {
"title": "copy", // "copy" | "redact"
"titleText": "Busy", // shown when title = "redact"
"titlePrefix": "", // prepended to the copy's title, e.g. "[Work]"
"sourceLink": false, // append the source event's link to the copy's notes
"location": true,
"notes": "none", // "none" | "tags" | "full" (legacy true/false still read)
"alarms": false, // off avoids duplicate notifications on the dest
"availability": "source" // "source" | "busy" (force the block to read busy)
}
| Preset | Meaning |
|---|---|
| Copy details | Real title + location, no notes (the default). |
| Full copy | Adds the whole note. As complete as EventKit allows. |
| Busy only | Title → titleText, drops location/notes, forces busy. |
| Custom | Anything else — including notes: "tags", below. |
"Copy details" does not copy notes. If your
#tagsaren't reaching the destination, this is almost always why:copyNotesTagsonly has an effect once notes actually cross over. Pick Full copy, or Custom → Notes → Tags only.
titlePrefix is prepended to whatever title the copy ends up with — including
a redacted one — so a busy-only mirror can still say where a block came from
([Work] Busy). Setting one puts the mirror into Custom, since a prefix
isn't part of any preset. Changing it rewrites existing copies in place rather
than duplicating them: the marker key is untouched, so the reconciler matches
each copy exactly and updates its title.
sourceLink appends the source event's own link — a meeting URL, usually — to
the copy's notes, so a mirrored block isn't a dead end you can see but not act
on. Only http/https is carried: an event's URL field can hold a message:
reference to the invitation mail or a local file, and neither resolves for
anyone the destination is shared with. A link already present in the notes isn't
repeated, which matters because invitations routinely put it in both places.
#private suppresses it along with everything else — the point of a redacted
block is that it says nothing about the meeting, and a meeting link says plenty.
Two EventKit limits worth knowing: attendees can't be replicated (no API to set participants), and the copy's URL field carries cal-mirror's own per-copy marker, which is why the source's link goes in the notes rather than where you would expect it.
Tags live in a source event's notes (not the title). A tag is #
followed by every non-whitespace character up to the next space — any ASCII is
allowed inside, so #ref-cal and #skip_2 are each one tag. A tag is only
recognized where the # starts the notes or follows whitespace, and an event may
carry several: #nomirror #ref-cal. Matching is case-insensitive and by whole
token, so #ref and #ref-cal are different tags.
The three control tags override a mirror's projection for one event:
| Tag | Effect |
|---|---|
#nomirror | Skip this event entirely — never copied, and any prior copy is removed. |
#private | Copy as a redacted busy block, even on a full-copy mirror. |
#public | Copy in full, even on a Busy-only mirror. |
Control tags are always stripped from the copy.
filters)Notes tags only work on events you author. For a calendar you don't control —
a subscribed work feed, an assigned-games feed — select on the event's own
properties instead. Every key names what it skips, so an absent filters
block copies everything, exactly as before:
"filters": {
"declined": true, // meetings you answered No to
"unanswered": false, // invitations you haven't replied to
"canceled": true, // events the organizer canceled
"allDay": false,
"free": false, // events whose availability is "free"
"shorterThanMinutes": 0, // 0 = off
"longerThanMinutes": 0, // 0 = off
"title": { "mode": "reject", "patterns": ["Lunch", "Focus time"] },
"hours": { "mode": "keep", "start": "08:00", "end": "18:00", "days": [2,3,4,5,6] }
}
title.mode is include or reject; patterns are case-insensitive substrings
(not regex) matched anywhere in the title. hours.mode is keep or drop,
days uses Calendar weekday numbers (1 = Sunday, empty = every day), and a
window may wrap midnight — "start": "22:00", "end": "06:00" is a valid
overnight window.
Three behaviors worth knowing:
8:00–18:00
keep-window, because part of it falls inside. Containment would surprise
anyone whose meeting starts before the window opens.hours — they have no time of day.
Use the allDay key to drop them; otherwise a work-hours mirror would
silently eat every birthday and holiday.unanswered never drops a block you made for yourself.Filters run after #nomirror and before tagFilter, and a filter drop is
final: #public and #private govern how much of an event crosses over,
not whether it does, so neither rescues a filtered-out event.
Both
declinedandcanceleddefault to off, so upgrading changes nothing. New mirrors created in the UI turn them on — mirroring a meeting you declined as a solid busy block is rarely what you want.
tagFilter)Give a mirror a tagFilter to copy only some source events, keyed off the
notes tags above. A mirror runs in one mode — include or reject,
never both:
"tagFilter": { "mode": "include", "tags": ["#ref"] } // copy ONLY events whose notes carry #ref
"tagFilter": { "mode": "reject", "tags": ["#skip"] } // copy everything EXCEPT events carrying #skip
No tagFilter (or an empty tags list) copies everything — the historical
behavior. This is the referee-availability play: tag the events you're free to
ref with #ref in their notes, point an include/#ref mirror with a Busy-only
projection at a calendar you share, and only those blocks cross over.
"notes": "tags")Sometimes you want a selection tag to reach the destination but not the note it sits in — a personal calendar feeding a shared one, say. Set the mirror's projection to:
"projection": { "notes": "tags" } // Custom → Notes → "Tags only"
The copy's notes become just the event's tags on one line, in source order, and
the prose never crosses. Control tags (#nomirror/#private/#public) and
#-… tags are dropped; #+… is kept verbatim. An event with no surviving tag
gets no notes at all. copyNotesTags is not consulted in this mode — choosing
it is the choice to carry tags.
copyNotesTags)When a mirror projects the whole note ("notes": "full"), your other #tags are
stripped from the copied notes by default so they don't leak. Set
"copyNotesTags": true on the mirror to keep them. Two per-tag overrides beat the
mirror setting either way — the +/- is part of the tag (so it matters for
matching too):
| Tag form | In the copied notes |
|---|---|
#-foo | Always removed, whatever copyNotesTags says. |
#+foo | Always kept (verbatim, + included), whatever copyNotesTags says. |
Tags moved to notes in v1.2. Earlier versions read
#nomirror/#private/#publicfrom the event title; those are no longer honored there. Move any such tags into the event's notes.
showHeartbeat puts a warning in the destination calendar when a mirror
stops syncing:
⚠︎ Calendar Mirror — Work Mirror: Source not found · last synced Tue 26 Aug
An all-day event on today, cleared automatically the moment the mirror syncs cleanly again. Healthy is silent — nothing is written while things work.
That inversion is deliberate. Destinations are calendars you deliberately share, so a daily "still working" marker is an artifact other people see, and it says nothing you'd act on. A warning is rare, actionable, and reaches you on your phone without opening anything.
Two limits worth knowing. A read-only or missing destination can't be written to, so those two failures are reported in the menu bar and the log but not in the calendar — there is nowhere to put them. And a dead engine can't warn about itself; launchd restarts it, and the menu-bar icon covers that case when you're back at the Mac.
Before v1.4 this wrote an all-day "last synced" banner on every successful cycle. Those are removed automatically on the first sync after upgrading.
By default the engine syncs on a schedule. Set "realtime": true at the top
level of config.json and it syncs when your calendars actually change —
usually within seconds — using EventKit's change notifications:
{
"realtime": true,
"intervalSeconds": 900, // ignored while realtime is on; the floor is 5 min
"mirrors": [ /* … */ ]
}
The schedule never goes away. Change notifications are best-effort: subscribed ICS feeds refresh on their own without announcing it, and sleep drops notifications outright. Realtime pins the floor to 5 minutes as a backstop — without one, a missed notification would mean a mirror silently stale forever. The floor pass is also the healing pass: it always does a full reconcile, so a copy someone deleted by hand comes back within five minutes.
Three things keep it from chasing its own tail. Changes are debounced, so one CalDAV pull delivering hundreds of events becomes one sync. Changes arriving just after our own writes are treated as our echo and ignored. And since v1.4 the in-calendar banner only writes when a mirror breaks, so a healthy cycle writes nothing at all and cannot re-trigger itself.
A change-driven cycle skips mirrors whose source hasn't moved, comparing a cheap digest instead of re-walking every destination. Without that, one edited work event would make all your mirrors pay full price.
Realtime is a global setting, not per-mirror. EventKit reports that the store changed, never which calendar, so a per-mirror switch would be a promise the API can't keep.
New installs get realtime on; upgrading an existing config.json leaves it off
until you turn it on, so nothing about your sync changes underneath you.
Two mirrors can share one destination — that is the point of per-mirror marker tags. But if the same event reaches both (a work feed and an on-call feed that overlap), you get two identical blocks stacked on a calendar you probably share, and neither mirror cleans the other's up because each only ever touches its own copies.
{ "dedupeDestinations": true } // top level, off by default
With it on, the first mirror in config order writes the block and the rest skip it. Config order decides, so the result doesn't depend on which mirror ran first and doesn't flap between cycles. If the owning mirror is disabled or deleted, the next one claims it on the following cycle.
Mirrors that redact differently never collapse. The claim is keyed on the projected title, so a busy-only copy ("Busy") and a full copy ("Standup") are different blocks and both survive. Only genuinely identical blocks collapse — including two different meetings that both project to "Busy" at the same time, which is what you want on a shared availability calendar.
Off by default: it changes what ends up in a calendar, and upgrading should never
do that on its own. The log says deduped×N when it skips anything.
Two products, two paths. Both live at the same version — bump
standalone/Info.plist + standalone/Info-ui.plist and both apple/*/project.yml
(MARKETING_VERSION / CURRENT_PROJECT_VERSION) together.
The standalone side — Developer ID, notarize, staple, publish — is in
standalone/README.md. This is the App Store side.
[!IMPORTANT] App Store builds must come from CI. Every binary records its build machine in
BuildMachineOSBuild, and Apple rejects anything built on a beta OS:ITMS-90301: Apple is not currently accepting applications built with this version of the OS.If the maintainer's Mac is running a macOS beta, nothing it archives can ship.
Run the Release to App Store Connect workflow
(.github/workflows/release.yml) from the
Actions tab. It picks the newest non-beta Xcode, fails outright on a beta
runner OS, then archives, validates, and — only if you tick upload —
delivers to App Store Connect. Leaving upload unticked builds and validates
without spending a build number.
It drives release-appstore.sh, which you can also run
locally for a build-and-validate check. The script's header documents the
signing rules the hard way: archives are signed for distribution at archive
time using manually managed profiles, never archived unsigned and re-signed on
export (that silently drops the macOS sandbox entitlement), and the .pkg needs
a second certificate, 3rd Party Mac Developer Installer.
CI needs nine repository secrets — the distribution and installer certificates
(.p12 + password each), both provisioning profiles, and an App Store Connect
API key (ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8).
Note that altool --validate-app returns VERIFY SUCCEEDED on binaries Apple
later rejects, so a clean validate is necessary, not sufficient.
MIT © Matt Baylor