Dates arrive in the worst possible formats. The school sends a two-page PDF letter with six dates buried in paragraphs. The futsal league posts a photo of a fixture list. The RT group chat announces “kerja bakti Minggu depan jam 7” and expects you to remember. Your dentist’s reminder is a screenshot.
Copying each one into a calendar by hand takes about a minute per event once you include opening the app, typing the title, picking the date and time, and adding the location. A term letter with 12 dates is 10–15 minutes. Skip it, and you find out about the field trip fee the morning it’s due.
In this guide you’ll build a small Python tool that:
- takes text files, photos, screenshots or PDFs of announcements
- sends them to Google’s Gemini API and gets back structured JSON: title, date, start/end time, location, what to bring
- checks every event against the source: wrong weekday, date in the past, end before start, invented events
- writes a standard
.icsfile that imports into Google Calendar, Apple Calendar or Outlook
The AI cost is roughly $0.001–$0.003 per document. Build time is about 45 minutes. This one is a quality-of-life tool first. There’s a small money angle at the end, but don’t build it for that.
What you need
| Item | Cost | Notes |
|---|---|---|
| Python 3.10+ | $0 | Laptop, mini PC or VPS. Uses zoneinfo from the standard library |
| Gemini API key | $0 to start | aistudio.google.com → “Get API key” |
google-genai, pydantic, icalendar, pillow | $0 | pip install |
| A calendar app | $0 | Google Calendar (web), Apple Calendar, or Outlook.com |
tzdata (Windows only) | $0 | pip install tzdata. Windows has no built-in timezone database for Python |
Pricing as of October 2026 (check ai.google.dev/pricing before relying on this, because Google changes model names and prices often):
- Gemini 2.5 Flash (paid tier): $0.30 per 1M input tokens (text/image/PDF), $2.50 per 1M output tokens
- Gemini 2.5 Flash-Lite (paid tier): $0.10 per 1M input, $0.40 per 1M output
- Free tier: rate-limited, but enough for a household. Read the privacy note in the Reality check first.
gemini-2.5-flash is still listed on the Gemini Developer API (the AI Studio key used here) as a stable model with no announced shutdown date, though Google recommends newer models for new projects. On Vertex AI / Google Cloud the 2.5 models are being shut down in October 2026, so this guide assumes an AI Studio key. Newer models such as Gemini 3.8 Flash cost more ($0.75 input / $3.75 output per 1M tokens through 2026, per Google’s pricing page) and still work fine at these volumes. If you switch, change the model name and the thinking setting (see Reality check).
Is there a no-code option?
Yes, partly. The Gemini app, with Google Calendar connected, can create an event when you upload a photo and ask it to. For one flyer now and then, try that first. Availability depends on your account type and region. The script is worth building when you want to do many documents in one batch, get automatic checks before anything reaches your calendar, put the events into a separate calendar you can delete in one click, or use a calendar that isn’t Google’s.
How the token maths works
- Pasted text: a typical school letter is 300–600 words, about 500–900 tokens. The prompt adds about 350.
- PDF: Gemini counts each PDF page as 258 tokens, so a 2-page letter is about 520 tokens plus the prompt.
- Photo or screenshot: resized to 1536 px on the long side, roughly 1,000–2,000 tokens (258 tokens per 768×768 tile).
- Output: about 80–120 tokens per event in JSON.
A 2-page PDF with 8 events on Gemini 2.5 Flash:
- Input: ~900 tokens × $0.30/1M = $0.00027
- Output: ~900 tokens × $2.50/1M = $0.00225
- Total: about $0.0025
Output is the bigger cost here, because each event is several fields. Gemini 2.5 Flash also “thinks” by default and bills thinking as output. Date extraction doesn’t need it, so the script sets thinking_budget=0.
Step 1: Set up the project
mkdir to-cal && cd to-cal
python3 -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install google-genai pydantic icalendar pillow # Windows: add tzdata
export GEMINI_API_KEY="paste-your-key-here" # PowerShell: $env:GEMINI_API_KEY="..."
export CAL_TZ="Asia/Jakarta" # or Asia/Makassar, Asia/Jayapura, Europe/London...
Set CAL_TZ to your own timezone. Indonesia alone has three (WIB, WITA, WIT), and a wrong timezone moves every timed event by an hour or two.
Step 2: Decide what you want back
The key trick, same as in our receipts guide, is structured output: you give Gemini a Pydantic schema and the API returns JSON that matches it. Two fields do the safety work:
weekday_written: the weekday as the source says it (“Jumat”). Notices almost always print the weekday next to the date. If the two disagree, either the model misread the date or the notice has a typo. Either way, you want to know.source_quote: the exact sentence the event came from. For text input, the script checks that this sentence really exists in the file. That catches events the model made up.
The prompt also gives the model today’s date. Without it, “next Friday”, “besok” or a date with no year can’t be resolved correctly.
Step 3: The script
Save this as to_cal.py:
import hashlib, os, sys
from datetime import date, datetime, timedelta, timezone
from io import BytesIO
from pathlib import Path
from zoneinfo import ZoneInfo
from google import genai
from google.genai import types
from icalendar import Calendar, Event
from PIL import Image, ImageOps
from pydantic import BaseModel
MODEL = os.environ.get("CAL_MODEL", "gemini-2.5-flash")
TZ = ZoneInfo(os.environ.get("CAL_TZ", "Asia/Jakarta"))
WEEKDAYS = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
class Item(BaseModel):
title: str
date: str # YYYY-MM-DD
weekday_written: str # weekday as the source states it, "" if none
start_time: str # HH:MM 24h, "" if all-day / not given
end_time: str # HH:MM 24h, "" if not given
location: str
notes: str # what to bring, pay, or wear
source_quote: str # the exact sentence the event came from
class Extraction(BaseModel):
events: list[Item]
def prompt() -> str:
today = datetime.now(TZ).date()
return f"""Today is {today.isoformat()} ({today.strftime('%A')}). Timezone: {TZ.key}.
Extract every dated event, deadline, or appointment from this document.
Rules:
- One entry per day. A Monday-Friday exam week = 5 entries.
- date as YYYY-MM-DD. If the year is missing, use the next occurrence after today.
- Resolve relative dates ("next Friday", "besok", "minggu depan") from today's date.
- weekday_written: the weekday word in the source, translated to English
(Senin=monday, Selasa=tuesday, Rabu=wednesday, Kamis=thursday, Jumat=friday,
Sabtu=saturday, Minggu=sunday). Use "" if the source names no weekday for that date.
For a range like "Senin-Jumat, 2-6 November", give each day its own weekday.
- Times in 24h HH:MM. "pukul 07.30" -> 07:30, "3 sore" -> 15:00. "" if not stated.
- Deadlines ("paling lambat", "due by") become events titled "DEADLINE: ...".
- notes: anything the reader must bring, pay, or wear. Keep it short.
- source_quote: copy the original sentence word for word. Never invent events.
"""
def load_part(path: Path) -> types.Part:
ext = path.suffix.lower()
if ext in {".txt", ".md"}:
return types.Part.from_text(text=path.read_text(encoding="utf-8"))
if ext == ".pdf":
return types.Part.from_bytes(data=path.read_bytes(), mime_type="application/pdf")
img = ImageOps.exif_transpose(Image.open(path)).convert("RGB")
img.thumbnail((1536, 1536)) # caps tokens and upload size
buf = BytesIO()
img.save(buf, format="JPEG", quality=85)
return types.Part.from_bytes(data=buf.getvalue(), mime_type="image/jpeg")
def extract(client, path: Path) -> list[Item]:
resp = client.models.generate_content(
model=MODEL,
contents=[load_part(path), prompt()],
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=Extraction,
temperature=0,
thinking_config=types.ThinkingConfig(thinking_budget=0),
),
)
if resp.parsed is None:
raise ValueError("response did not match the schema")
return resp.parsed.events
def parse_time(s: str):
return datetime.strptime(s, "%H:%M").time() if s else None
def problems(e: Item, source_text: str | None) -> list[str]:
try:
d = date.fromisoformat(e.date)
except ValueError:
return [f"bad date '{e.date}'"]
issues = []
today = datetime.now(TZ).date()
actual = WEEKDAYS[d.weekday()]
if e.weekday_written and e.weekday_written.lower() != actual:
issues.append(f"source says {e.weekday_written}, but {e.date} is a {actual}")
if d < today:
issues.append("date is in the past")
if d > today + timedelta(days=400):
issues.append("date is more than a year away")
try:
start, end = parse_time(e.start_time), parse_time(e.end_time)
except ValueError:
return issues + [f"bad time '{e.start_time}'-'{e.end_time}'"]
if start and end and end <= start:
issues.append("end time is not after start time")
if source_text is not None: # text input: the quote must really be there
norm = " ".join(source_text.split()).lower()
if " ".join(e.source_quote.split()).lower() not in norm:
issues.append("quote not found in source")
return issues
def to_vevent(e: Item, source: str) -> Event:
ev = Event()
uid = hashlib.sha256(f"{e.date}|{e.start_time}|{e.title}".encode()).hexdigest()[:20]
ev.add("uid", f"{uid}@to-cal")
ev.add("dtstamp", datetime.now(timezone.utc))
ev.add("summary", e.title)
d = date.fromisoformat(e.date)
start = parse_time(e.start_time)
if start:
dt = datetime.combine(d, start, tzinfo=TZ)
end = parse_time(e.end_time)
dt_end = datetime.combine(d, end, tzinfo=TZ) if end else dt + timedelta(hours=1)
ev.add("dtstart", dt.astimezone(timezone.utc)) # UTC: every app reads it the same
ev.add("dtend", dt_end.astimezone(timezone.utc))
else:
ev.add("dtstart", d) # all-day event
ev.add("dtend", d + timedelta(days=1)) # end date is exclusive in iCal
if e.location:
ev.add("location", e.location)
ev.add("description", f"{e.notes}\n\nSource ({source}): \"{e.source_quote}\"".strip())
return ev
def main(paths: list[str]):
client = genai.Client() # reads GEMINI_API_KEY
cal = Calendar()
cal.add("prodid", "-//to-cal//solvinc//EN")
cal.add("version", "2.0")
ok = flagged = 0
for p in map(Path, paths):
try:
items = extract(client, p)
except Exception as ex: # network, quota, unreadable file
print(f"ERROR {p.name}: {ex}", file=sys.stderr)
continue
text = p.read_text(encoding="utf-8") if p.suffix.lower() in {".txt", ".md"} else None
for e in items:
issues = problems(e, text)
when = f"{e.date} {e.start_time or 'all-day':>7}"
if issues:
flagged += 1
print(f"CHECK {when} {e.title[:40]:40} <- {'; '.join(issues)}")
continue
cal.add_component(to_vevent(e, p.name))
ok += 1
print(f"ok {when} {e.title[:40]}")
Path("events.ics").write_bytes(cal.to_ical())
print(f"\n{ok} events written to events.ics, {flagged} to check by hand")
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit("usage: python to_cal.py FILE [FILE ...]")
main(sys.argv[1:])
Step 4: Run it
Put an announcement in a text file. Here’s a realistic one, notice.txt, with the kind of typo schools really make (14 November 2026 is a Saturday, not a Friday):
Yth. Bapak/Ibu Orang Tua Siswa Kelas 4,
Kegiatan bulan Oktober-November:
1. Field trip ke Museum Geologi, Jumat, 16 Oktober 2026, berkumpul pukul 06.30, kembali sekitar 14.00. Bawa bekal dan topi.
2. Pembayaran iuran field trip Rp150.000 paling lambat Jumat, 23 Oktober.
3. PTS (Penilaian Tengah Semester) Senin-Jumat, 2-6 November 2026.
4. Pengambilan rapor PTS: Jumat, 14 November 2026 pukul 08.00-11.00 di kelas masing-masing.
Then:
python to_cal.py notice.txt
# or several at once:
python to_cal.py notice.txt flyer.jpg league-fixtures.pdf
Output looks like this:
ok 2026-10-16 06:30 Field trip: Museum Geologi
ok 2026-10-23 all-day DEADLINE: field trip fee
ok 2026-11-02 all-day PTS exam day 1
ok 2026-11-03 all-day PTS exam day 2
...
CHECK 2026-11-14 08:00 Report card pickup <- source says friday, but 2026-11-14 is a saturday
7 events written to events.ics, 1 to check by hand
Flagged events are left out of events.ics. You read the warning, check the original (or ask the teacher which day is right), and add that one by hand. That’s the point: the tool does the boring 90% and refuses to guess on the rest.
The description of every event includes the source sentence, so in three weeks, when you’re wondering “bring what?”, the answer is right there in the calendar entry.
Step 5: Import into your calendar
Make a separate calendar first. If something goes wrong, you delete one calendar instead of hunting down 15 events.
Google Calendar (import works on the web version, not the phone app):
- calendar.google.com → next to “Other calendars”, click + → Create new calendar → name it “School” or “Imported”.
- Gear icon → Settings → Import & export → Import.
- Pick
events.ics, choose the “School” calendar in the dropdown, click Import.
The events show up on your phone within a minute or so through normal sync.
Apple Calendar: on a Mac, File → Import. On iPhone, AirDrop or email the .ics to yourself, tap it, and choose Add All. You can pick the target calendar there.
Outlook.com: Calendar → Add calendar → Upload from file.
Reminders: the script doesn’t add alarms on purpose. Calendar apps handle imported alarms inconsistently, so set a default notification on the “School” calendar itself (Google: calendar settings → Event notifications / All-day event notifications). A “1 day before at 19:00” default on all-day events is what makes the deadline entries actually useful.
Step 6 (optional): Make it a habit
You don’t need cron for this. Documents come in randomly, and you want to see the CHECK lines. What works in practice:
- Keep an
inbox/folder. Whenever a notice arrives, save or screenshot it there. - Once a week (Sunday evening is popular), run
python to_cal.py inbox/*and import the result. - Move processed files to
done/so you don’t import them twice.
From a phone, the fastest route into inbox/ is a synced folder: Google Drive for desktop on Windows/macOS, or Syncthing on any OS.
For a group: if you run a club, a mosque schedule, or a community league, you can publish events.ics on any static host (GitHub Pages, Cloudflare Pages, both free) and give members the URL. In Google Calendar they use Other calendars → + → From URL. They subscribe once and new events appear automatically. Be aware that Google refreshes subscribed calendars on its own schedule, often several hours and sometimes up to a day, so this is no good for last-minute changes.
Honest cost breakdown
Scenario: a family with two kids in school, plus sports and community events, around 30 documents a month.
| Item | Monthly |
|---|---|
| Gemini 2.5 Flash, 30 × ~$0.003 | ~$0.09 |
| Re-runs and testing | ~$0.05 |
| Python, libraries, calendar apps | $0 |
| Total | well under $1 |
On the free tier it’s $0, with the privacy trade-off below.
Time: if you currently enter 40–60 events a month at about a minute each, that’s 40–60 minutes. With the script you spend maybe 10 minutes: saving files, one run, one import, checking 2–5 flagged items. So it saves 30–50 minutes a month. That’s real but modest. The bigger win is that you actually do it, because a weekly 5-minute batch is easier than typing in each date as it arrives, which most people quietly stop doing by week three.
Measure your own: count the events you added (or meant to add) last month.
Turning it into money (honestly: small)
This is mostly a personal tool, but there are two realistic ways it can earn a little:
1. Schedule feeds for organisations. Sports leagues, tutoring centres, courses, and religious communities publish schedules as PDFs or images that members then copy by hand. You run the script on each new schedule, review the CHECK items, and keep a subscribable .ics URL updated. Members subscribe once. That’s an hour of setup and maybe 15 minutes per schedule update. Price it as a small monthly retainer, based on how many people it saves time for, and use local rates rather than a figure from a blog.
2. Add it to a virtual-assistant service. If you already do admin work for busy clients, “send me any letter or flyer, it’s in your calendar tonight” is an easy extra to offer. The script lets you do it in minutes.
Realistic expectations: nobody pays for “AI calendar extraction”. People pay to stop missing things. Your first client is likely to be someone who already knows you and just missed a deadline.
Reality check: what does NOT work
Trusting it without the checks. The model returns confident JSON whether it’s right or not. The weekday check alone catches a lot: misread dates (6 vs 8 in a blurry photo), wrong years, and real typos in the notice. Don’t remove the checks to get “more events through”.
Recurring schedules. “Every Tuesday 15:30 extra lessons until December” becomes either one event or a pile of separate ones, and the model may stop early. For recurring things, add one recurring event by hand. It takes 30 seconds and it’s always right.
Vague dates. “Akhir bulan”, “after the exams”, “TBA”, “to be confirmed”. There’s no correct date, so the model will either skip it or make one up. The quote check doesn’t help here, because the quote is real and the date is a guess. Look out for any event whose quote has no actual date in it.
The quote check is text-only. For photos and PDFs, the script can’t compare the quote against the source. Check flyer results by eye. Also note that curly quotes, en-dashes or extra spaces in the original can occasionally cause a false “quote not found” for text input. Glance at the source and add the event by hand if it’s fine.
iPhone HEIC photos. Pillow can’t open .heic files out of the box, so they show up as an ERROR line. Either pip install pillow-heif and add from pillow_heif import register_heif_opener; register_heif_opener() near the top of the script, or set the iPhone camera to “Most Compatible” (JPEG), or just screenshot the photo.
Hand-drawn or decorative flyers. Script fonts, dates arranged artistically, light text over photos. Read errors go up a lot. Use a sharp, straight-on photo, or type the three key dates yourself.
Putting other people’s data on the free tier. Under Google’s Gemini API terms, content sent on the unpaid tier may be used to improve Google’s products and can be read by human reviewers. A futsal fixture list doesn’t matter. A school letter with children’s names, a clinic appointment, or a client’s schedule does. Turn on billing (pennies a month) for anything personal or anything you handle for someone else, or remove the names before you run it.
Importing the same file twice. The script gives each event a stable UID based on date, time and title, which helps some calendar apps spot repeats. Don’t count on it. Google Calendar’s import behaviour with repeated UIDs isn’t something to build on. Move processed files to done/ and you won’t have the problem.
Wrong timezone. If you live in Bali and leave CAL_TZ at Asia/Jakarta, every timed event is an hour off. All-day events aren’t affected, which makes the bug easy to miss. Set it once and check the first import.
Hard-coding the model. Google retires model versions (see ai.google.dev/gemini-api/docs/deprecations). When gemini-2.5-flash gets a shutdown date, set CAL_MODEL to the replacement. For Gemini 3.x models, replace thinking_budget=0 with the lowest thinking_level that model supports ("low" on Gemini 3.8 Flash, "minimal" on 3.6 Flash; check the thinking docs for your model). Thinking can’t be fully switched off on these, so expect slightly higher output costs. Then re-run a few old notices and compare before you trust it.
Final checklist
-
CAL_TZset to your real timezone - Tested on 3–5 old notices whose dates you already know
- Separate “School” or “Imported” calendar created, with default notifications set
- CHECK lines read and handled every run, never ignored
- Billing enabled before any documents with names or someone else’s data go through
- Processed files moved to
done/ - Recurring events added by hand, not extracted
Run it on this month’s pile of letters and screenshots. If the Sunday batch takes five minutes and you stop missing deadlines, it’s done its job. If you run a club or a course, the subscribable feed is the version worth showing people.