diff --git a/public/locales/ar.json b/public/locales/ar.json
index 5cfc2bb..f342dae 100644
--- a/public/locales/ar.json
+++ b/public/locales/ar.json
@@ -358,7 +358,12 @@
"resetToast": "تم إعادة تعيين التغييرات."
},
"iconLabel": "Icon",
- "invalidDate": "Use a valid date in the selected date format."
+ "invalidDate": "Use a valid date in the selected date format.",
+ "attachmentLabel": "Attachment",
+ "attachmentHint": "Attach a local image, PDF, or document. Images will be shown in the event popup.",
+ "attachmentFallback": "Attachment",
+ "attachmentReadError": "The attachment could not be read.",
+ "attachmentTooLarge": "Attachment may be at most 5 MB."
},
"notes": {
"title": "لوحة الملاحظات",
diff --git a/public/locales/de.json b/public/locales/de.json
index 748623b..8644c62 100644
--- a/public/locales/de.json
+++ b/public/locales/de.json
@@ -375,7 +375,12 @@
"resetToast": "Änderungen zurückgesetzt."
},
"iconLabel": "Icon",
- "invalidDate": "Bitte ein gültiges Datum im ausgewählten Format verwenden."
+ "invalidDate": "Bitte ein gültiges Datum im ausgewählten Format verwenden.",
+ "attachmentLabel": "Attachment",
+ "attachmentHint": "Attach a local image, PDF, or document. Images will be shown in the event popup.",
+ "attachmentFallback": "Attachment",
+ "attachmentReadError": "The attachment could not be read.",
+ "attachmentTooLarge": "Attachment may be at most 5 MB."
},
"notes": {
"title": "Notizen",
diff --git a/public/locales/el.json b/public/locales/el.json
index c26e383..b1c39e6 100644
--- a/public/locales/el.json
+++ b/public/locales/el.json
@@ -358,7 +358,12 @@
"resetToast": "Οι αλλαγές επαναφέρθηκαν."
},
"iconLabel": "Icon",
- "invalidDate": "Use a valid date in the selected date format."
+ "invalidDate": "Use a valid date in the selected date format.",
+ "attachmentLabel": "Attachment",
+ "attachmentHint": "Attach a local image, PDF, or document. Images will be shown in the event popup.",
+ "attachmentFallback": "Attachment",
+ "attachmentReadError": "The attachment could not be read.",
+ "attachmentTooLarge": "Attachment may be at most 5 MB."
},
"notes": {
"title": "Σημειώσεις",
diff --git a/public/locales/en.json b/public/locales/en.json
index 77a4869..c9c5d11 100644
--- a/public/locales/en.json
+++ b/public/locales/en.json
@@ -358,7 +358,12 @@
"resetToast": "Changes reset."
},
"iconLabel": "Icon",
- "invalidDate": "Use a valid date in the selected date format."
+ "invalidDate": "Use a valid date in the selected date format.",
+ "attachmentLabel": "Attachment",
+ "attachmentHint": "Attach a local image, PDF, or document. Images will be shown in the event popup.",
+ "attachmentFallback": "Attachment",
+ "attachmentReadError": "The attachment could not be read.",
+ "attachmentTooLarge": "Attachment may be at most 5 MB."
},
"notes": {
"title": "Board",
diff --git a/public/locales/es.json b/public/locales/es.json
index 7ecee87..4e6716a 100644
--- a/public/locales/es.json
+++ b/public/locales/es.json
@@ -358,7 +358,12 @@
"resetToast": "Cambios restablecidos."
},
"iconLabel": "Icon",
- "invalidDate": "Use a valid date in the selected date format."
+ "invalidDate": "Use a valid date in the selected date format.",
+ "attachmentLabel": "Attachment",
+ "attachmentHint": "Attach a local image, PDF, or document. Images will be shown in the event popup.",
+ "attachmentFallback": "Attachment",
+ "attachmentReadError": "The attachment could not be read.",
+ "attachmentTooLarge": "Attachment may be at most 5 MB."
},
"notes": {
"title": "Notas",
diff --git a/public/locales/fr.json b/public/locales/fr.json
index 12feca1..82cf675 100644
--- a/public/locales/fr.json
+++ b/public/locales/fr.json
@@ -358,7 +358,12 @@
"resetToast": "Modifications annulées."
},
"iconLabel": "Icon",
- "invalidDate": "Use a valid date in the selected date format."
+ "invalidDate": "Use a valid date in the selected date format.",
+ "attachmentLabel": "Attachment",
+ "attachmentHint": "Attach a local image, PDF, or document. Images will be shown in the event popup.",
+ "attachmentFallback": "Attachment",
+ "attachmentReadError": "The attachment could not be read.",
+ "attachmentTooLarge": "Attachment may be at most 5 MB."
},
"notes": {
"title": "Notes",
diff --git a/public/locales/hi.json b/public/locales/hi.json
index 30069a2..0f2c858 100644
--- a/public/locales/hi.json
+++ b/public/locales/hi.json
@@ -358,7 +358,12 @@
"resetToast": "परिवर्तन रीसेट हो गए।"
},
"iconLabel": "Icon",
- "invalidDate": "Use a valid date in the selected date format."
+ "invalidDate": "Use a valid date in the selected date format.",
+ "attachmentLabel": "Attachment",
+ "attachmentHint": "Attach a local image, PDF, or document. Images will be shown in the event popup.",
+ "attachmentFallback": "Attachment",
+ "attachmentReadError": "The attachment could not be read.",
+ "attachmentTooLarge": "Attachment may be at most 5 MB."
},
"notes": {
"title": "नोट बोर्ड",
diff --git a/public/locales/it.json b/public/locales/it.json
index c5df136..9e085c7 100644
--- a/public/locales/it.json
+++ b/public/locales/it.json
@@ -358,7 +358,12 @@
"resetToast": "Modifiche ripristinate."
},
"iconLabel": "Icon",
- "invalidDate": "Use a valid date in the selected date format."
+ "invalidDate": "Use a valid date in the selected date format.",
+ "attachmentLabel": "Attachment",
+ "attachmentHint": "Attach a local image, PDF, or document. Images will be shown in the event popup.",
+ "attachmentFallback": "Attachment",
+ "attachmentReadError": "The attachment could not be read.",
+ "attachmentTooLarge": "Attachment may be at most 5 MB."
},
"notes": {
"title": "Bacheca",
diff --git a/public/locales/ja.json b/public/locales/ja.json
index 74b90e4..7995b14 100644
--- a/public/locales/ja.json
+++ b/public/locales/ja.json
@@ -358,7 +358,12 @@
"resetToast": "変更がリセットされました。"
},
"iconLabel": "Icon",
- "invalidDate": "Use a valid date in the selected date format."
+ "invalidDate": "Use a valid date in the selected date format.",
+ "attachmentLabel": "Attachment",
+ "attachmentHint": "Attach a local image, PDF, or document. Images will be shown in the event popup.",
+ "attachmentFallback": "Attachment",
+ "attachmentReadError": "The attachment could not be read.",
+ "attachmentTooLarge": "Attachment may be at most 5 MB."
},
"notes": {
"title": "メモボード",
diff --git a/public/locales/pt.json b/public/locales/pt.json
index 4c6de1f..d74decc 100644
--- a/public/locales/pt.json
+++ b/public/locales/pt.json
@@ -358,7 +358,12 @@
"resetToast": "Alterações restauradas."
},
"iconLabel": "Ícone",
- "invalidDate": "Use uma data válida no formato selecionado."
+ "invalidDate": "Use uma data válida no formato selecionado.",
+ "attachmentLabel": "Anexo",
+ "attachmentHint": "Anexe uma imagem, PDF ou documento local. Imagens aparecem no pop-up do evento.",
+ "attachmentFallback": "Anexo",
+ "attachmentReadError": "Nao foi possivel ler o anexo.",
+ "attachmentTooLarge": "O anexo pode ter no maximo 5 MB."
},
"notes": {
"title": "Quadro de notas",
diff --git a/public/locales/ru.json b/public/locales/ru.json
index 6d7b270..4ad7293 100644
--- a/public/locales/ru.json
+++ b/public/locales/ru.json
@@ -358,7 +358,12 @@
"resetToast": "Изменения сброшены."
},
"iconLabel": "Icon",
- "invalidDate": "Use a valid date in the selected date format."
+ "invalidDate": "Use a valid date in the selected date format.",
+ "attachmentLabel": "Attachment",
+ "attachmentHint": "Attach a local image, PDF, or document. Images will be shown in the event popup.",
+ "attachmentFallback": "Attachment",
+ "attachmentReadError": "The attachment could not be read.",
+ "attachmentTooLarge": "Attachment may be at most 5 MB."
},
"notes": {
"title": "Заметки",
diff --git a/public/locales/sv.json b/public/locales/sv.json
index 5cd0ec7..fb700de 100644
--- a/public/locales/sv.json
+++ b/public/locales/sv.json
@@ -358,7 +358,12 @@
"resetToast": "Ändringar återställda."
},
"iconLabel": "Icon",
- "invalidDate": "Use a valid date in the selected date format."
+ "invalidDate": "Use a valid date in the selected date format.",
+ "attachmentLabel": "Attachment",
+ "attachmentHint": "Attach a local image, PDF, or document. Images will be shown in the event popup.",
+ "attachmentFallback": "Attachment",
+ "attachmentReadError": "The attachment could not be read.",
+ "attachmentTooLarge": "Attachment may be at most 5 MB."
},
"notes": {
"title": "Anteckningar",
diff --git a/public/locales/tr.json b/public/locales/tr.json
index ceb5e0a..bfa2345 100644
--- a/public/locales/tr.json
+++ b/public/locales/tr.json
@@ -358,7 +358,12 @@
"resetToast": "Değişiklikler sıfırlandı."
},
"iconLabel": "Icon",
- "invalidDate": "Use a valid date in the selected date format."
+ "invalidDate": "Use a valid date in the selected date format.",
+ "attachmentLabel": "Attachment",
+ "attachmentHint": "Attach a local image, PDF, or document. Images will be shown in the event popup.",
+ "attachmentFallback": "Attachment",
+ "attachmentReadError": "The attachment could not be read.",
+ "attachmentTooLarge": "Attachment may be at most 5 MB."
},
"notes": {
"title": "Notlar",
diff --git a/public/locales/uk.json b/public/locales/uk.json
index ded1608..f27b19e 100644
--- a/public/locales/uk.json
+++ b/public/locales/uk.json
@@ -358,7 +358,12 @@
"resetToast": "Зміни скинуто."
},
"iconLabel": "Icon",
- "invalidDate": "Use a valid date in the selected date format."
+ "invalidDate": "Use a valid date in the selected date format.",
+ "attachmentLabel": "Attachment",
+ "attachmentHint": "Attach a local image, PDF, or document. Images will be shown in the event popup.",
+ "attachmentFallback": "Attachment",
+ "attachmentReadError": "The attachment could not be read.",
+ "attachmentTooLarge": "Attachment may be at most 5 MB."
},
"notes": {
"title": "Нотатки",
diff --git a/public/locales/zh.json b/public/locales/zh.json
index 9567d54..fdbbafb 100644
--- a/public/locales/zh.json
+++ b/public/locales/zh.json
@@ -358,7 +358,12 @@
"resetToast": "更改已重置。"
},
"iconLabel": "Icon",
- "invalidDate": "Use a valid date in the selected date format."
+ "invalidDate": "Use a valid date in the selected date format.",
+ "attachmentLabel": "Attachment",
+ "attachmentHint": "Attach a local image, PDF, or document. Images will be shown in the event popup.",
+ "attachmentFallback": "Attachment",
+ "attachmentReadError": "The attachment could not be read.",
+ "attachmentTooLarge": "Attachment may be at most 5 MB."
},
"notes": {
"title": "便签板",
diff --git a/public/pages/calendar.js b/public/pages/calendar.js
index 20dc2c9..f521330 100644
--- a/public/pages/calendar.js
+++ b/public/pages/calendar.js
@@ -169,6 +169,8 @@ const EVENT_ICONS = [
];
const CUSTOM_EVENT_ICONS = new Set(['tooth']);
+const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;
+const ATTACHMENT_IMAGE_MIME = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
const HOUR_HEIGHT = 56; // px pro Stunde in Wochen-/Tagesansicht
@@ -312,6 +314,35 @@ function eventIconElement(icon, className = 'event-icon') {
return el;
}
+function isImageAttachment(mime) {
+ return ATTACHMENT_IMAGE_MIME.has(String(mime || '').toLowerCase());
+}
+
+function readFileAsDataUrl(file) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(String(reader.result || ''));
+ reader.onerror = () => reject(new Error(t('calendar.attachmentReadError')));
+ reader.readAsDataURL(file);
+ });
+}
+
+function attachmentHtml(event) {
+ if (!event?.attachment_data) return '';
+ const name = esc(event.attachment_name || t('calendar.attachmentFallback'));
+ if (isImageAttachment(event.attachment_mime)) {
+ return `
+
`;
+ }
+ return `
+ `;
+}
+
function bindDateInputs(root) {
root.querySelectorAll('.js-date-input').forEach((input) => {
input.addEventListener('blur', () => {
@@ -631,6 +662,7 @@ function renderWeekView(container) {
const timedEvs = days.map((d) =>
eventsOnDay(d).filter((e) => !e.all_day && e.start_datetime.includes('T'))
);
+ const layouts = timedEvs.map((events) => layoutOverlaps(events));
container.innerHTML = `
@@ -674,7 +706,7 @@ function renderWeekView(container) {
${Array.from({ length: 24 }, (_, h) => `
`).join('')}
- ${timedEvs[i].map((ev) => renderWeekEvent(ev)).join('')}
+ ${timedEvs[i].map((ev) => renderWeekEvent(ev, layouts[i].get(ev.id))).join('')}
${d === state.today ? `
` : ''}
`).join('')}
@@ -712,19 +744,18 @@ function renderWeekView(container) {
}
}
-function renderWeekEvent(ev) {
- const start = timeToMinutes(localTime(ev.start_datetime));
- const end = ev.end_datetime
- ? timeToMinutes(localTime(ev.end_datetime))
- : start + 60;
+function renderWeekEvent(ev, layout = null) {
+ const { start, end } = timeRangeForEvent(ev);
const duration = Math.max(end - start, 30);
const top = (start / 60) * HOUR_HEIGHT;
const height = (duration / 60) * HOUR_HEIGHT - 2;
+ const left = layout ? `calc(${(layout.colIndex / layout.totalCols) * 100}% + 2px)` : '2px';
+ const width = layout ? `calc(${100 / layout.totalCols}% - 4px)` : 'auto';
return `
+ style="top:${top}px;height:${height}px;left:${left};width:${width};${ev.cal_color || ev.color ? `background-color:${esc(ev.cal_color || ev.color)};` : ''}${getContrastColor(ev.cal_color || ev.color) ? `color:${getContrastColor(ev.cal_color || ev.color)};` : ''}">
${eventIconHtml(ev.icon, 'event-icon event-icon--compact')}${esc(ev.title)}
${formatTime(ev.start_datetime)}${ev.end_datetime ? '–' + formatTime(ev.end_datetime) : ''}
@@ -743,6 +774,66 @@ function nowTop() {
return (minutes / 60) * HOUR_HEIGHT;
}
+function timeRangeForEvent(ev) {
+ const start = timeToMinutes(localTime(ev.start_datetime));
+ const end = ev.end_datetime
+ ? timeToMinutes(localTime(ev.end_datetime))
+ : start + 60;
+ return {
+ start,
+ end: Math.max(end, start + 30),
+ };
+}
+
+function layoutOverlaps(events) {
+ const groups = [];
+ const sorted = [...events].sort((a, b) => {
+ const aRange = timeRangeForEvent(a);
+ const bRange = timeRangeForEvent(b);
+ return aRange.start - bRange.start || aRange.end - bRange.end;
+ });
+
+ let current = [];
+ let currentEnd = -1;
+ for (const ev of sorted) {
+ const range = timeRangeForEvent(ev);
+ if (!current.length || range.start < currentEnd) {
+ current.push(ev);
+ currentEnd = current.length === 1 ? range.end : Math.max(currentEnd, range.end);
+ } else {
+ groups.push(current);
+ current = [ev];
+ currentEnd = range.end;
+ }
+ }
+ if (current.length) groups.push(current);
+
+ const layout = new Map();
+ for (const group of groups) {
+ const columns = [];
+ const placements = [];
+ for (const ev of group) {
+ const range = timeRangeForEvent(ev);
+ let colIndex = columns.findIndex((end) => end <= range.start);
+ if (colIndex === -1) {
+ colIndex = columns.length;
+ columns.push(range.end);
+ } else {
+ columns[colIndex] = range.end;
+ }
+ placements.push({ ev, colIndex });
+ }
+ const totalCols = Math.max(columns.length, 1);
+ for (const placement of placements) {
+ layout.set(placement.ev.id, {
+ colIndex: placement.colIndex,
+ totalCols,
+ });
+ }
+ }
+ return layout;
+}
+
// --------------------------------------------------------
// Tagesansicht
// --------------------------------------------------------
@@ -752,6 +843,7 @@ function renderDayView(container) {
const dayEvs = eventsOnDay(state.cursor);
const allday = dayEvs.filter((e) => e.all_day || !e.start_datetime.includes('T'));
const timed = dayEvs.filter((e) => !e.all_day && e.start_datetime.includes('T'));
+ const layout = layoutOverlaps(timed);
container.innerHTML = `
@@ -781,7 +873,7 @@ function renderDayView(container) {
${Array.from({ length: 24 }, (_, h) => `
`).join('')}
- ${timed.map((ev) => renderWeekEvent(ev)).join('')}
+ ${timed.map((ev) => renderWeekEvent(ev, layout.get(ev.id))).join('')}
${state.cursor === state.today ? `
` : ''}
@@ -902,6 +994,7 @@ function showEventPopup(ev, anchor) {
${timeStr}
${ev.location ? `📍 ${esc(fmtLocation(ev.location))}
` : ''}
${ev.description ? `${esc(ev.description)}
` : ''}
+ ${ev.attachment_data ? attachmentHtml(ev) : ''}
${ev.assigned_name ? `👤 ${esc(ev.assigned_name)}
` : ''}
+
+
${renderRRuleFields('event', isEdit ? event.recurrence_rule : null)}
${renderCalendarReminderSection(reminder, event)}
@@ -1324,7 +1471,7 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {
`;
}
-async function saveEvent(overlay, mode, eventId, existingReminder = null) {
+async function saveEvent(overlay, mode, eventId, existingReminder = null, attachmentState = null) {
const saveBtn = overlay.querySelector('#modal-save');
const title = overlay.querySelector('#modal-title').value.trim();
@@ -1382,6 +1529,10 @@ async function saveEvent(overlay, mode, eventId, existingReminder = null) {
all_day: allday ? 1 : 0,
location, color, icon, assigned_to: assigned_to ? parseInt(assigned_to, 10) : null,
recurrence_rule: rrule.recurrence_rule,
+ attachment_name: attachmentState?.name || null,
+ attachment_mime: attachmentState?.mime || null,
+ attachment_size: attachmentState?.size || null,
+ attachment_data: attachmentState?.data || null,
};
let savedEventId = eventId;
diff --git a/public/styles/calendar.css b/public/styles/calendar.css
index 2cddff7..0dfb62b 100644
--- a/public/styles/calendar.css
+++ b/public/styles/calendar.css
@@ -386,8 +386,6 @@
.week-event {
position: absolute;
- left: 2px;
- right: 2px;
border-radius: var(--radius-xs);
padding: var(--space-0h) var(--space-1);
font-size: var(--text-xs);
@@ -419,6 +417,29 @@
opacity: 0.85;
}
+.event-attachment-preview {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ margin-top: var(--space-2);
+}
+
+.event-attachment-preview img {
+ width: 100%;
+ max-height: 180px;
+ object-fit: cover;
+ border-radius: var(--radius-md);
+ border: 1px solid var(--color-border-subtle);
+}
+
+.event-attachment-preview a {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ color: var(--color-accent);
+ word-break: break-word;
+}
+
/* --------------------------------------------------------
* Tagesansicht
* -------------------------------------------------------- */
@@ -763,6 +784,25 @@
border-top: 1px solid var(--color-border);
}
+.event-popup__attachment {
+ margin-top: var(--space-2);
+}
+
+.event-popup__attachment--image img {
+ width: 100%;
+ max-height: 220px;
+ object-fit: cover;
+ border-radius: var(--radius-md);
+ border: 1px solid var(--color-border-subtle);
+}
+
+.event-popup__attachment--file {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ color: var(--color-accent);
+}
+
/* --------------------------------------------------------
* Ganztägige Ereignisse (oben in Wochen-/Tagesansicht)
* -------------------------------------------------------- */
diff --git a/server/db-schema-test.js b/server/db-schema-test.js
index 880b44b..e3fb25e 100644
--- a/server/db-schema-test.js
+++ b/server/db-schema-test.js
@@ -419,6 +419,12 @@ const MIGRATIONS_SQL = {
CREATE INDEX IF NOT EXISTS idx_family_documents_created_by ON family_documents(created_by);
CREATE INDEX IF NOT EXISTS idx_family_document_access_user ON family_document_access(user_id);
`,
+ 20: `
+ ALTER TABLE calendar_events ADD COLUMN attachment_name TEXT;
+ ALTER TABLE calendar_events ADD COLUMN attachment_mime TEXT;
+ ALTER TABLE calendar_events ADD COLUMN attachment_size INTEGER;
+ ALTER TABLE calendar_events ADD COLUMN attachment_data TEXT;
+ `,
};
export { MIGRATIONS_SQL };
diff --git a/server/db.js b/server/db.js
index 85892e0..3c407f9 100644
--- a/server/db.js
+++ b/server/db.js
@@ -853,6 +853,16 @@ const MIGRATIONS = [
CREATE INDEX IF NOT EXISTS idx_family_document_access_user ON family_document_access(user_id);
`,
},
+ {
+ version: 27,
+ description: 'Calendar event attachments',
+ up: `
+ ALTER TABLE calendar_events ADD COLUMN attachment_name TEXT;
+ ALTER TABLE calendar_events ADD COLUMN attachment_mime TEXT;
+ ALTER TABLE calendar_events ADD COLUMN attachment_size INTEGER;
+ ALTER TABLE calendar_events ADD COLUMN attachment_data TEXT;
+ `,
+ },
];
/**
diff --git a/server/openapi.js b/server/openapi.js
index 8c45408..2d9b49e 100644
--- a/server/openapi.js
+++ b/server/openapi.js
@@ -349,7 +349,13 @@ function buildPaths() {
},
'/api/v1/calendar': {
get: op({ summary: 'List calendar events', tag: 'Calendar' }),
- post: op({ summary: 'Create calendar event', tag: 'Calendar', stateChanging: true, requestBody: jsonBody(null) }),
+ post: op({
+ summary: 'Create calendar event',
+ tag: 'Calendar',
+ stateChanging: true,
+ description: 'Supports optional local file attachments via `attachment_name`, `attachment_mime`, `attachment_size`, and `attachment_data` (base64 data URL).',
+ requestBody: jsonBody(null),
+ }),
},
'/api/v1/calendar/upcoming': { get: op({ summary: 'List upcoming events', tag: 'Calendar' }) },
'/api/v1/calendar/google/auth': { get: op({ summary: 'Start Google Calendar OAuth', tag: 'Calendar', admin: true }) },
@@ -374,7 +380,14 @@ function buildPaths() {
},
'/api/v1/calendar/{id}': {
get: op({ summary: 'Get calendar event', tag: 'Calendar', params: [idParam()] }),
- put: op({ summary: 'Update calendar event', tag: 'Calendar', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
+ put: op({
+ summary: 'Update calendar event',
+ tag: 'Calendar',
+ params: [idParam()],
+ stateChanging: true,
+ description: 'Supports optional local file attachments via `attachment_name`, `attachment_mime`, `attachment_size`, and `attachment_data` (base64 data URL).',
+ requestBody: jsonBody(null),
+ }),
delete: op({ summary: 'Delete calendar event', tag: 'Calendar', params: [idParam()], stateChanging: true }),
},
'/api/v1/calendar/{id}/reset': {
diff --git a/server/routes/calendar.js b/server/routes/calendar.js
index 040f2a7..af59d31 100644
--- a/server/routes/calendar.js
+++ b/server/routes/calendar.js
@@ -20,6 +20,19 @@ const log = createLogger('Calendar');
const router = express.Router();
const VALID_SOURCES = ['local', 'google', 'apple', 'ics'];
+const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;
+const ATTACHMENT_MIME = new Set([
+ 'image/png',
+ 'image/jpeg',
+ 'image/webp',
+ 'image/gif',
+ 'application/pdf',
+ 'text/plain',
+ 'application/msword',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'application/vnd.ms-excel',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+]);
const ICS_COLOR_RE = /^#[0-9a-fA-F]{6}$/;
const VALID_EVENT_ICONS = new Set([
'calendar', 'tooth', 'drill', 'alarm-clock', 'clock', 'bell', 'map-pin', 'home',
@@ -59,6 +72,20 @@ function eventIcon(value) {
return VALID_EVENT_ICONS.has(icon) ? icon : null;
}
+function parseAttachment(dataUrl) {
+ const raw = typeof dataUrl === 'string' ? dataUrl.trim() : '';
+ if (!raw) return { name: null, mime: null, size: null, data: null };
+ const match = raw.match(/^data:([^;,]+);base64,([A-Za-z0-9+/=\s]+)$/);
+ if (!match) throw new Error('attachment_data: ungültiges Dateiformat.');
+ const mime = match[1].toLowerCase();
+ if (!ATTACHMENT_MIME.has(mime)) throw new Error('attachment_data: Dateityp nicht erlaubt.');
+ const base64 = match[2].replace(/\s/g, '');
+ const buffer = Buffer.from(base64, 'base64');
+ if (!buffer.length) throw new Error('attachment_data: Datei ist leer.');
+ if (buffer.length > MAX_ATTACHMENT_BYTES) throw new Error('attachment_data: Datei darf höchstens 5 MB groß sein.');
+ return { name: null, mime, size: buffer.length, data: base64 };
+}
+
// --------------------------------------------------------
// RRULE-Expansion: alle Vorkommen eines wiederkehrenden Events
// innerhalb [from, to] generieren (inklusive beider Grenzen).
@@ -591,17 +618,24 @@ router.post('/', (req, res) => {
if (!user) return res.status(400).json({ error: 'assigned_to: Benutzer nicht gefunden', code: 400 });
}
+ const attachment = req.body.attachment_data ? parseAttachment(req.body.attachment_data) : { mime: null, size: null, data: null };
+
const result = db.get().prepare(`
INSERT INTO calendar_events
(title, description, start_datetime, end_datetime, all_day,
- location, color, icon, assigned_to, created_by, recurrence_rule)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ location, color, icon, assigned_to, created_by, recurrence_rule,
+ attachment_name, attachment_mime, attachment_size, attachment_data)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
vTitle.value, vDesc.value,
vStart.value, vEnd.value,
all_day ? 1 : 0, vLoc.value,
vColor.value, vIcon, assigned_to || null,
- userId, vRrule.value
+ userId, vRrule.value,
+ req.body.attachment_name || null,
+ attachment.mime,
+ attachment.size,
+ attachment.data
);
const event = db.get().prepare(`
@@ -646,10 +680,17 @@ router.put('/:id', (req, res) => {
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
const vIcon = req.body.icon !== undefined ? eventIcon(req.body.icon) : event.icon;
if (!vIcon) return res.status(400).json({ error: 'icon: invalid calendar event icon.', code: 400 });
+ const attachment = req.body.attachment_data !== undefined
+ ? (req.body.attachment_data ? parseAttachment(req.body.attachment_data) : { mime: null, size: null, data: null })
+ : {
+ mime: event.attachment_mime,
+ size: event.attachment_size,
+ data: event.attachment_data,
+ };
const {
title, description, start_datetime, end_datetime,
- all_day, location, color: colorVal, assigned_to, recurrence_rule,
+ all_day, location, color: colorVal, assigned_to, recurrence_rule, attachment_name,
} = req.body;
const userModified = event.external_source !== 'local' ? 1 : event.user_modified;
@@ -666,6 +707,10 @@ router.put('/:id', (req, res) => {
icon = COALESCE(?, icon),
assigned_to = ?,
recurrence_rule = ?,
+ attachment_name = ?,
+ attachment_mime = ?,
+ attachment_size = ?,
+ attachment_data = ?,
user_modified = ?
WHERE id = ?
`).run(
@@ -679,6 +724,10 @@ router.put('/:id', (req, res) => {
req.body.icon !== undefined ? vIcon : null,
assigned_to !== undefined ? (assigned_to || null) : event.assigned_to,
recurrence_rule !== undefined ? (recurrence_rule || null) : event.recurrence_rule,
+ attachment_name !== undefined ? (attachment_name || null) : event.attachment_name,
+ attachment.mime,
+ attachment.size,
+ attachment.data,
userModified,
id
);