195 lines
10 KiB
JavaScript
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?.(() => { 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));
|