Skip to content

Commit 9cb4fe1

Browse files
committed
feat: import ICS file
1 parent 4e5ea0d commit 9cb4fe1

File tree

2 files changed

+147
-0
lines changed

2 files changed

+147
-0
lines changed

index.html

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,15 @@
2727
<span></span>
2828
<span>Import Database</span>
2929
</div>
30+
<div class="dropdown-item" onclick="document.getElementById('importCalendarFile').click()">
31+
<span>📅</span>
32+
<span>Import Calendar</span>
33+
</div>
3034
</div>
3135
</div>
3236
<input type="file" id="importFile" accept="application/json" style="display:none" />
3337
<input type="file" id="importDbFile" accept=".json" style="display:none" />
38+
<input type="file" id="importCalendarFile" accept=".ics,text/calendar" style="display:none" />
3439

3540
<div class="theme-toggle">
3641
<button id="darkModeToggle">
@@ -42,6 +47,69 @@
4247

4348
<script>
4449
// Utility: format date as strict RFC3339 (no ms, always ends in Z)
50+
//
51+
// Lightweight .ics parser to extract VEVENT SUMMARY and DTSTART
52+
/**
53+
* Parse raw ICS text and return array of {summary, date}
54+
* Only supports DTSTART in formats YYYYMMDD, YYYYMMDDThhmmss, or ...Z
55+
*/
56+
function parseICS(text) {
57+
// Unfold lines (RFC 5545 3.1) – remove CRLF followed by space / tab
58+
text = text.replace(/\r?\n[ \t]/g, "");
59+
const lines = text.split(/\r?\n/);
60+
const events = [];
61+
let current = null;
62+
for (const line of lines) {
63+
if (line.startsWith("BEGIN:VEVENT")) {
64+
current = { summary: null, date: null, rrule: null };
65+
} else if (line.startsWith("END:VEVENT")) {
66+
if (current) events.push(current);
67+
current = null;
68+
} else if (current) {
69+
if (line.startsWith("SUMMARY:")) {
70+
current.summary = line.substring(8).trim();
71+
} else if (line.startsWith("DTSTART")) {
72+
const [, value] = line.split(":");
73+
if (!value) continue;
74+
let date = null;
75+
const v = value.trim();
76+
if (/^\d{8}T\d{6}Z$/.test(v)) {
77+
date = new Date(v);
78+
} else if (/^\d{8}T\d{6}$/.test(v)) {
79+
const y = v.slice(0, 4), m = v.slice(4, 6), d = v.slice(6, 8);
80+
const H = v.slice(9, 11), M = v.slice(11, 13), S = v.slice(13, 15);
81+
date = new Date(Number(y), Number(m) - 1, Number(d), Number(H), Number(M), Number(S));
82+
} else if (/^\d{8}$/.test(v)) {
83+
const y = v.slice(0, 4), m = v.slice(4, 6), d = v.slice(6, 8);
84+
date = new Date(Number(y), Number(m) - 1, Number(d));
85+
}
86+
current.date = date;
87+
} else if (line.startsWith("RRULE:")) {
88+
current.rrule = line.substring(6).trim();
89+
}
90+
}
91+
}
92+
// Map RRULE to recurrence fields
93+
return events.map(ev => {
94+
if (ev.rrule) {
95+
const parts = Object.fromEntries(ev.rrule.split(';').map(p => p.split('=')));
96+
const freq = (parts.FREQ || '').toUpperCase();
97+
const interval = parseInt(parts.INTERVAL || '1', 10);
98+
let unit = null;
99+
switch (freq) {
100+
case 'DAILY': unit = 'day'; break;
101+
case 'WEEKLY': unit = 'week'; break;
102+
case 'MONTHLY': unit = 'month'; break;
103+
case 'YEARLY': unit = 'year'; break;
104+
}
105+
if (unit) {
106+
ev.recurrenceInterval = interval;
107+
ev.recurrenceUnit = unit;
108+
}
109+
}
110+
return ev;
111+
});
112+
}
45113
function toRFC3339NoMillis(date) {
46114
const pad = (n) => n.toString().padStart(2, '0');
47115
return date.getUTCFullYear() + '-' +
@@ -1417,6 +1485,85 @@ <h4>Projects</h4>
14171485
}
14181486
});
14191487

1488+
// Import Calendar (.ics) handler
1489+
document.getElementById('importCalendarFile').addEventListener('change', async function(e) {
1490+
const file = e.target.files[0];
1491+
if (!file) return;
1492+
try {
1493+
const text = await file.text();
1494+
const events = parseICS(text);
1495+
1496+
const nowUTC = new Date();
1497+
function getNextOccurrence(start, interval, unit) {
1498+
if (!start) return null;
1499+
const next = new Date(start.getTime());
1500+
const inc = interval || 1;
1501+
const step = unit;
1502+
const add = {
1503+
day: () => next.setUTCDate(next.getUTCDate() + inc),
1504+
week: () => next.setUTCDate(next.getUTCDate() + inc * 7),
1505+
month: () => next.setUTCMonth(next.getUTCMonth() + inc),
1506+
year: () => next.setUTCFullYear(next.getUTCFullYear() + inc)
1507+
}[step];
1508+
if (!add) return next; // unsupported
1509+
while (next < nowUTC) {
1510+
add();
1511+
}
1512+
return next;
1513+
}
1514+
1515+
// Create a new project named after the uploaded file
1516+
const projectTitle = file.name.replace(/\.(ics|calendar)$/i, '') || 'Imported Calendar';
1517+
let projectId = 1;
1518+
try {
1519+
const projResp = await fetch('/api/projects', {
1520+
method: 'POST',
1521+
headers: { 'Content-Type': 'application/json' },
1522+
body: JSON.stringify({ title: projectTitle })
1523+
});
1524+
if (!projResp.ok) throw new Error('Failed to create project');
1525+
const projData = await projResp.json();
1526+
projectId = projData.id;
1527+
} catch (projErr) {
1528+
console.error('Project creation failed, falling back to default project', projErr);
1529+
}
1530+
1531+
for (const ev of events) {
1532+
// Build payload for each event as a todo
1533+
const todoPayload = {
1534+
title: ev.summary || 'Untitled event',
1535+
completed: false,
1536+
project_id: projectId,
1537+
// Determine correct due date
1538+
due_date: (() => {
1539+
if (!ev.date) return null;
1540+
const nextDate = ev.recurrenceUnit ? getNextOccurrence(ev.date, ev.recurrenceInterval, ev.recurrenceUnit) : ev.date;
1541+
return toRFC3339NoMillis(nextDate);
1542+
})(),
1543+
recurrence_interval: ev.recurrenceInterval,
1544+
recurrence_unit: ev.recurrenceUnit
1545+
};
1546+
try {
1547+
await fetch('/api/todo', {
1548+
method: 'POST',
1549+
headers: { 'Content-Type': 'application/json' },
1550+
body: JSON.stringify(todoPayload)
1551+
});
1552+
} catch (err) {
1553+
console.error('Failed to create todo for event', ev, err);
1554+
}
1555+
}
1556+
1557+
alert('Calendar import completed');
1558+
await loadTodosByProject();
1559+
} catch (err) {
1560+
console.error('ICS import error:', err);
1561+
alert('Import failed: ' + (err.message || 'Unknown error'));
1562+
} finally {
1563+
e.target.value = '';
1564+
}
1565+
});
1566+
14201567
// Auto-resize textarea function
14211568
function autoResizeTextarea(textarea) {
14221569
textarea.style.height = 'auto';

todos.db~

0 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)