fix: Import-500 bei doppelten UIDs, Picker-UI & Settings-URL-State

- Import: Dedupe doppelter UIDs innerhalb der Datei (Nextcloud exportiert
  wiederkehrende Termine als mehrere VEVENTs gleicher UID) -> kein
  UNIQUE-constraint-500 mehr; Commit abgesichert. Test ergaenzt (15 gruen).
- Picker (Gruppen-Sichtbarkeit + Mitglieder): als <div>-Zeilen statt <label>,
  damit die globale ".form-group label"-Uppercase/Grau-Regel das Layout nicht
  mehr zerschiesst. Saubere .pick-row-Optik (Checkbox/Radio links, Name links).
- Einstellungen haben jetzt eigenen URL-State (#...&settings=1): Reload/Cache-
  leeren bleibt in den Einstellungen statt zur Kalenderansicht zu springen.
- Version v27.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-05-31 17:15:17 +02:00
parent 8abeefcb5a
commit c7185a128e
5 changed files with 133 additions and 25 deletions

View File

@@ -386,8 +386,17 @@ def _import_ics_into(cal: models.LocalCalendar, raw: bytes, db: Session) -> dict
parsed = ical_io.parse_ics(raw)
imported = 0
skipped = 0
errors = list(parsed["errors"])
# local_events.uid is globally unique. Dedupe against the DB AND within this
# file — e.g. Nextcloud exports recurring events as several VEVENTs sharing a
# UID (RECURRENCE-ID overrides), which would otherwise violate the constraint.
seen_uids: set[str] = set()
for item in parsed["events"]:
uid = item.get("uid") or str(uuid.uuid4())
if uid in seen_uids:
skipped += 1
continue
seen_uids.add(uid)
existing = db.query(models.LocalEvent).filter(models.LocalEvent.uid == uid).first()
if existing:
skipped += 1
@@ -407,8 +416,12 @@ def _import_ics_into(cal: models.LocalCalendar, raw: bytes, db: Session) -> dict
)
db.add(ev)
imported += 1
db.commit()
return {"imported": imported, "skipped": skipped, "errors": parsed["errors"]}
try:
db.commit()
except Exception as exc:
db.rollback()
raise ValueError(f"Import fehlgeschlagen: {exc}")
return {"imported": imported, "skipped": skipped, "errors": errors}
@router.post("/calendars/{calendar_id}/import")

View File

@@ -289,6 +289,39 @@ def test_import_dedupes_by_uid(client):
assert imported and imported[0]["creator"]["display_name"] == "Max Mustermann (importiert)"
DUP_UID_ICS = b"""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Nextcloud
BEGIN:VEVENT
UID:recurring@nc
SUMMARY:Standup
DTSTART:20260601T090000Z
DTEND:20260601T091500Z
RRULE:FREQ=WEEKLY;BYDAY=MO
END:VEVENT
BEGIN:VEVENT
UID:recurring@nc
RECURRENCE-ID:20260608T090000Z
SUMMARY:Standup verschoben
DTSTART:20260608T100000Z
DTEND:20260608T101500Z
END:VEVENT
END:VCALENDAR
"""
def test_import_handles_duplicate_uid_in_file(client):
"""Nextcloud exports recurring events as multiple VEVENTs sharing a UID;
importing must not 500 on the unique constraint."""
admin = register_admin(client)
cal_id = _make_calendar(client, admin, "NC")
files = {"file": ("nc.ics", DUP_UID_ICS, "text/calendar")}
r = client.post(f"/api/local/calendars/{cal_id}/import", headers=auth(admin), files=files)
assert r.status_code == 200, r.text
body = r.json()
assert body["imported"] == 1 and body["skipped"] == 1
def test_export_contains_organizer_and_rrule(client):
admin = register_admin(client)
cal_id = _make_calendar(client, admin, "Export-Test")

View File

@@ -1859,3 +1859,36 @@ a { color: var(--primary); text-decoration: none; }
.cal-radio-item:last-child { border-bottom: none; }
.cal-radio-item:hover { background: var(--bg-surface); }
.cal-radio-item .cal-item-dot { border-radius: 50%; flex: 0 0 auto; }
/* Picker rows (group-visible calendar radio + group member checkboxes).
Deliberately NOT <label> elements, so the global ".form-group label"
uppercase/grey styling never applies. */
.cal-radio-list,
#group-member-picker {
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
max-height: 260px;
overflow-y: auto;
}
.pick-row {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
padding: 10px 14px;
cursor: pointer;
border-bottom: 1px solid var(--border);
text-align: left;
text-transform: none;
letter-spacing: normal;
font-size: 14px;
color: var(--text-1);
}
.pick-row:last-child { border-bottom: none; }
.pick-row:hover { background: var(--bg-surface); }
.pick-row-sel { background: rgba(66, 133, 244, 0.12); }
.pick-mark { flex: 0 0 auto; width: 18px; text-align: center; color: var(--primary); }
.pick-dot { flex: 0 0 auto; width: 12px; height: 12px; border-radius: 50%; }
.pick-dot-empty { background: transparent; }
.pick-name { flex: 1 1 auto; text-align: left; }

View File

