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 .ics file 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

ItemCostNotes
Python 3.10+$0Laptop, mini PC or VPS. Uses zoneinfo from the standard library
Gemini API key$0 to startaistudio.google.com → “Get API key”
google-genai, pydantic, icalendar, pillow$0pip install
A calendar app$0Google Calendar (web), Apple Calendar, or Outlook.com
tzdata (Windows only)$0pip 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):

  1. calendar.google.com → next to “Other calendars”, click + → Create new calendar → name it “School” or “Imported”.
  2. Gear icon → Settings → Import & export → Import.
  3. 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.

ItemMonthly
Gemini 2.5 Flash, 30 × ~$0.003~$0.09
Re-runs and testing~$0.05
Python, libraries, calendar apps$0
Totalwell 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_TZ set 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.