I used to be an office planner, and plans arrived by email and chat all day. Every one had to be typed into a calendar. So I built a small converter: paste text, check what it found, download an .ics file. It runs entirely in the page. Nothing is uploaded and there is no account: https://yotei.win/text-to-ics
This is what I learned writing it, in case you are building something similar.
Finding the date
There is no single format to parse, so the reader tries patterns in order and takes the first valid one:
2026-11-17-
17/11/2026,17-11-26,17.11.2026, and17/11without a year -
17 November,17th of November 2026,17 de noviembre November 17-
today,tomorrow, and a bare weekday, which means the next one
Three things bit me:
- Month first or day first.
04/05/2027is two different days. I do not guess: there is a "date order" setting, and it defaults to month first only foren-US,en-CAanden-PHlocales. - A date without a year means the next time that day comes around, not this year's past one.
-
\bdoes not work with accents.lunedìends in a non-ASCII letter, so\bfinds no word boundary after it. Lookarounds with theuflag do:
new RegExp(`(?<![\\p{L}])(${DAY_RE})(?![\\p{L}])`, 'iu') Several events in one paste
A schedule is the most useful input and the hardest. I support one event per line, a date as a heading with times underneath, and blocks separated by blank lines. If a line has a date and no time, it is a heading when the next line has a time but no date, and an all-day event otherwise.
Time zones without a library
A calendar file stores a moment, so "10:00 in Amsterdam" has to become a UTC instant. Intl.DateTimeFormat can tell you the offset of a zone at a given moment, and two passes handle a daylight saving change:
function offsetMinutes(utcMs, tz) { const f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', }); const p = Object.fromEntries(f.formatToParts(new Date(utcMs)).map((x) => [x.type, x.value])); const asUtc = Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second); return (asUtc - Math.floor(utcMs / 1000) * 1000) / 60000; } function zonedToUtc(date, time, tz) { const [y, m, d] = date.split('-').map(Number); const [h, mi] = time.split(':').map(Number); const guess = Date.UTC(y, m - 1, d, h, mi); let t = guess - offsetMinutes(guess, tz) * 60000; t = guess - offsetMinutes(t, tz) * 60000; return new Date(t); } ICS details that break imports
- Lines end with CRLF, not LF.
- Lines are folded at 75 octets, so count UTF-8 bytes, not characters. An accented letter at the fold point is where files break.
- Commas, semicolons and backslashes in text are escaped with a backslash.
- Every event needs a
UID, and two events must not share one. - An all-day event uses
DTSTART;VALUE=DATE:20261117and the end date is exclusive, so a one-day event ends on the 18th. - A time ending in
Zis UTC. A time with noZand no zone is "floating": it shows the same clock time everywhere, which is wrong for a call across zones.
One bug of my own: a <textarea> normalises CRLF to LF. My validator therefore reported "lines end with LF" on perfectly good files. The fix is to judge line endings only on a file read from disk, never on text that went through a text box.
Add to calendar links
Google, Outlook and Yahoo accept an event in the URL (calendar.google.com/calendar/render?action=TEMPLATE&text=...&dates=...). Apple Calendar has no such link, so for iPhone and Mac the only route is the .ics file.
The tools
All of these run in the browser:
- Text to ICS: https://yotei.win/text-to-ics
- ICS file generator: https://yotei.win/ics-file-generator
- CSV to ICS: https://yotei.win/csv-to-ics
- ICS validator: https://yotei.win/ics-validator
- Add to calendar link generator: https://yotei.win/add-to-calendar-link-generator
I'm the maker of Yotei, a desktop app that reads events from screenshots. These tools are separate and free. If you find a phrase whose date it gets wrong, I would like to hear it.
