feat: safe HTML descriptions, custom reminder picker, synced default event duration
- Render event descriptions as sanitized HTML (links/line breaks) instead of raw escaped text; no script execution - Reminder picker: presets + custom number+unit (minutes/hours/days/weeks) - Grey out + hint the reminder editor when a calendar's notifications are off (reminders are kept, just not fired) - New synced setting default_event_duration_minutes (default 60) for new events Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -210,6 +210,13 @@ def _migrate():
|
|||||||
logging.info("Migration: added reminders_enabled to %s", tbl)
|
logging.info("Migration: added reminders_enabled to %s", tbl)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
# Synced default duration (minutes) for newly created events.
|
||||||
|
try:
|
||||||
|
conn.execute(text("ALTER TABLE user_settings ADD COLUMN default_event_duration_minutes INTEGER DEFAULT 60"))
|
||||||
|
conn.commit()
|
||||||
|
logging.info("Migration: added default_event_duration_minutes to user_settings")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
_migrate()
|
_migrate()
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ class UserSettings(Base):
|
|||||||
# Default reminder in minutes-before-start applied to all events client-side
|
# Default reminder in minutes-before-start applied to all events client-side
|
||||||
# (0 = at start time). NULL = no default reminder.
|
# (0 = at start time). NULL = no default reminder.
|
||||||
default_reminder_minutes = Column(Integer, nullable=True)
|
default_reminder_minutes = Column(Integer, nullable=True)
|
||||||
|
# Default duration (in minutes) applied to a newly created event's end time.
|
||||||
|
default_event_duration_minutes = Column(Integer, default=60)
|
||||||
|
|
||||||
user = relationship("User", back_populates="settings")
|
user = relationship("User", back_populates="settings")
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class SettingsUpdate(BaseModel):
|
|||||||
private_event_visibility: Optional[str] = None
|
private_event_visibility: Optional[str] = None
|
||||||
group_visible_calendar_id: Optional[int] = None
|
group_visible_calendar_id: Optional[int] = None
|
||||||
default_reminder_minutes: Optional[int] = None # null = off
|
default_reminder_minutes: Optional[int] = None # null = off
|
||||||
|
default_event_duration_minutes: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
def _settings_dict(s: models.UserSettings) -> dict:
|
def _settings_dict(s: models.UserSettings) -> dict:
|
||||||
@@ -52,6 +53,7 @@ def _settings_dict(s: models.UserSettings) -> dict:
|
|||||||
"private_event_visibility": s.private_event_visibility or "busy",
|
"private_event_visibility": s.private_event_visibility or "busy",
|
||||||
"group_visible_calendar_id": s.group_visible_calendar_id,
|
"group_visible_calendar_id": s.group_visible_calendar_id,
|
||||||
"default_reminder_minutes": s.default_reminder_minutes,
|
"default_reminder_minutes": s.default_reminder_minutes,
|
||||||
|
"default_event_duration_minutes": s.default_event_duration_minutes or 60,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -355,9 +355,16 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
|
|
||||||
.ev-color-row { display: flex; align-items: center; gap: 8px; }
|
.ev-color-row { display: flex; align-items: center; gap: 8px; }
|
||||||
.ev-reminders-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 6px; }
|
.ev-reminders-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 6px; }
|
||||||
.ev-reminder-row { display: flex; align-items: center; gap: 8px; }
|
.ev-reminder-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
.ev-reminder-row select { flex: 1; }
|
.ev-reminder-row > select { flex: 1; min-width: 120px; }
|
||||||
.ev-reminder-remove { flex-shrink: 0; }
|
.ev-reminder-remove { flex-shrink: 0; }
|
||||||
|
.ev-reminder-custom { display: inline-flex; align-items: center; gap: 6px; flex-basis: 100%; }
|
||||||
|
.ev-reminder-custom input[type="number"] { width: 64px; }
|
||||||
|
.ev-reminder-before { color: var(--text-3); font-size: 12px; }
|
||||||
|
.form-hint { font-size: 12px; color: var(--text-3); margin-bottom: 6px; }
|
||||||
|
.reminders-disabled .ev-reminders-list,
|
||||||
|
.reminders-disabled #ev-reminder-add { opacity: 0.5; }
|
||||||
|
#ev-reminders-hint { color: var(--accent); }
|
||||||
.ev-color-hex {
|
.ev-color-hex {
|
||||||
flex: 1; height: 36px; padding: 0 10px;
|
flex: 1; height: 36px; padding: 0 10px;
|
||||||
background: var(--bg-hover); border: 1px solid var(--border);
|
background: var(--bg-hover); border: 1px solid var(--border);
|
||||||
@@ -1193,6 +1200,10 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
#popup-time { color: var(--text-1); font-weight: 500; }
|
#popup-time { color: var(--text-1); font-weight: 500; }
|
||||||
.popup-row-desc { color: var(--text-1); }
|
.popup-row-desc { color: var(--text-1); }
|
||||||
.popup-row-desc span { white-space: pre-wrap; }
|
.popup-row-desc span { white-space: pre-wrap; }
|
||||||
|
#popup-description { overflow-wrap: anywhere; }
|
||||||
|
#popup-description a { color: var(--primary); text-decoration: underline; }
|
||||||
|
#popup-description p { margin: 0 0 6px; }
|
||||||
|
#popup-description ul, #popup-description ol { margin: 4px 0; padding-left: 20px; }
|
||||||
#popup-creator { font-style: italic; }
|
#popup-creator { font-style: italic; }
|
||||||
|
|
||||||
.popup-copy-menu {
|
.popup-copy-menu {
|
||||||
|
|||||||
@@ -375,6 +375,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group" id="ev-reminders-group" style="display:none">
|
<div class="form-group" id="ev-reminders-group" style="display:none">
|
||||||
<label data-i18n="reminders">Benachrichtigungen</label>
|
<label data-i18n="reminders">Benachrichtigungen</label>
|
||||||
|
<div id="ev-reminders-hint" class="form-hint" style="display:none" data-i18n="reminders_calendar_off">Für diesen Kalender sind Benachrichtigungen deaktiviert – Erinnerungen werden nicht ausgeführt.</div>
|
||||||
<div id="ev-reminders-list" class="ev-reminders-list"></div>
|
<div id="ev-reminders-list" class="ev-reminders-list"></div>
|
||||||
<button type="button" class="btn btn-ghost btn-sm" id="ev-reminder-add" data-i18n="reminder_add">Benachrichtigung hinzufügen</button>
|
<button type="button" class="btn btn-ghost btn-sm" id="ev-reminder-add" data-i18n="reminder_add">Benachrichtigung hinzufügen</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -843,6 +844,18 @@
|
|||||||
<span data-i18n="settings_dim_past">Vergangene Termine ausgrauen</span>
|
<span data-i18n="settings_dim_past">Vergangene Termine ausgrauen</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="settings_default_duration">Standard-Termindauer</label>
|
||||||
|
<select id="cfg-default-duration">
|
||||||
|
<option value="15">15 min</option>
|
||||||
|
<option value="30">30 min</option>
|
||||||
|
<option value="45">45 min</option>
|
||||||
|
<option value="60">1 h</option>
|
||||||
|
<option value="90">1.5 h</option>
|
||||||
|
<option value="120">2 h</option>
|
||||||
|
<option value="240">4 h</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_hidden_cals">Ausgeblendete Kalender</h4>
|
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_hidden_cals">Ausgeblendete Kalender</h4>
|
||||||
<div id="hidden-cals-list"><span style="font-size:13px;color:var(--text-3)" data-i18n="settings_no_hidden_cals">Keine ausgeblendeten Kalender</span></div>
|
<div id="hidden-cals-list"><span style="font-size:13px;color:var(--text-3)" data-i18n="settings_no_hidden_cals">Keine ausgeblendeten Kalender</span></div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { api } from './api.js';
|
import { api } from './api.js';
|
||||||
import { applyTheme, isToday, isSameDay, toLocalDatetimeInput, toDateInput, dateKey, dayOfWeek, weekStart, DEFAULT_TEXT_COLOR, DEFAULT_LINE_COLOR, DEFAULT_BG_COLOR } from './utils.js';
|
import { applyTheme, isToday, isSameDay, toLocalDatetimeInput, toDateInput, dateKey, dayOfWeek, weekStart, renderDescriptionHtml, DEFAULT_TEXT_COLOR, DEFAULT_LINE_COLOR, DEFAULT_BG_COLOR } from './utils.js';
|
||||||
import { renderMonth } from './views/month.js';
|
import { renderMonth } from './views/month.js';
|
||||||
import { renderWeek } from './views/week.js';
|
import { renderWeek } from './views/week.js';
|
||||||
import { renderAgenda } from './views/agenda.js';
|
import { renderAgenda } from './views/agenda.js';
|
||||||
@@ -1401,7 +1401,7 @@ function showEventPopup(ev, anchor) {
|
|||||||
|
|
||||||
document.getElementById('popup-location').textContent = ev.location || '';
|
document.getElementById('popup-location').textContent = ev.location || '';
|
||||||
document.getElementById('popup-row-location').style.display = ev.location ? '' : 'none';
|
document.getElementById('popup-row-location').style.display = ev.location ? '' : 'none';
|
||||||
document.getElementById('popup-description').textContent = ev.description || '';
|
document.getElementById('popup-description').innerHTML = renderDescriptionHtml(ev.description || '');
|
||||||
document.getElementById('popup-row-description').style.display = ev.description ? '' : 'none';
|
document.getElementById('popup-row-description').style.display = ev.description ? '' : 'none';
|
||||||
document.getElementById('popup-calendar').textContent = ev.calendar_name || '';
|
document.getElementById('popup-calendar').textContent = ev.calendar_name || '';
|
||||||
document.getElementById('popup-row-calendar').style.display = ev.calendar_name ? '' : 'none';
|
document.getElementById('popup-row-calendar').style.display = ev.calendar_name ? '' : 'none';
|
||||||
@@ -1700,8 +1700,8 @@ function openNewEventModal(date) {
|
|||||||
document.getElementById('ev-allday').checked = false;
|
document.getElementById('ev-allday').checked = false;
|
||||||
|
|
||||||
const start = new Date(date);
|
const start = new Date(date);
|
||||||
const end = new Date(date);
|
const durMin = (state.settings && state.settings.default_event_duration_minutes) || 60;
|
||||||
end.setHours(end.getHours() + 1);
|
const end = new Date(start.getTime() + durMin * 60000);
|
||||||
setDtValue('ev-start', toLocalDatetimeInput(start), 'datetime');
|
setDtValue('ev-start', toLocalDatetimeInput(start), 'datetime');
|
||||||
setDtValue('ev-end', toLocalDatetimeInput(end), 'datetime');
|
setDtValue('ev-end', toLocalDatetimeInput(end), 'datetime');
|
||||||
setDtValue('ev-start-date', toDateInput(start), 'date');
|
setDtValue('ev-start-date', toDateInput(start), 'date');
|
||||||
@@ -1835,7 +1835,15 @@ function resetColorPicker(color) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Reminders (local events only) ─────────────────────────
|
// ── Reminders (local events only) ─────────────────────────
|
||||||
const REMINDER_OPTIONS = [0, 5, 15, 30, 60, 1440, 10080];
|
// A few quick presets; anything else is entered via the "custom" mode
|
||||||
|
// (number + unit). Reminders are stored as minutes-before-start integers.
|
||||||
|
const REMINDER_PRESETS = [0, 30, 1440]; // at start, 30 min, 1 day
|
||||||
|
const REMINDER_UNITS = [
|
||||||
|
{ unit: 'minutes', mult: 1 },
|
||||||
|
{ unit: 'hours', mult: 60 },
|
||||||
|
{ unit: 'days', mult: 1440 },
|
||||||
|
{ unit: 'weeks', mult: 10080 },
|
||||||
|
];
|
||||||
|
|
||||||
function reminderLabel(min) {
|
function reminderLabel(min) {
|
||||||
if (min <= 0) return t('reminder_at_start');
|
if (min <= 0) return t('reminder_at_start');
|
||||||
@@ -1845,6 +1853,15 @@ function reminderLabel(min) {
|
|||||||
const w = min / 10080; return w === 1 ? t('reminder_week_one') : t('reminder_weeks', { n: w });
|
const w = min / 10080; return w === 1 ? t('reminder_week_one') : t('reminder_weeks', { n: w });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Split a minutes value into the largest exact {value, unit} for the custom picker.
|
||||||
|
function splitReminder(min) {
|
||||||
|
for (let i = REMINDER_UNITS.length - 1; i >= 0; i--) {
|
||||||
|
const u = REMINDER_UNITS[i];
|
||||||
|
if (min > 0 && min % u.mult === 0) return { value: min / u.mult, unit: u.unit };
|
||||||
|
}
|
||||||
|
return { value: Math.max(1, min), unit: 'minutes' };
|
||||||
|
}
|
||||||
|
|
||||||
function setEventReminders(arr) {
|
function setEventReminders(arr) {
|
||||||
state.eventReminders = Array.isArray(arr)
|
state.eventReminders = Array.isArray(arr)
|
||||||
? arr.map(Number).filter(n => !isNaN(n)) : [];
|
? arr.map(Number).filter(n => !isNaN(n)) : [];
|
||||||
@@ -1858,19 +1875,63 @@ function renderReminderRows() {
|
|||||||
state.eventReminders.forEach((min, idx) => {
|
state.eventReminders.forEach((min, idx) => {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'ev-reminder-row';
|
row.className = 'ev-reminder-row';
|
||||||
|
|
||||||
|
const isPreset = REMINDER_PRESETS.includes(min);
|
||||||
const sel = document.createElement('select');
|
const sel = document.createElement('select');
|
||||||
// Keep a non-catalog value (from an old/imported reminder) selectable.
|
REMINDER_PRESETS.forEach(v => {
|
||||||
const opts = REMINDER_OPTIONS.includes(min) ? REMINDER_OPTIONS : [min, ...REMINDER_OPTIONS];
|
|
||||||
opts.forEach(v => {
|
|
||||||
const o = document.createElement('option');
|
const o = document.createElement('option');
|
||||||
o.value = String(v);
|
o.value = String(v);
|
||||||
o.textContent = reminderLabel(v);
|
o.textContent = reminderLabel(v);
|
||||||
if (v === min) o.selected = true;
|
if (isPreset && v === min) o.selected = true;
|
||||||
sel.appendChild(o);
|
sel.appendChild(o);
|
||||||
});
|
});
|
||||||
sel.addEventListener('change', () => {
|
const customOpt = document.createElement('option');
|
||||||
state.eventReminders[idx] = parseInt(sel.value, 10);
|
customOpt.value = 'custom';
|
||||||
|
customOpt.textContent = t('reminder_custom');
|
||||||
|
if (!isPreset) customOpt.selected = true;
|
||||||
|
sel.appendChild(customOpt);
|
||||||
|
|
||||||
|
// Custom (number + unit) inputs, shown only in custom mode.
|
||||||
|
const customWrap = document.createElement('span');
|
||||||
|
customWrap.className = 'ev-reminder-custom';
|
||||||
|
customWrap.style.display = isPreset ? 'none' : '';
|
||||||
|
const numInput = document.createElement('input');
|
||||||
|
numInput.type = 'number';
|
||||||
|
numInput.min = '1';
|
||||||
|
const unitSel = document.createElement('select');
|
||||||
|
REMINDER_UNITS.forEach(u => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = u.unit;
|
||||||
|
o.textContent = t('reminder_unit_' + u.unit);
|
||||||
|
unitSel.appendChild(o);
|
||||||
});
|
});
|
||||||
|
const beforeLbl = document.createElement('span');
|
||||||
|
beforeLbl.className = 'ev-reminder-before';
|
||||||
|
beforeLbl.textContent = t('reminder_before');
|
||||||
|
const sp = splitReminder(isPreset ? 30 : min);
|
||||||
|
numInput.value = String(sp.value);
|
||||||
|
unitSel.value = sp.unit;
|
||||||
|
customWrap.appendChild(numInput);
|
||||||
|
customWrap.appendChild(unitSel);
|
||||||
|
customWrap.appendChild(beforeLbl);
|
||||||
|
|
||||||
|
function commitCustom() {
|
||||||
|
const n = Math.max(1, parseInt(numInput.value, 10) || 1);
|
||||||
|
const mult = REMINDER_UNITS.find(u => u.unit === unitSel.value).mult;
|
||||||
|
state.eventReminders[idx] = n * mult;
|
||||||
|
}
|
||||||
|
sel.addEventListener('change', () => {
|
||||||
|
if (sel.value === 'custom') {
|
||||||
|
customWrap.style.display = '';
|
||||||
|
commitCustom();
|
||||||
|
} else {
|
||||||
|
customWrap.style.display = 'none';
|
||||||
|
state.eventReminders[idx] = parseInt(sel.value, 10);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
numInput.addEventListener('input', commitCustom);
|
||||||
|
unitSel.addEventListener('change', commitCustom);
|
||||||
|
|
||||||
const rm = document.createElement('button');
|
const rm = document.createElement('button');
|
||||||
rm.type = 'button';
|
rm.type = 'button';
|
||||||
rm.className = 'icon-btn ev-reminder-remove';
|
rm.className = 'icon-btn ev-reminder-remove';
|
||||||
@@ -1880,6 +1941,7 @@ function renderReminderRows() {
|
|||||||
renderReminderRows();
|
renderReminderRows();
|
||||||
});
|
});
|
||||||
row.appendChild(sel);
|
row.appendChild(sel);
|
||||||
|
row.appendChild(customWrap);
|
||||||
row.appendChild(rm);
|
row.appendChild(rm);
|
||||||
list.appendChild(row);
|
list.appendChild(row);
|
||||||
});
|
});
|
||||||
@@ -1887,16 +1949,30 @@ function renderReminderRows() {
|
|||||||
|
|
||||||
function addReminderRow() {
|
function addReminderRow() {
|
||||||
const def = (state.settings && state.settings.default_reminder_minutes != null)
|
const def = (state.settings && state.settings.default_reminder_minutes != null)
|
||||||
? state.settings.default_reminder_minutes : 10;
|
? state.settings.default_reminder_minutes : 30;
|
||||||
state.eventReminders.push(REMINDER_OPTIONS.includes(def) ? def : 10);
|
state.eventReminders.push(def >= 0 ? def : 30);
|
||||||
renderReminderRows();
|
renderReminderRows();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reminders apply to local events only (the server stores them on local events).
|
// Reminders apply to local events only (the server stores them on local events).
|
||||||
|
// When the selected calendar has its reminders disabled, we keep any existing
|
||||||
|
// reminders intact (never delete them) but grey out the controls and explain
|
||||||
|
// that they won't fire — matching the apps' behaviour.
|
||||||
function updateRemindersRow() {
|
function updateRemindersRow() {
|
||||||
const calVal = document.getElementById('ev-calendar').value || '';
|
const calVal = document.getElementById('ev-calendar').value || '';
|
||||||
const isLocal = calVal.startsWith('local-');
|
const isLocal = calVal.startsWith('local-');
|
||||||
document.getElementById('ev-reminders-group').style.display = isLocal ? '' : 'none';
|
const group = document.getElementById('ev-reminders-group');
|
||||||
|
group.style.display = isLocal ? '' : 'none';
|
||||||
|
if (!isLocal) return;
|
||||||
|
|
||||||
|
const calId = parseInt(calVal.slice('local-'.length), 10);
|
||||||
|
const cal = (state.localCalendars || []).find(c => c.id === calId);
|
||||||
|
const disabled = !!(cal && cal.reminders_enabled === false);
|
||||||
|
|
||||||
|
const hint = document.getElementById('ev-reminders-hint');
|
||||||
|
if (hint) hint.style.display = disabled ? '' : 'none';
|
||||||
|
group.classList.toggle('reminders-disabled', disabled);
|
||||||
|
group.querySelectorAll('select, input, button').forEach(el => { el.disabled = disabled; });
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRruleFromUI() {
|
function buildRruleFromUI() {
|
||||||
@@ -2928,6 +3004,7 @@ function openSettingsModal() {
|
|||||||
prev.style.background = val || fallback;
|
prev.style.background = val || fallback;
|
||||||
});
|
});
|
||||||
document.getElementById('cfg-dim-past').checked = !!s.dim_past_events;
|
document.getElementById('cfg-dim-past').checked = !!s.dim_past_events;
|
||||||
|
document.getElementById('cfg-default-duration').value = String(s.default_event_duration_minutes || 60);
|
||||||
document.getElementById('cfg-language').value = getLang();
|
document.getElementById('cfg-language').value = getLang();
|
||||||
document.getElementById('cfg-private-visibility').value = s.private_event_visibility || 'busy';
|
document.getElementById('cfg-private-visibility').value = s.private_event_visibility || 'busy';
|
||||||
renderGroupVisibleList(s.group_visible_calendar_id);
|
renderGroupVisibleList(s.group_visible_calendar_id);
|
||||||
@@ -3438,6 +3515,7 @@ function bindSettingsModal() {
|
|||||||
line_color: colourOrNull('cfg-line-color-hex'),
|
line_color: colourOrNull('cfg-line-color-hex'),
|
||||||
bg_color: colourOrNull('cfg-bg-color-hex'),
|
bg_color: colourOrNull('cfg-bg-color-hex'),
|
||||||
dim_past_events: document.getElementById('cfg-dim-past').checked,
|
dim_past_events: document.getElementById('cfg-dim-past').checked,
|
||||||
|
default_event_duration_minutes: parseInt(document.getElementById('cfg-default-duration').value, 10) || 60,
|
||||||
hour_height: getActive('cfg-hour-height') || 44,
|
hour_height: getActive('cfg-hour-height') || 44,
|
||||||
language: document.getElementById('cfg-language').value,
|
language: document.getElementById('cfg-language').value,
|
||||||
private_event_visibility: document.getElementById('cfg-private-visibility').value,
|
private_event_visibility: document.getElementById('cfg-private-visibility').value,
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ const translations = {
|
|||||||
settings_week_start: 'Erster Wochentag',
|
settings_week_start: 'Erster Wochentag',
|
||||||
week_start_monday: 'Montag', week_start_sunday: 'Sonntag',
|
week_start_monday: 'Montag', week_start_sunday: 'Sonntag',
|
||||||
settings_dim_past: 'Vergangene Termine ausgrauen',
|
settings_dim_past: 'Vergangene Termine ausgrauen',
|
||||||
|
settings_default_duration: 'Standard-Termindauer',
|
||||||
settings_privacy: 'Privatsphäre',
|
settings_privacy: 'Privatsphäre',
|
||||||
settings_private_visibility: 'Private Termine für Gruppenmitglieder',
|
settings_private_visibility: 'Private Termine für Gruppenmitglieder',
|
||||||
settings_private_visibility_desc: 'Wie private Termine für andere Gruppenmitglieder erscheinen',
|
settings_private_visibility_desc: 'Wie private Termine für andere Gruppenmitglieder erscheinen',
|
||||||
@@ -104,6 +105,13 @@ const translations = {
|
|||||||
reminder_days: '{n} Tage vorher',
|
reminder_days: '{n} Tage vorher',
|
||||||
reminder_week_one: '1 Woche vorher',
|
reminder_week_one: '1 Woche vorher',
|
||||||
reminder_weeks: '{n} Wochen vorher',
|
reminder_weeks: '{n} Wochen vorher',
|
||||||
|
reminder_custom: 'Benutzerdefiniert…',
|
||||||
|
reminder_unit_minutes: 'Minuten',
|
||||||
|
reminder_unit_hours: 'Stunden',
|
||||||
|
reminder_unit_days: 'Tage',
|
||||||
|
reminder_unit_weeks: 'Wochen',
|
||||||
|
reminder_before: 'vorher',
|
||||||
|
reminders_calendar_off: 'Für diesen Kalender sind Benachrichtigungen deaktiviert – Erinnerungen werden nicht ausgeführt.',
|
||||||
calendar_reminders_on: 'Benachrichtigungen aktiviert',
|
calendar_reminders_on: 'Benachrichtigungen aktiviert',
|
||||||
calendar_reminders_off: 'Benachrichtigungen deaktiviert',
|
calendar_reminders_off: 'Benachrichtigungen deaktiviert',
|
||||||
share: 'Teilen',
|
share: 'Teilen',
|
||||||
@@ -372,6 +380,7 @@ const translations = {
|
|||||||
settings_week_start: 'First day of week',
|
settings_week_start: 'First day of week',
|
||||||
week_start_monday: 'Monday', week_start_sunday: 'Sunday',
|
week_start_monday: 'Monday', week_start_sunday: 'Sunday',
|
||||||
settings_dim_past: 'Dim past events',
|
settings_dim_past: 'Dim past events',
|
||||||
|
settings_default_duration: 'Default event duration',
|
||||||
settings_privacy: 'Privacy',
|
settings_privacy: 'Privacy',
|
||||||
settings_private_visibility: 'Private events for group members',
|
settings_private_visibility: 'Private events for group members',
|
||||||
settings_private_visibility_desc: 'How your private events appear to other group members',
|
settings_private_visibility_desc: 'How your private events appear to other group members',
|
||||||
@@ -389,6 +398,13 @@ const translations = {
|
|||||||
reminder_days: '{n} days before',
|
reminder_days: '{n} days before',
|
||||||
reminder_week_one: '1 week before',
|
reminder_week_one: '1 week before',
|
||||||
reminder_weeks: '{n} weeks before',
|
reminder_weeks: '{n} weeks before',
|
||||||
|
reminder_custom: 'Custom…',
|
||||||
|
reminder_unit_minutes: 'minutes',
|
||||||
|
reminder_unit_hours: 'hours',
|
||||||
|
reminder_unit_days: 'days',
|
||||||
|
reminder_unit_weeks: 'weeks',
|
||||||
|
reminder_before: 'before',
|
||||||
|
reminders_calendar_off: 'Reminders are disabled for this calendar – they will not fire.',
|
||||||
calendar_reminders_on: 'Reminders enabled',
|
calendar_reminders_on: 'Reminders enabled',
|
||||||
calendar_reminders_off: 'Reminders disabled',
|
calendar_reminders_off: 'Reminders disabled',
|
||||||
share: 'Share',
|
share: 'Share',
|
||||||
|
|||||||
@@ -142,6 +142,79 @@ function contrastRatio(c1, c2) {
|
|||||||
} catch { return 21; }
|
} catch { return 21; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Safe HTML rendering for event descriptions ───────────────────────────
|
||||||
|
// External calendars (CalDAV, iCal) often store rich-text descriptions as raw
|
||||||
|
// HTML. We render a small allowlist of formatting tags so links/line breaks
|
||||||
|
// look right, but strip everything dangerous (scripts, event handlers, inline
|
||||||
|
// styles) — we never execute code from a description.
|
||||||
|
const ALLOWED_TAGS = new Set(['A', 'BR', 'P', 'DIV', 'B', 'STRONG', 'I', 'EM', 'U', 'UL', 'OL', 'LI', 'SPAN']);
|
||||||
|
const URL_RE = /(https?:\/\/[^\s<]+[^\s<.,:;!?)\]}'"])/g;
|
||||||
|
|
||||||
|
function makeSafeLink(href, text) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = href;
|
||||||
|
a.textContent = text;
|
||||||
|
a.target = '_blank';
|
||||||
|
a.rel = 'noopener noreferrer';
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace bare URLs inside a text node with clickable <a> elements.
|
||||||
|
function linkifyTextNode(node, out) {
|
||||||
|
const text = node.nodeValue;
|
||||||
|
let last = 0;
|
||||||
|
let m;
|
||||||
|
URL_RE.lastIndex = 0;
|
||||||
|
while ((m = URL_RE.exec(text)) !== null) {
|
||||||
|
if (m.index > last) out.appendChild(document.createTextNode(text.slice(last, m.index)));
|
||||||
|
out.appendChild(makeSafeLink(m[0], m[0]));
|
||||||
|
last = m.index + m[0].length;
|
||||||
|
}
|
||||||
|
if (last < text.length) out.appendChild(document.createTextNode(text.slice(last)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively copy `src` into `dest`, keeping only allowlisted tags/attributes.
|
||||||
|
function sanitizeInto(src, dest) {
|
||||||
|
src.childNodes.forEach(node => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
linkifyTextNode(node, dest);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||||
|
const tag = node.tagName;
|
||||||
|
if (!ALLOWED_TAGS.has(tag)) {
|
||||||
|
// Drop the tag but keep its (sanitized) contents.
|
||||||
|
sanitizeInto(node, dest);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let el;
|
||||||
|
if (tag === 'A') {
|
||||||
|
const href = node.getAttribute('href') || '';
|
||||||
|
if (/^(https?:|mailto:)/i.test(href)) {
|
||||||
|
el = makeSafeLink(href, '');
|
||||||
|
} else {
|
||||||
|
// Unsafe/relative href: render as plain text container.
|
||||||
|
el = document.createElement('span');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
el = document.createElement(tag.toLowerCase());
|
||||||
|
}
|
||||||
|
sanitizeInto(node, el);
|
||||||
|
dest.appendChild(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a sanitized HTML string for an event description, safe for innerHTML.
|
||||||
|
export function renderDescriptionHtml(raw) {
|
||||||
|
if (!raw) return '';
|
||||||
|
const hasHtml = /<[a-z][\s\S]*>/i.test(raw);
|
||||||
|
const doc = new DOMParser().parseFromString(
|
||||||
|
hasHtml ? raw : raw.replace(/\n/g, '<br>'), 'text/html');
|
||||||
|
const out = document.createElement('div');
|
||||||
|
sanitizeInto(doc.body, out);
|
||||||
|
return out.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
function hexToRgba(hex, alpha) {
|
function hexToRgba(hex, alpha) {
|
||||||
const r = parseInt(hex.slice(1,3), 16);
|
const r = parseInt(hex.slice(1,3), 16);
|
||||||
const g = parseInt(hex.slice(3,5), 16);
|
const g = parseInt(hex.slice(3,5), 16);
|
||||||
|
|||||||
Reference in New Issue
Block a user