Files
Wegwichtel/public/js/group-points.js
T
Florian Zumpe ae798034c5
Sonarqube Scanner / Prepare environment (push) Successful in 7s
Sonarqube Scanner / Build and test (push) Failing after 22s
Sonarqube Scanner / SonarQube analysis (push) Skipped
Sonarqube Scanner / Deploy to Server (push) Successful in 5s
fixed sonarqube findings
2026-10-18 17:48:46 +02:00

195 lines
10 KiB
JavaScript

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?.(() => { 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));