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)
|
||||
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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Increment APP_VERSION with every code change
|
||||
export const APP_VERSION = 'v26';
|
||||
export const APP_VERSION = 'v27';
|
||||
|
||||
Reference in New Issue
Block a user