When you work across multiple organizations, your calendars get fragmented. One organization uses Office 365, another uses Google Workspace. Appointments end up in both calendars, and every time you schedule a meeting, you have to check both to find available times.
It was tedious.
At first, I thought "well, I'll just check both." But then I double-booked twice without noticing, and I realized this wasn't sustainable.
Researching Existing Tools
First, I googled. "Google Calendar Outlook sync" and similar queries.
What came up were international SaaS products: OneCal, CalendarBridge, SyncGene, and others. They cost around $5-10/month and claim to offer "Google ⇄ Office 365 bidirectional sync." Reviews were decent.
I tried to use OneCal.
But I couldn't. The Office 365 OAuth screen appeared with "Admin approval required" and stopped there.
Ah, I see. The organization restricts external app integrations. Should I submit a request to the admin saying "Please approve this calendar sync tool"? No, that's not realistic. The approval process costs more than the problem.
CalendarBridge was the same. SyncGene was the same.
With OAuth restrictions as a barrier, external tools weren't an option.
Build It Myself? → Put on Hold
Next, I considered "should I build it myself?" Maybe with Lovable I could make something in a month or so.
But when I asked ChatGPT, it said "bidirectional sync is a minefield."
- Infinite loops (A→B→A→B...)
- Identifying the same event (Google ID and Outlook ID are different)
- Conflict resolution (what if both are edited simultaneously?)
- Handling deletions (what if one side deletes?)
Yeah, fair point. If I tried to do this properly, I'd basically be recreating OneCal.
I gave up on building it myself.
I reorganized what I actually wanted to achieve.
"I want to schedule appointments while viewing both calendars without double-booking."
I don't need perfect bidirectional sync. I just need to prevent booking conflicts.
Google → Outlook (This Was Easy)
I decided to try one direction first. Make Google events visible in Outlook.
Outlook's web app has an "Add calendar" → "From Internet" feature. You get the ICS URL (subscription URL) from Google Calendar and paste it in.
Google Calendar's ICS URL can be found in Calendar settings → "Integrate calendar" → "Secret address in iCal format."
I added this to Outlook, and it just appeared.
Done in 5 minutes.
On the Outlook side, free/busy calculations include Google-originated events. This means when you try to add a new appointment in Outlook, the "this time is busy" indicator works.
One direction solved.
Outlook → Google (This Is Where It Got Messy)
Let's do the reverse. View Outlook events in Google.
Should be the same approach. "Publish" the Outlook calendar, get the ICS URL, add it to Google Calendar via URL.
Done. I can see Outlook events in Google.
But this doesn't prevent double-booking.
When I tried to add a new event in Google, time slots with Outlook-originated events were shown as "available." Google's event creation UI doesn't tell you "this time is busy."
I looked into it, and apparently Google Calendar's URL subscription (ICS display) is "view-only" and isn't used for free/busy calculations. You can see it, but you can't control it.
Problem.
The "Block Event" Approach with GAS
At this point, I thought "what if I just create actual events on the Google side?"
Read event information from Outlook's ICS and sync it to a dedicated Google calendar as "blocks." No need to sync titles or details. Just fill time slots as "Busy."
This would:
- Avoid OAuth restrictions (ICS is a public URL)
- Enable double-booking prevention (actual events are used in free/busy calculations)
- Minimize information leakage risk (don't transfer titles)
I could write this in Google Apps Script (GAS).
[📦 商品リンク: moshimo-book-PhRut]
First Implementation: Parsing ICS
ICS specification is RFC5545. Event information is between BEGIN:VEVENT and END:VEVENT. I only needed UID, DTSTART, DTEND.
Initially, I parsed with simple regex.
const m = val.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/);
Assuming YYYYMMDDTHHMMSS format. With seconds.
But when I looked at the actual ICS from Outlook, I found lines like this:
DTSTART;TZID=Tokyo Standard Time:20260109T143000
No seconds. HHMM.
Parse failed, and those events were skipped.
Fixed the regex. Made it handle both with and without seconds.
// YYYYMMDDTHHMM(Z)?
let m = val.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(Z)?$/);
if (m) { ... }
// YYYYMMDDTHHMMSS(Z)?
m = val.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/);
if (m) { ... }
This got it working.
But the next problem was waiting.
Recurring Events Disappearing
For recurring meetings like weekly standups, only "some" were being synced.
Logs showed created=27 or so, but the actual calendar only had about 10 items.
Why?
Investigation revealed that for recurring events, Outlook's ICS reuses the same UID across multiple events.
For example:
BEGIN:VEVENT
UID:040000008200E00074C5B7101A82E008...
DTSTART:20251223T130000
DTEND:20251223T140000
RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=TU
END:VEVENT
This is the master event. A rule saying "biweekly Tuesday 13:00-14:00."
And if a specific week has a time change, you get something like this:
BEGIN:VEVENT
UID:040000008200E00074C5B7101A82E008... (same UID)
RECURRENCE-ID;TZID=Tokyo Standard Time:20260106T130000
DTSTART;TZID=Tokyo Standard Time:20260109T143000
DTEND;TZID=Tokyo Standard Time:20260109T153000
END:VEVENT
It has RECURRENCE-ID. This means "the 1/6 occurrence is rescheduled to 1/9 at 14:30."
But the initial implementation used only UID as the key, so events with the same UID kept overwriting each other, leaving only the last one.
Redesigned the key. Combine UID + start time.
const instanceId = e.recurrenceIdRaw || formatIcsLocalKey_(start);
const key = `${e.uid}|${instanceId}`;
Now each occurrence is recognized as a separate event.
Duplicate Events Problem
Still, some events appeared "side by side" as duplicates.
Same time, same title "Block" appearing twice.
Found the cause: Outlook was outputting the same instance twice.
- Master event (has RRULE, no RECURRENCE-ID)
- Individual instance (has RECURRENCE-ID)
Both point to "1/23 13:00-14:00" but exist as separate VEVENT entries in the ICS.
Why is it designed this way?
Added normalization. When multiple events have the same start time, prioritize the one with RECURRENCE-ID (exception instance) and ignore the master.
function normalizeEvents_(events) {
const map = new Map();
for (const e of events) {
const k = `${e.uid}|${formatIcsLocalKey_(e.dtstart)}`;
if (!map.has(k)) {
map.set(k, e);
continue;
}
const existing = map.get(k);
// Prioritize the one with RECURRENCE-ID
if (!existing.recurrenceIdRaw && e.recurrenceIdRaw) {
map.set(k, e);
}
}
return Array.from(map.values());
}
Finally, duplicates were eliminated.
But by this point, I was getting tired.
Accepting Trade-offs
I stopped to reconsider.
What did I actually want? "To prevent double-booking."
I don't need to perfectly replicate Outlook events in Google. No titles, no details, no attendees needed. I just need to know time slots are "occupied."
Then even if there are multiple events in the same time slot, one block is enough.
I accepted the trade-offs.
- Events with the same start/end times are consolidated into one block
- Title is fixed as "Block"
- Details are empty (prevents information leakage)
- The block calendar is "disposable"
This simplified the implementation.
Keys can be just start time + end time.
const eventKey = `${startTime.getTime()}_${endTime.getTime()}`;
Events at the same time get the same key. They get overwritten. But that's fine.
Final Architecture
The final setup:
Source: Office 365 calendar imported via URL subscription to Google Calendar
Target: Dedicated "Block" Google Calendar
Sync frequency: Every 4 hours (calendars don't update that frequently)
Sync method: Differential sync
I chose differential sync for efficiency. Deleting everything and recreating would balloon API calls as event counts grow. Currently around 100 events, but thinking ahead, differential made sense.
Differential sync requires maintaining a mapping (source event key → target event ID). Stored as JSON in Script Properties.
const map = JSON.parse(props.getProperty(PROP_MAP) || '{}');
Sync logic:
- Get events from source calendar
- Calculate each event's key (start+end)
- If in map, update; otherwise, create new
- Keys not found this time get deleted
About 200 lines of code.
Using It in Practice
Started using it today.
Only been a few hours, but no problems so far. When I try to add an event in Google, time slots with Outlook events show as "busy."
Syncs within 4 hours. Not real-time, but acceptable.
The block calendar has a dark gray color in the left calendar list. It's immediately clear "don't touch this one."
Trigger frequency of 4 hours feels right for now. Might change later, but this works for now.
Reflection: Asymmetry and Trade-offs
Google → Outlook took 5 minutes. Just add an ICS URL.
Outlook → Google took days. ICS parsing, recurring events, deduplication, key design, differential sync.
Reasons for the technical asymmetry:
- Outlook's ICS output quirks (no seconds, RECURRENCE-ID, master+exception dual representation)
- Google's ICS subscription behavior (not used for free/busy calculations)
- OAuth restrictions as an organizational constraint
Initially, I wanted "perfect sync." But partway through, it became "I just need to know what's blocked."
There are trade-offs:
- Can't see titles (but that's fine, prevents information leakage)
- Not real-time (but 4 hours is acceptable)
- Multiple events at the same time become one block (but the goal is still achieved)
A perfect solution probably doesn't exist. Accept constraints, identify what's necessary and sufficient, and shape it into something that works in practice.
"I just need to know what's blocked" — that acceptance was the key decision.
Related Resources
[📦 商品リンク: moshimo-book-R44mY]
[📦 商品リンク: moshimo-book-tJG6Z]