group editor implemented
Sonarqube Scanner / Prepare environment (push) Successful in 6s
Sonarqube Scanner / Build and test (push) Successful in 27s
Sonarqube Scanner / Deploy to Server (push) Failing after 7s
Sonarqube Scanner / SonarQube analysis (push) Failing after 1m14s

This commit is contained in:
Florian Zumpe
2026-10-18 16:33:12 +02:00
parent 9c4d5f6324
commit 31768ae907
28 changed files with 2361 additions and 55 deletions
+209 -20
View File
@@ -88,6 +88,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const state = {
currentPoi: null,
groupDeletions: [],
groupDeletePoint: false,
currentRoute: null,
currentSource: null,
pendingAudioFile: null,
@@ -212,6 +214,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}
function showListView() {
void ns.GroupPoints?.release();
releasePoiPreview();
ns.RouteRecorder?.endEditing();
setDialogTitle('Streckenverwaltung');
@@ -221,6 +224,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}
function showRouteView() {
void ns.GroupPoints?.release();
releasePoiPreview();
setDialogTitle(routeDialogTitle());
setHidden(byId(ids.routeListView), true);
@@ -276,7 +280,18 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}
async function handleResponse(response) {
if (response.ok) return parseJson(response);
if ([401, 403].includes(response.status) && state.currentSource === 'group') {
throw new Error(ns.I18n.t('groups.error.access'));
}
if (response.status === 423) throw new Error(ns.I18n.t('point-locks.lost'));
if (response.status === 409 && response.headers?.get('X-Group-Route')) {
throw new Error(ns.I18n.t('groups.error.conflict'));
}
if (response.ok) {
ns.Groups?.observe?.(response);
ns.GroupPoints?.observe?.(response);
return parseJson(response);
}
const payload = await parseJson(response);
const error = new Error(payload.message || ns.I18n.t("editor.error.action"));
error.status = response.status;
@@ -286,7 +301,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
async function readJson(path) {
const response = await fetch(apiUrl(path), {
credentials: 'same-origin',
headers: { Accept: 'application/json' }
headers: { ...ns.Groups?.headersFor?.(path), ...ns.GroupPoints?.headersFor?.(path), Accept: 'application/json' }
});
return handleResponse(response);
}
@@ -296,6 +311,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
body: JSON.stringify(body),
credentials: 'same-origin',
headers: {
...ns.Groups?.headersFor?.(path), ...ns.GroupPoints?.headersFor?.(path),
Accept: 'application/json',
'Content-Type': 'application/json'
},
@@ -308,7 +324,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const response = await fetch(apiUrl(path), {
body: formData,
credentials: 'same-origin',
headers: { Accept: 'application/json' },
headers: { ...ns.Groups?.headersFor?.(path), ...ns.GroupPoints?.headersFor?.(path), Accept: 'application/json' },
method
});
return handleResponse(response);
@@ -317,7 +333,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
async function sendDelete(path) {
const response = await fetch(apiUrl(path), {
credentials: 'same-origin',
headers: { Accept: 'application/json' },
headers: { ...ns.Groups?.headersFor?.(path), ...ns.GroupPoints?.headersFor?.(path), Accept: 'application/json' },
method: 'DELETE'
});
return handleResponse(response);
@@ -375,6 +391,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const actions = document.createElement('div');
actions.className = classes.iconRow;
actions.appendChild(iconButton(ns.I18n.t("editor.edit-route"), '✎', 'edit-route'));
if (ns.Groups?.shareButton) actions.appendChild(ns.Groups.shareButton('share-route'));
if (routeSource(route) === 'local') {
actions.appendChild(iconButton(ns.I18n.t("editor.delete-route"), '×', 'delete-route', false, true));
}
@@ -453,6 +470,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}
let routeListGeneration = 0;
let shareInProgress = false;
async function loadRoutes() {
const requestGeneration = ++routeListGeneration;
@@ -518,6 +536,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
setDialogExpanded(true);
showListView();
await guarded(loadRoutes);
void ns.Groups?.refresh?.();
}
async function openNewLocalRoute() {
@@ -526,6 +545,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}
function closeEditor() {
if (pointSaveInProgress) return;
void ns.GroupPoints?.release();
releasePoiPreview();
ns.RouteRecorder?.endEditing();
const dialog = byId(ids.dialog);
@@ -837,7 +858,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
} else {
const payload = await readJson(`/routes/${encodeURIComponent(id)}`);
const detail = payload.route || payload;
state.currentRoute = { ...detail, _source: 'server' };
state.currentRoute = { ...detail, _source: state.currentSource };
state.pois = poiArray(detail);
}
@@ -871,6 +892,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const file = byId(ids.routeGpx)?.files?.[0];
data.append('name', values.name);
data.append('description', values.description);
data.append('creator_id', state.currentRoute.creator_id || ns.LocalRoutes.creatorId());
if (file) data.append('gpx', file);
return data;
}
@@ -911,21 +933,22 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const id = routeId(state.currentRoute);
if (!id) throw new Error(ns.I18n.t("editor.error.create-locally"));
const file = byId(ids.routeGpx)?.files?.[0];
values = { ...values, creator_id: state.currentRoute.creator_id || ns.LocalRoutes.creatorId() };
const path = `/routes/${encodeURIComponent(id)}`;
const payload = file
? await sendForm(path, 'PUT', formDataFromRoute(values))
: await sendJson(path, 'PUT', values);
const saved = payload.route || payload;
state.currentRoute = { ...saved, _source: 'server' };
state.currentRoute = { ...saved, _source: state.currentSource };
await loadRoutes();
await loadRoute({ id, _source: 'server' });
await loadRoute({ id, _source: state.currentSource });
setStatus(ns.I18n.t("editor.saved"));
}
async function saveRoute() {
const values = routeFromForm();
if (!values.name) throw new Error(ns.I18n.t("editor.error.route-name"));
if (state.currentSource === 'server') await saveServerRoute(values);
if (state.currentSource !== 'local') await saveServerRoute(values);
else await saveLocalRoute(values);
}
@@ -1134,6 +1157,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}
function newPoi() {
void ns.GroupPoints?.release();
releasePoiPreview();
const position = currentPosition();
const lat = position?.lat ?? null;
@@ -1141,6 +1165,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const accuracy = Number(position?.accuracy);
state.currentPoi = null;
state.groupDeletions = [];
state.groupDeletePoint = false;
const identifier = state.currentSource === 'local' ? nextLocalPoiId() : null;
fillPoiForm({
...(identifier == null ? {} : { id: identifier }),
@@ -1155,11 +1181,30 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
async function editPoi(poi) {
if (!poi) return;
releasePoiPreview();
let draft;
if (state.currentSource === 'group' && ns.GroupPoints) {
const opened = await ns.GroupPoints.begin(routeId(state.currentRoute), poi);
if (!opened) return;
poi = opened.poi;
draft = opened.draft;
}
state.groupDeletions = draft?.media?.deletions || [];
state.groupDeletePoint = Boolean(draft?.media?.deletePoint);
state.currentPoi = poi;
fillPoiForm(poi);
fillPoiForm(draft ? { ...poi, ...draft.values } : poi);
if (draft) {
state.pendingAudioFile = draft.media?.audio || null;
state.pendingImages = (draft.media?.images || []).map(file => ({ file, key: `pending-image-${ns.LocalRoutes.createPoiId()}`, previewUrl: objectUrlFor(file) }));
const hiddenImages = state.groupDeletions.map(item => item.path);
state.currentPoi = { ...poi, images: (poi.images || []).filter(image => !hiddenImages.includes(`pictures/${image.id}`)),
...(hiddenImages.includes('audio') ? { audioUrl: null } : {}) };
renderPoiMediaPreview();
updatePoiMediaStatus();
setStatus(ns.I18n.t(state.groupDeletePoint ? 'point-locks.delete-restored' : 'point-locks.restored'));
}
byId(ids.poiFormTitle).textContent = ns.I18n.t("editor.edit-station");
showPoiView();
if (state.currentSource !== 'local') return;
if (state.currentSource !== 'local') return true;
const requestGeneration = poiMediaGeneration;
const hydrated = await ns.LocalRoutes.getPoiWithMedia(routeId(state.currentRoute), poi.id, { requestPermission: true });
if (requestGeneration !== poiMediaGeneration || state.currentPoi !== poi) {
@@ -1278,16 +1323,62 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
if (state.pendingAudioFile) {
const method = existingAudioUrl(state.currentPoi) ? 'PUT' : 'POST';
await uploadOne(`${basePath}/audio`, 'audio', state.pendingAudioFile, method);
if (state.currentSource === 'group') { state.pendingAudioFile = null; await rememberGroupPoint(poiFromForm(), currentPoiId); }
}
for (const pending of state.pendingImages) {
for (const pending of [...state.pendingImages]) {
await uploadOne(`${basePath}/pictures`, 'picture', pending.file);
if (state.currentSource === 'group') {
state.pendingImages = state.pendingImages.filter(item => item !== pending);
releasePendingPreview(pending);
await rememberGroupPoint(poiFromForm(), currentPoiId);
}
}
}
async function rememberGroupPoint(values, identifier = byId(ids.poiId).value || 'new') {
return ns.GroupPoints.remember(state.currentRoute, identifier, values, {
audio: state.pendingAudioFile, images: state.pendingImages.map(item => item.file), deletions: state.groupDeletions, deletePoint: state.groupDeletePoint
});
}
async function prepareGroupPoint(values, currentPoiId) {
const draft = await rememberGroupPoint(values);
if (!currentPoiId) return true;
try {
const prepared = await ns.GroupPoints.prepare(draft);
if (prepared) {
state.groupDeletions = draft.media.deletions;
state.currentPoi = prepared.poi;
return true;
}
} catch { /* The complete intent was persisted before trying to acquire the point. */ }
setStatus(ns.I18n.t('point-locks.saved-device'));
return false;
}
async function saveGroupPointActions(currentRouteId, currentPoiId, saved, values) {
const savedPoiId = poiId(saved) || currentPoiId;
if (!currentPoiId) {
byId(ids.poiId).value = savedPoiId;
state.currentPoi = saved;
// Persist the acknowledged ID before any further network request.
await rememberGroupPoint(values, savedPoiId);
await ns.GroupPoints.remove(currentRouteId, 'new');
await ns.GroupPoints.acceptNew(currentRouteId, saved);
}
for (const deletion of [...state.groupDeletions]) {
await sendDelete(`/routes/${currentRouteId}/pois/${savedPoiId}/${deletion.path}`);
state.groupDeletions = state.groupDeletions.filter(item => item !== deletion);
await rememberGroupPoint(values, savedPoiId);
}
}
async function saveServerPoi(values) {
if (state.currentSource === 'group' && state.groupDeletePoint) return deleteCurrentPoi();
const currentRouteId = routeId(state.currentRoute);
if (!currentRouteId) throw new Error(ns.I18n.t("editor.error.save-route-first"));
const currentPoiId = byId(ids.poiId).value;
if (state.currentSource === 'group' && ns.GroupPoints && !await prepareGroupPoint(values, currentPoiId)) return;
const path = currentPoiId
? `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`
: `/routes/${encodeURIComponent(currentRouteId)}/pois`;
@@ -1295,16 +1386,34 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const payload = await sendJson(path, method, values);
const saved = payload.poi || payload;
const savedPoiId = poiId(saved) || currentPoiId;
if (state.currentSource === 'group' && ns.GroupPoints) await saveGroupPointActions(currentRouteId, currentPoiId, saved, values);
if (savedPoiId) await uploadPoiMedia(currentRouteId, savedPoiId);
if (state.currentSource === 'group' && ns.GroupPoints) await ns.GroupPoints.remove(currentRouteId, savedPoiId);
setStatus(ns.I18n.t("editor.station-saved"));
await loadRoute({ id: currentRouteId, _source: 'server' });
await loadRoute({ id: currentRouteId, _source: state.currentSource });
}
let pointSaveInProgress = false;
async function savePoi() {
if (pointSaveInProgress) return;
pointSaveInProgress = true;
byId(ids.dialog).inert = true;
try { await savePoiContents(); } finally { pointSaveInProgress = false; byId(ids.dialog).inert = false; }
}
async function savePoiContents() {
const values = poiFromForm();
if (!values.title) throw new Error(ns.I18n.t("editor.error.station-name"));
if (state.currentSource === 'local') await saveLocalPoi(values);
else await saveServerPoi(values);
else {
try { await saveServerPoi(values); }
catch (error) {
if (state.currentSource !== 'group' || !ns.GroupPoints) throw error;
// Retain all unacknowledged files and intents, even when the lock changed during upload.
await rememberGroupPoint(values);
setStatus(ns.I18n.t('point-locks.saved-device'));
}
}
}
function updateCurrentPoi(updatedPoi) {
@@ -1364,13 +1473,15 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const updated = { ...state.currentPoi };
if (kind === 'audio') {
await sendDelete(`${basePath}/audio`);
if (state.currentSource === 'group') state.groupDeletions.push({ path: 'audio' });
else await sendDelete(`${basePath}/audio`);
updated.audio = null;
updated.audioUrl = null;
updated.audio_path = null;
} else {
if (!mediaId) throw new Error(ns.I18n.t("editor.error.remove-image"));
await sendDelete(`${basePath}/pictures/${encodeURIComponent(mediaId)}`);
if (state.currentSource === 'group') state.groupDeletions.push({ path: `pictures/${encodeURIComponent(mediaId)}` });
else await sendDelete(`${basePath}/pictures/${encodeURIComponent(mediaId)}`);
updated.images = (state.currentPoi.images || []).filter(image => text(image.id) !== text(mediaId));
}
@@ -1378,7 +1489,10 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
state.selectedMediaKey = null;
renderPoiMediaPreview();
updatePoiMediaStatus();
setStatus(kind === 'audio' ? ns.I18n.t("editor.audio.removed") : ns.I18n.t("editor.image-removed"));
if (state.currentSource === 'group') {
await rememberGroupPoint(poiFromForm());
setStatus(ns.I18n.t('point-locks.deletion-staged'));
} else setStatus(kind === 'audio' ? ns.I18n.t("editor.audio.removed") : ns.I18n.t("editor.image-removed"));
}
async function removeMedia(control) {
@@ -1427,8 +1541,16 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
return;
}
await sendDelete(`/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`);
await loadRoute({ id: currentRouteId, _source: 'server' });
if (state.currentSource === 'group' && ns.GroupPoints) {
state.groupDeletePoint = true;
const draft = await rememberGroupPoint(poiFromForm());
try {
if (!await ns.GroupPoints.prepare(draft)) { setStatus(ns.I18n.t('point-locks.saved-device')); return; }
await sendDelete(`/routes/${currentRouteId}/pois/${currentPoiId}`);
await ns.GroupPoints.remove(currentRouteId, currentPoiId);
} catch { setStatus(ns.I18n.t('point-locks.saved-device')); return; }
} else await sendDelete(`/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`);
await loadRoute({ id: currentRouteId, _source: state.currentSource });
setStatus(ns.I18n.t("editor.station-removed"));
}
@@ -1447,6 +1569,25 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
guarded(savePoiOrder);
}
async function saveGroupPoiOrder(currentRouteId) {
let deferred = false;
for (const [index, poi] of state.pois.entries()) {
if (await ns.GroupPoints.read(currentRouteId, poi.id)) { deferred = true; continue; }
const values = { ...poi, sequence: index };
try {
const opened = await ns.GroupPoints.begin(currentRouteId, poi);
await ns.GroupPoints.remember(state.currentRoute, poi.id, { ...(opened?.poi || values), sequence: index }, { images: [], audio: null, deletions: [] });
if (!opened) { deferred = true; continue; }
await sendJson(`/routes/${currentRouteId}/pois/${poi.id}`, 'PUT', { sequence: index, sortOrder: index });
await ns.GroupPoints.remove(currentRouteId, poi.id);
} catch {
await ns.GroupPoints.remember(state.currentRoute, poi.id, values, { images: [], audio: null, deletions: [] });
deferred = true;
} finally { await ns.GroupPoints.release(); }
}
setStatus(ns.I18n.t(deferred ? 'point-locks.saved-device' : 'editor.order.saved'));
}
async function savePoiOrder() {
const currentRouteId = routeId(state.currentRoute);
if (!currentRouteId) return;
@@ -1455,6 +1596,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
setStatus(ns.I18n.t("editor.station-order-saved"));
return;
}
if (state.currentSource === 'group' && ns.GroupPoints) return saveGroupPoiOrder(currentRouteId);
const jobs = state.pois.map((poi, index) => {
const currentPoiId = poiId(poi);
if (!currentPoiId) return null;
@@ -1506,6 +1648,21 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const route = routeForItem(event.target);
if (!route) return;
if (action === 'share-route') {
if (shareInProgress) return;
shareInProgress = true;
try {
let sourceId = routeId(route);
if (routeSource(route) === 'local') {
if (!globalThis.confirm(ns.I18n.t('groups.upload-confirm'))) return;
await loadRoute(route);
await uploadLocalRoute();
sourceId = state.currentRoute.serverRouteId;
}
await ns.Groups.share(sourceId);
} finally { shareInProgress = false; }
return;
}
if (action === 'edit-route') {
await loadRoute(route);
return;
@@ -1517,6 +1674,12 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
async function deletePoiFromList(poi) {
if (!poi) return;
if (state.currentSource === 'group') {
if (!await editPoi(poi)) return;
state.groupDeletePoint = true;
await savePoi();
return;
}
state.currentPoi = poi;
await deleteCurrentPoi();
}
@@ -1550,8 +1713,10 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
function bind() {
for (const eventName of ['cancel', 'close']) {
byId(ids.dialog)?.addEventListener(eventName, () => {
byId(ids.dialog)?.addEventListener(eventName, event => {
if (pointSaveInProgress && eventName === 'cancel') { event.preventDefault(); return; }
releasePoiPreview();
void ns.GroupPoints?.release();
ns.RouteRecorder?.endEditing();
});
}
@@ -1567,7 +1732,10 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
byId(ids.poiAudio)?.addEventListener('change', handleAudioSelection);
byId(ids.poiImages)?.addEventListener('change', () => guarded(handleImageSelection));
activate(byId(ids.poiMediaPreview), event => guarded(() => mediaPreviewAction(event)));
activate(byId(ids.poiDelete), () => guarded(deleteCurrentPoi));
activate(byId(ids.poiDelete), () => {
if (state.currentSource === 'group') { state.groupDeletePoint = true; void guarded(savePoi); }
else void guarded(deleteCurrentPoi);
});
activate(byId(ids.poiCancel), showRouteView);
activate(byId(ids.routeList), event => guarded(() => routeListAction(event)));
activate(byId(ids.poiList), event => guarded(() => poiListAction(event)));
@@ -1580,6 +1748,27 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
close: closeEditor,
open: openEditor,
openNewLocalRoute,
openGroup: async route => {
await openEditor();
await loadRoute({ ...route, _source: 'group' });
},
openPendingPoint: async draft => {
await openEditor();
try { await loadRoute({ ...draft.route, _source: 'group' }); }
catch {
state.currentSource = 'group';
state.currentRoute = { ...draft.route, _source: 'group' };
state.pois = draft.basePoi ? [draft.basePoi] : [];
fillRouteForm(state.currentRoute); renderPoiList(); showRouteView();
}
if (draft.poiId === 'new') {
newPoi();
fillPoiForm(draft.values);
state.pendingAudioFile = draft.media?.audio || null;
state.pendingImages = (draft.media?.images || []).map(file => ({ file, key: `pending-image-${ns.LocalRoutes.createPoiId()}`, previewUrl: objectUrlFor(file) }));
renderPoiMediaPreview(); updatePoiMediaStatus();
} else await editPoi(draft.basePoi || { id: draft.poiId });
},
refresh: loadRoutes
});
}