|
27 | 27 | <span>↑</span>
|
28 | 28 | <span>Import Database</span>
|
29 | 29 | </div>
|
| 30 | + <div class="dropdown-item" onclick="document.getElementById('importCalendarFile').click()"> |
| 31 | + <span>📅</span> |
| 32 | + <span>Import Calendar</span> |
| 33 | + </div> |
30 | 34 | </div>
|
31 | 35 | </div>
|
32 | 36 | <input type="file" id="importFile" accept="application/json" style="display:none" />
|
33 | 37 | <input type="file" id="importDbFile" accept=".json" style="display:none" />
|
| 38 | + <input type="file" id="importCalendarFile" accept=".ics,text/calendar" style="display:none" /> |
34 | 39 |
|
35 | 40 | <div class="theme-toggle">
|
36 | 41 | <button id="darkModeToggle">
|
|
42 | 47 |
|
43 | 48 | <script>
|
44 | 49 | // 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 | +} |
45 | 113 | function toRFC3339NoMillis(date) {
|
46 | 114 | const pad = (n) => n.toString().padStart(2, '0');
|
47 | 115 | return date.getUTCFullYear() + '-' +
|
@@ -1417,6 +1485,85 @@ <h4>Projects</h4>
|
1417 | 1485 | }
|
1418 | 1486 | });
|
1419 | 1487 |
|
| 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 | + |
1420 | 1567 | // Auto-resize textarea function
|
1421 | 1568 | function autoResizeTextarea(textarea) {
|
1422 | 1569 | textarea.style.height = 'auto';
|
|
0 commit comments