Fixing a bulk import into the wrong Google calendar
I mistakenly imported a 104-fixture World Cup .ics into the wrong Google Calendar. Here is everything Google Calendar offers you to rectify this situation:
- No “undo import”.
- No “select all and delete” in the UI.
- No multi-event drag onto another calendar.
- A “Change calendar” dropdown on each event, individually, 104 times.
So, scripting it is. Google Apps Script’s CalendarApp API gives you reads, writes, deletes, and a six-minute execution budget per run, which is plenty for 104 events. The plan: read the fixtures from the wrong calendar, recreate them in the empty “Sport” calendar I’d created, delete the originals.
The whole thing came down to filtering by event title. Every group-stage fixture starts with a country flag emoji, but a ^flag regex wouldn’t do it on its own as there are the knockouts to consider too (which don’t start with a flag since it is not known which teams will get through to the knockouts). The way I approach this typically is to dry run with logging which makes it easier to identify matching vs non-matching entries, and then only move to execution once I have all the matches and don’t have any false matches.
Round 1: country flag emojis aren’t one character
Country flags in Unicode are pairs of regional indicator letters, one per ISO country code letter. So 🇧🇷 is [U+1F1E7][U+1F1F7] = B + R = Brazil. There is no “any flag” code point.
So startsWith('🇧🇷') would only catch Brazil fixtures. To match any country flag, you have to hit the regional-indicator range:
titleRegex: /^[\u{1F1E6}-\u{1F1FF}]{2}/u,
Then add in alternatives for the knockout-stage titles like “Winner Group J vs Runner-up Group H”:
titleRegex: /^([\u{1F1E6}-\u{1F1FF}]{2}|Winner|Runner|Loser)/u,
Dry run reported 68 of 104. Even the group-stage count (72) was short. Something else was wrong.
Round 2: hidden whitespace from the import
The knockout phase calendar titles had a leading non-breaking space or other whitespace bug, courtesy of whatever generated the source .ics. The ^ anchor was hitting the whitespace, not the flag or text we wanted to match.
Cheap fix is to allow leading whitespace, though this could be a source of issues with a different dataset:
titleRegex: /^\s*([\u{1F1E6}-\u{1F1FF}]{2}|Winner|Runner|Loser)/u,
For diagnosis: when a regex you trust says “no match” against a string you trust, log the failures with something like JSON.stringify(title.substring(0, 30)). Hidden characters that look identical to a normal space in the calendar UI then become visible escape sequences, " Winner..." becomes "\u00a0Winner..." for instance.
So now we’re up to 100 matched… But the World Cup has 104 fixtures! Four fixtures still aren’t being matched.
Round 3: England and Scotland are constituent countries
England and Scotland are constituent countries within a sovereign country called the UK. The four missing fixtures all involved England or Scotland. 🏴 and 🏴 are flags in the sense that they render as little rectangles with crosses on them. Underneath, they have nothing in common with the regional-indicator pair that produces flags like 🇬🇧.
Subdivision flags use the black flag code point U+1F3F4 as a base, then tag characters in the U+E0000 range that spell out the subdivision code (gb-eng, gb-sct, gb-wls), then a cancel tag U+E007F. So 🏴 is a 7-codepoint sequence, none of which are regional indicators.
The regex only needs to add an alternate for the leading 🏴, since that uniquely starts subdivision flags:
titleRegex: /^\s*([\u{1F1E6}-\u{1F1FF}]{2}|\u{1F3F4}|Winner|Runner|Loser)/u,
Dry run: 104 matched. Switched off dry-run. Live execution successful.
The whole script
// CONFIG (edit these)const CONFIG = { sourceCalendarId: 'SOURCE_CALENDAR_ID', targetCalendarId: 'TARGET_CALENDAR_ID', startDate: '2026-06-01', endDate: '2026-07-31', titleRegex: /^\s*([\u{1F1E6}-\u{1F1FF}]{2}|\u{1F3F4}|Winner|Runner|Loser)/u, dryRun: true // true = preview, false = actually move};function run() { const source = CalendarApp.getCalendarById(CONFIG.sourceCalendarId); const target = CalendarApp.getCalendarById(CONFIG.targetCalendarId); if (!source) throw new Error('Source calendar not found: ' + CONFIG.sourceCalendarId); if (!target) throw new Error('Target calendar not found: ' + CONFIG.targetCalendarId); const events = source.getEvents(new Date(CONFIG.startDate), new Date(CONFIG.endDate)); let matched = 0; events.forEach(e => { if (!CONFIG.titleRegex.test(e.getTitle())) return; matched++; Logger.log((CONFIG.dryRun ? '[DRY] ' : '[MOVE] ') + e.getTitle()); if (!CONFIG.dryRun) { target.createEvent(e.getTitle(), e.getStartTime(), e.getEndTime(), { description: e.getDescription(), location: e.getLocation() }); e.deleteEvent(); } }); Logger.log(`${CONFIG.dryRun ? 'Would move' : 'Moved'} ${matched} events`);}
If you’re looking to use it or build on it: Paste into script.google.com, edit the config block, run with dryRun: true to confirm the count and titles (you’ll need to give some permissions to the script somewhere in there), flip to false when you’re ready to execute for real. The script does a hard check on both calendar IDs up front so a typo in the target ID surfaces before any source events get deleted. And that’s it.