globalThis.Wegwichtel ??= {}; (function (ns) { 'use strict'; const t = (key, vars) => ns.I18n.t(`point-locks.${key}`, vars); let database; let current = null; let generation = 0; let activity = 0; let sentActivity = 0; let touching = false; const key = (routeId, poiId) => `${routeId}:${poiId}`; async function store(mode, operation) { if (!database) database = new Promise((resolve, reject) => { const request = indexedDB.open('wegwichtel.group-point-drafts.v1', 1); request.onupgradeneeded = () => request.result.createObjectStore('drafts', { keyPath: 'key' }); request.onsuccess = () => resolve(request.result); request.onerror = () => { database = null; reject(request.error); }; request.onblocked = () => { database = null; reject(new Error(t('storage-error'))); }; }); const db = await database; return new Promise((resolve, reject) => { const transaction = db.transaction('drafts', mode); const request = operation(transaction.objectStore('drafts')); transaction.oncomplete = () => resolve(request.result); transaction.onabort = () => reject(transaction.error || new Error(t('storage-error'))); transaction.onerror = () => reject(transaction.error || new Error(t('storage-error'))); }); } const read = (routeId, poiId) => store('readonly', target => target.get(key(routeId, poiId))); const list = () => store('readonly', target => target.getAll()); async function remove(routeId, poiId) { await store('readwrite', target => target.delete(key(routeId, poiId))); document.dispatchEvent(new Event('wegwichtel:point-drafts-changed')); } async function remember(route, poiId, values, media) { const previous = await read(route.id, poiId); const draft = { key: key(route.id, poiId), route: { id: route.id, name: route.name, description: route.description, creator_id: route.creator_id }, poiId, values, media, baseVersion: previous?.baseVersion || current?.version || null, baseMediaVersion: previous?.baseMediaVersion || current?.mediaVersion || null, basePoi: previous?.basePoi || current?.poi || { ...values, id: poiId }, savedAt: new Date().toISOString() }; try { await store('readwrite', target => target.put(draft)); } catch { throw new Error(t('storage-error')); } document.dispatchEvent(new Event('wegwichtel:point-drafts-changed')); return draft; } async function request(routeId, poiId, body) { const response = await fetch(`${(ns.Config?.apiBase || '/api').replace(/\/$/, '')}/groups/routes/${routeId}/pois/${poiId}/lock`, { method: 'POST', credentials: 'same-origin', cache: 'no-store', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (response.status === 204) return {}; const payload = await response.json(); if (!response.ok) { const error = new Error(t(response.status === 423 ? 'lost' : 'unavailable')); error.status = response.status; error.details = payload.details; throw error; } return payload; } async function acquire(routeId, poiId, token = null) { try { return await request(routeId, poiId, { token }); } catch (error) { if (error.details?.code !== 'point-locked') throw error; if (!globalThis.confirm(t('takeover'))) return null; // Compare-and-swap: a second takeover while the question was open needs a new confirmation. return request(routeId, poiId, { replace: error.details.replace }); } } async function release() { generation += 1; const previous = current; current = null; if (!previous?.token) return; try { await request(previous.routeId, previous.poiId, { release: true, token: previous.token }); } catch { /* Another editor may own the point now; never remove their lock. */ } } async function begin(routeId, poi) { await release(); const openedGeneration = generation; const draft = await read(routeId, poi.id); let result; try { result = await acquire(routeId, poi.id); } catch (error) { if (openedGeneration !== generation) return null; if (!draft) throw error; if (!globalThis.confirm(t('offline-restore'))) return null; current = { routeId, poiId: poi.id, token: null, version: draft.baseVersion, mediaVersion: draft.baseMediaVersion, poi: draft.basePoi }; return { poi: draft.basePoi, draft }; } if (!result) return null; if (openedGeneration !== generation) { await request(routeId, poi.id, { release: true, token: result.token }).catch(() => {}); return null; } current = { ...result, routeId, poiId: poi.id }; if (draft && !globalThis.confirm(t('restore'))) { await release(); return null; } // Keep the old baseline until the saved draft has been compared with the server. if (draft) current = { ...current, version: draft.baseVersion, mediaVersion: draft.baseMediaVersion, poi: draft.basePoi }; return { poi: result.poi, draft }; } function compare(draft, remote) { return new Promise(resolve => { const dialog = document.getElementById('point-compare-dialog'); const content = document.getElementById('point-compare-content'); const table = document.createElement('table'); const header = document.createElement('tr'); for (const label of ['field', 'before', 'mine', 'theirs']) { const cell = document.createElement('th'); cell.textContent = t(label); header.appendChild(cell); } table.appendChild(header); for (const field of ['title', 'description', 'lat', 'lon', 'triggerRadiusM', 'sequence']) { const row = document.createElement('tr'); for (const value of [t(`fields.${field === 'triggerRadiusM' ? 'trigger-radius' : field}`), draft.basePoi?.[field], draft.values[field], remote.poi[field]]) { const cell = document.createElement('td'); cell.textContent = String(value ?? '—'); row.appendChild(cell); } table.appendChild(row); } const summary = document.createElement('p'); const media = poi => t('media-count', { images: poi?.images?.length || 0, audio: poi?.audioUrl ? 1 : 0 }); const files = [...(draft.media?.images || []).map(file => file.name), draft.media?.audio?.name].filter(Boolean).join(', '); summary.textContent = t('media-compare', { before: media(draft.basePoi), theirs: media(remote.poi), files: files || '—', removed: draft.media?.deletions?.length || 0 }); const finish = accepted => { dialog.removeEventListener('close', cancel); document.getElementById('point-compare-accept').removeEventListener('click', accept); document.getElementById('point-compare-cancel').removeEventListener('click', cancel); dialog.close(); resolve(accepted); }; const accept = () => finish(true); const cancel = () => finish(false); document.getElementById('point-compare-accept').addEventListener('click', accept); document.getElementById('point-compare-cancel').addEventListener('click', cancel); dialog.addEventListener('close', cancel); const fileStatus = document.createElement('p'); fileStatus.textContent = t(draft.baseMediaVersion === remote.mediaVersion ? 'files-unchanged' : 'files-changed'); const previews = document.createElement('div'); for (const image of remote.poi.images || []) { const preview = document.createElement('img'); preview.src = image.url; preview.alt = image.caption || ''; previews.appendChild(preview); } if (remote.poi.audioUrl) { const audio = document.createElement('audio'); audio.controls = true; audio.preload = 'none'; audio.src = remote.poi.audioUrl; previews.appendChild(audio); } const warning = document.createElement('p'); warning.textContent = draft.media?.deletePoint ? t('delete-warning') : ''; content.replaceChildren(table, summary, fileStatus, previews, warning); dialog.showModal(); }); } async function prepare(draft) { const savingGeneration = generation; const remote = await acquire(draft.route.id, draft.poiId, current?.token); if (!remote) return false; if (savingGeneration !== generation) { await request(draft.route.id, draft.poiId, { release: true, token: remote.token }).catch(() => {}); return false; } current = { ...remote, routeId: draft.route.id, poiId: draft.poiId }; if (draft.baseVersion !== remote.version && !await compare(draft, remote)) { await release(); return false; } // Deleting an already absent file is a no-op after an explicit content comparison. draft.media.deletions = (draft.media.deletions || []).filter(item => item.path === 'audio' ? Boolean(remote.poi.audioUrl) : remote.poi.images.some(image => item.path === `pictures/${image.id}`)); if (savingGeneration !== generation) return false; return remote; } function noteActivity() { activity += 1; } async function keepActive() { if (!current?.token || activity === sentActivity || touching) return; const editing = current; const observed = activity; touching = true; try { await request(editing.routeId, editing.poiId, { heartbeat: true, token: editing.token }); if (editing === current) sentActivity = observed; } catch (error) { if (error.status === 423) { sentActivity = observed; document.dispatchEvent(new Event('wegwichtel:point-lock-lost')); } } finally { touching = false; } } globalThis.setInterval?.(() => { void keepActive(); }, 30_000); function headersFor(path) { const match = /^\/routes\/(\d+)\/pois\/(\d+)(?:\/|$)/.exec(path); if (!match || !current || String(current.routeId) !== match[1] || String(current.poiId) !== match[2]) return {}; return { 'X-Poi-Lock': current.token || '', 'If-Poi-Match': current.version || '' }; } function observe(response) { const version = response.headers?.get('X-Poi-Version'); if (current && String(current.poiId) === response.headers?.get('X-Poi-Id') && version) current.version = version; } ns.GroupPoints = Object.freeze({ begin, release, remember, read, list, remove, prepare, headersFor, observe, noteActivity, keepActive, acceptNew: async (routeId, poi) => { current = { ...await acquire(routeId, poi.id), routeId, poiId: poi.id }; }, changed: () => document.dispatchEvent(new Event('wegwichtel:point-drafts-changed')) }); }(globalThis.Wegwichtel));