@@ -70,13 +70,19 @@ function readUrlState() {
const d = new Date(date + 'T00:00:00');
if (!isNaN(d.getTime())) out.date = d;
}
out.settings = params.get('settings') === '1';
return out;
}
// Tracks whether the settings modal is open, so a reload returns to settings
// instead of the calendar view.
let uiSettingsOpen = false;
function writeUrlState() {
const d = state.currentDate;
const dateStr = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
const newHash = `date=${dateStr}&view=${state.currentView}`;
let newHash = `date=${dateStr}&view=${state.currentView}`;
if (uiSettingsOpen) newHash += '&settings=1';
if (window.location.hash.replace(/^#/,'') !== newHash) {
// replaceState statt pushState: prev/next-Klicks sollen nicht jeden
// einzelnen Tag in den Browser-History-Stack drücken
@@ -130,6 +136,9 @@ export async function initCalendar() {
handleHAOAuthReturn();
loadGroups();
// Reopen the settings modal after a reload if the URL says we were in it.
if (readUrlState().settings) openSettingsModal();
// Browser-Back/Forward: URL-Hash → State synchronisieren
window.addEventListener('hashchange', () => {
const u = readUrlState();
@@ -2292,17 +2301,19 @@ function renderGroupMemberPicker() {
const picked = modal.__memberIds || new Set();
const picker = document.getElementById('group-member-picker');
picker.innerHTML = dir.length
? dir.map(u =>
`<label class="group-member-item">
<input type="checkbox" data-member-id="${u.id}" ${picked.has(u.id) ? 'checked' : ''} />
<span class="group-member-name">${escHtml(u.display_name || '')}</span>
</label>`
).join('')
? dir.map(u => {
const on = picked.has(u.id);
return `<div class="pick-row ${on ? 'pick-row-sel' : ''}" data-member-id="${u.id}" role="checkbox" aria-checked="${on}">
<span class="pick-mark">${on ? '☑' : ''}</span>
<span class="pick-name">${escHtml(u.display_name || '')}</span>
</div>`;
}).join('')
: `<span class="accounts-section-empty">${t('share_no_users')}</span>`;
picker.querySelectorAll('input[data-member-id]').forEach(cb => {
cb.addEventListener('change', () => {
const id = parseInt(cb.dataset.memberId);
if (cb.checked) picked.add(id); else picked.delete(id);
picker.querySelectorAll('.pick-row').forEach(rowEl => {
rowEl.addEventListener('click', () => {
const id = parseInt(rowEl.dataset.memberId);
if (picked.has(id)) picked.delete(id); else picked.add(id);
renderGroupMemberPicker();
});
});
}
@@ -2360,22 +2371,36 @@ function bindGroupUI() {
function renderGroupVisibleList(selectedId) {
const el = document.getElementById('cfg-group-visible-list');
if (!el) return;
el.dataset.selected = (selectedId == null) ? '' : String(selectedId);
const own = state.localCalendars.filter(c => c.owned !== false && !c.group);
const opt = (id, name, color) => {
const checked = (id === null && (selectedId == null)) || id === selectedId;
const dot = color ? `<span class="cal-item-dot" style="background:${color};width:12px;height:12px"></span>` : '';
return `<label class="cal-radio-item">
<input type="radio" name="cfg-group-visible" value="${id == null ? '' : id}" ${checked ? 'checked' : ''} />
${dot}<span>${escHtml(name)}</span>
</label>`;
const selVal = el.dataset.selected;
const row = (id, name, color) => {
const val = (id == null) ? '' : String(id);
const sel = val === selVal;
const dot = color
? `<span class="pick-dot" style="background:${color}"></span>`
: `<span class="pick-dot pick-dot-empty"></span>`;
return `<div class="pick-row ${sel ? 'pick-row-sel' : ''}" data-pick="${val}" role="radio" aria-checked="${sel}">
<span class="pick-mark">${sel ? '●' : '○'}</span>
${dot}
<span class="pick-name">${escHtml(name)}</span>
</div>`;
};
el.innerHTML =
opt(null, t('group_visible_none'), null) +
own.map(c => opt(c.id, c.name, c.color)).join('');
row(null, t('group_visible_none'), null) +
own.map(c => row(c.id, c.name, c.color)).join('');
el.querySelectorAll('.pick-row').forEach(r => {
r.addEventListener('click', () => {
el.dataset.selected = r.dataset.pick;
renderGroupVisibleList(r.dataset.pick === '' ? null : parseInt(r.dataset.pick));
});
});
}
// ── Settings Modal ────────────────────────────────────────
function openSettingsModal() {
uiSettingsOpen = true;
writeUrlState();
const s = state.settings;
document.getElementById('cfg-default-view').value = s.default_view || 'month';
document.getElementById('cfg-week-start').value = s.week_start_day || 'monday';
@@ -2888,8 +2913,8 @@ function bindSettingsModal() {
language: document.getElementById('cfg-language').value,
private_event_visibility: document.getElementById('cfg-private-visibility').value,
};
const gvSel = document.querySelector('input[name="cfg-group-visible"]:checked');
settings.group_visible_calendar_id = gvSel && gvSel.value ? parseInt(gvSel.value) : null;
const gvVal = document.getElementById('cfg-group-visible-list')?.dataset.selected;
settings.group_visible_calendar_id = gvVal ? parseInt(gvVal) : null;
try {
await api.put('/settings/', settings);
state.settings = { ...state.settings, ...settings };
@@ -3205,6 +3230,10 @@ function openModal(id) {
}
function closeModal(id) {
document.getElementById(id).classList.add('hidden');
if (id === 'modal-settings') {
uiSettingsOpen = false;
writeUrlState();
}
}
// Close button bindings (added once)

View File

@@ -1,2 +1,2 @@
// Increment APP_VERSION with every code change
export const APP_VERSION = 'v26';
export const APP_VERSION = 'v27';