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:
@@ -386,8 +386,17 @@ def _import_ics_into(cal: models.LocalCalendar, raw: bytes, db: Session) -> dict
|
|||||||
parsed = ical_io.parse_ics(raw)
|
parsed = ical_io.parse_ics(raw)
|
||||||
imported = 0
|
imported = 0
|
||||||
skipped = 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"]:
|
for item in parsed["events"]:
|
||||||
uid = item.get("uid") or str(uuid.uuid4())
|
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()
|
existing = db.query(models.LocalEvent).filter(models.LocalEvent.uid == uid).first()
|
||||||
if existing:
|
if existing:
|
||||||
skipped += 1
|
skipped += 1
|
||||||
@@ -407,8 +416,12 @@ def _import_ics_into(cal: models.LocalCalendar, raw: bytes, db: Session) -> dict
|
|||||||
)
|
)
|
||||||
db.add(ev)
|
db.add(ev)
|
||||||
imported += 1
|
imported += 1
|
||||||
db.commit()
|
try:
|
||||||
return {"imported": imported, "skipped": skipped, "errors": parsed["errors"]}
|
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")
|
@router.post("/calendars/{calendar_id}/import")
|
||||||
|
|||||||
@@ -289,6 +289,39 @@ def test_import_dedupes_by_uid(client):
|
|||||||
assert imported and imported[0]["creator"]["display_name"] == "Max Mustermann (importiert)"
|
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):
|
def test_export_contains_organizer_and_rrule(client):
|
||||||
admin = register_admin(client)
|
admin = register_admin(client)
|
||||||
cal_id = _make_calendar(client, admin, "Export-Test")
|
cal_id = _make_calendar(client, admin, "Export-Test")
|
||||||
|
|||||||
@@ -1859,3 +1859,36 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
.cal-radio-item:last-child { border-bottom: none; }
|
.cal-radio-item:last-child { border-bottom: none; }
|
||||||
.cal-radio-item:hover { background: var(--bg-surface); }
|
.cal-radio-item:hover { background: var(--bg-surface); }
|
||||||
.cal-radio-item .cal-item-dot { border-radius: 50%; flex: 0 0 auto; }
|
.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; }
|
||||||
|
|||||||
@@ -70,13 +70,19 @@ function readUrlState() {
|
|||||||
const d = new Date(date + 'T00:00:00');
|
const d = new Date(date + 'T00:00:00');
|
||||||
if (!isNaN(d.getTime())) out.date = d;
|
if (!isNaN(d.getTime())) out.date = d;
|
||||||
}
|
}
|
||||||
|
out.settings = params.get('settings') === '1';
|
||||||
return out;
|
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() {
|
function writeUrlState() {
|
||||||
const d = state.currentDate;
|
const d = state.currentDate;
|
||||||
const dateStr = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
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) {
|
if (window.location.hash.replace(/^#/,'') !== newHash) {
|
||||||
// replaceState statt pushState: prev/next-Klicks sollen nicht jeden
|
// replaceState statt pushState: prev/next-Klicks sollen nicht jeden
|
||||||
// einzelnen Tag in den Browser-History-Stack drücken
|
// einzelnen Tag in den Browser-History-Stack drücken
|
||||||
@@ -130,6 +136,9 @@ export async function initCalendar() {
|
|||||||
handleHAOAuthReturn();
|
handleHAOAuthReturn();
|
||||||
loadGroups();
|
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
|
// Browser-Back/Forward: URL-Hash → State synchronisieren
|
||||||
window.addEventListener('hashchange', () => {
|
window.addEventListener('hashchange', () => {
|
||||||
const u = readUrlState();
|
const u = readUrlState();
|
||||||
@@ -2292,17 +2301,19 @@ function renderGroupMemberPicker() {
|
|||||||
const picked = modal.__memberIds || new Set();
|
const picked = modal.__memberIds || new Set();
|
||||||
const picker = document.getElementById('group-member-picker');
|
const picker = document.getElementById('group-member-picker');
|
||||||
picker.innerHTML = dir.length
|
picker.innerHTML = dir.length
|
||||||
? dir.map(u =>
|
? dir.map(u => {
|
||||||
`<label class="group-member-item">
|
const on = picked.has(u.id);
|
||||||
<input type="checkbox" data-member-id="${u.id}" ${picked.has(u.id) ? 'checked' : ''} />
|
return `<div class="pick-row ${on ? 'pick-row-sel' : ''}" data-member-id="${u.id}" role="checkbox" aria-checked="${on}">
|
||||||
<span class="group-member-name">${escHtml(u.display_name || '')}</span>
|
<span class="pick-mark">${on ? '☑' : '☐'}</span>
|
||||||
</label>`
|
<span class="pick-name">${escHtml(u.display_name || '')}</span>
|
||||||
).join('')
|
</div>`;
|
||||||
|
}).join('')
|
||||||
: `<span class="accounts-section-empty">${t('share_no_users')}</span>`;
|
: `<span class="accounts-section-empty">${t('share_no_users')}</span>`;
|
||||||
picker.querySelectorAll('input[data-member-id]').forEach(cb => {
|
picker.querySelectorAll('.pick-row').forEach(rowEl => {
|
||||||
cb.addEventListener('change', () => {
|
rowEl.addEventListener('click', () => {
|
||||||
const id = parseInt(cb.dataset.memberId);
|
const id = parseInt(rowEl.dataset.memberId);
|
||||||
if (cb.checked) picked.add(id); else picked.delete(id);
|
if (picked.has(id)) picked.delete(id); else picked.add(id);
|
||||||
|
renderGroupMemberPicker();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2360,22 +2371,36 @@ function bindGroupUI() {
|
|||||||
function renderGroupVisibleList(selectedId) {
|
function renderGroupVisibleList(selectedId) {
|
||||||
const el = document.getElementById('cfg-group-visible-list');
|
const el = document.getElementById('cfg-group-visible-list');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
el.dataset.selected = (selectedId == null) ? '' : String(selectedId);
|
||||||
const own = state.localCalendars.filter(c => c.owned !== false && !c.group);
|
const own = state.localCalendars.filter(c => c.owned !== false && !c.group);
|
||||||
const opt = (id, name, color) => {
|
const selVal = el.dataset.selected;
|
||||||
const checked = (id === null && (selectedId == null)) || id === selectedId;
|
const row = (id, name, color) => {
|
||||||
const dot = color ? `<span class="cal-item-dot" style="background:${color};width:12px;height:12px"></span>` : '';
|
const val = (id == null) ? '' : String(id);
|
||||||
return `<label class="cal-radio-item">
|
const sel = val === selVal;
|
||||||
<input type="radio" name="cfg-group-visible" value="${id == null ? '' : id}" ${checked ? 'checked' : ''} />
|
const dot = color
|
||||||
${dot}<span>${escHtml(name)}</span>
|
? `<span class="pick-dot" style="background:${color}"></span>`
|
||||||
</label>`;
|
: `<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 =
|
el.innerHTML =
|
||||||
opt(null, t('group_visible_none'), null) +
|
row(null, t('group_visible_none'), null) +
|
||||||
own.map(c => opt(c.id, c.name, c.color)).join('');
|
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 ────────────────────────────────────────
|
// ── Settings Modal ────────────────────────────────────────
|
||||||
function openSettingsModal() {
|
function openSettingsModal() {
|
||||||
|
uiSettingsOpen = true;
|
||||||
|
writeUrlState();
|
||||||
const s = state.settings;
|
const s = state.settings;
|
||||||
document.getElementById('cfg-default-view').value = s.default_view || 'month';
|
document.getElementById('cfg-default-view').value = s.default_view || 'month';
|
||||||
document.getElementById('cfg-week-start').value = s.week_start_day || 'monday';
|
document.getElementById('cfg-week-start').value = s.week_start_day || 'monday';
|
||||||
@@ -2888,8 +2913,8 @@ function bindSettingsModal() {
|
|||||||
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,
|
||||||
};
|
};
|
||||||
const gvSel = document.querySelector('input[name="cfg-group-visible"]:checked');
|
const gvVal = document.getElementById('cfg-group-visible-list')?.dataset.selected;
|
||||||
settings.group_visible_calendar_id = gvSel && gvSel.value ? parseInt(gvSel.value) : null;
|
settings.group_visible_calendar_id = gvVal ? parseInt(gvVal) : null;
|
||||||
try {
|
try {
|
||||||
await api.put('/settings/', settings);
|
await api.put('/settings/', settings);
|
||||||
state.settings = { ...state.settings, ...settings };
|
state.settings = { ...state.settings, ...settings };
|
||||||
@@ -3205,6 +3230,10 @@ function openModal(id) {
|
|||||||
}
|
}
|
||||||
function closeModal(id) {
|
function closeModal(id) {
|
||||||
document.getElementById(id).classList.add('hidden');
|
document.getElementById(id).classList.add('hidden');
|
||||||
|
if (id === 'modal-settings') {
|
||||||
|
uiSettingsOpen = false;
|
||||||
|
writeUrlState();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close button bindings (added once)
|
// Close button bindings (added once)
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// Increment APP_VERSION with every code change
|
// Increment APP_VERSION with every code change
|
||||||
export const APP_VERSION = 'v26';
|
export const APP_VERSION = 'v27';
|
||||||
|
|||||||
Reference in New Issue
Block a user