This commit is contained in:
+1
-1
@@ -77,7 +77,7 @@
|
|||||||
<ul id="route-control-local-list" class="route-control-list" aria-live="polite"></ul>
|
<ul id="route-control-local-list" class="route-control-list" aria-live="polite"></ul>
|
||||||
</section>
|
</section>
|
||||||
<div class="route-control-actions">
|
<div class="route-control-actions">
|
||||||
<button id="editor-button" class="editor-button" type="button" aria-label="Streckenverwaltung öffnen" title="Streckenverwaltung öffnen" aria-haspopup="dialog" aria-expanded="false" aria-controls="editor-dialog">Strecken verwalten</button>
|
<button id="editor-button" class="editor-button" type="button" title="Streckenverwaltung öffnen" aria-haspopup="dialog" aria-expanded="false" aria-controls="editor-dialog">Strecken verwalten</button>
|
||||||
<button id="route-control-new" class="editor-primary-button" type="button">Neue lokale Strecke</button>
|
<button id="route-control-new" class="editor-primary-button" type="button">Neue lokale Strecke</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
+1
-1
@@ -712,7 +712,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function refreshCurrentLocalRoute(id) {
|
function refreshCurrentLocalRoute(id) {
|
||||||
if (!state.route || state.route.source !== 'local' || String(state.route.id) !== String(id)) return;
|
if (state.route?.source !== 'local' || String(state.route.id) !== String(id)) return;
|
||||||
persistRouteProgress();
|
persistRouteProgress();
|
||||||
try {
|
try {
|
||||||
state.route = ns.LocalRoutes.getAppRoute(id);
|
state.route = ns.LocalRoutes.getAppRoute(id);
|
||||||
|
|||||||
@@ -530,7 +530,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
id,
|
id,
|
||||||
gpx,
|
gpx,
|
||||||
route: {
|
route: {
|
||||||
...(state.currentRoute || {}),
|
...state.currentRoute,
|
||||||
id,
|
id,
|
||||||
name: values.name,
|
name: values.name,
|
||||||
description: values.description
|
description: values.description
|
||||||
@@ -622,14 +622,14 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
|
|
||||||
async function saveLocalPoi(values) {
|
async function saveLocalPoi(values) {
|
||||||
if (!Number.isFinite(values.lat) || !Number.isFinite(values.lon)) {
|
if (!Number.isFinite(values.lat) || !Number.isFinite(values.lon)) {
|
||||||
throw new Error('Für einen GPX-POI sind gültige Breiten- und Längengrade erforderlich.');
|
throw new TypeError('Für einen GPX-POI sind gültige Breiten- und Längengrade erforderlich.');
|
||||||
}
|
}
|
||||||
const currentId = Number(byId(ids.poiId).value);
|
const currentId = Number(byId(ids.poiId).value);
|
||||||
const identifier = Number.isSafeInteger(currentId) ? currentId : ns.LocalRoutes.createPoiId();
|
const identifier = Number.isSafeInteger(currentId) ? currentId : ns.LocalRoutes.createPoiId();
|
||||||
const existing = state.pois.find(poi => Number(poi.id) === identifier) || state.currentPoi;
|
const existing = state.pois.find(poi => Number(poi.id) === identifier) || state.currentPoi;
|
||||||
const media = await mediaFromLocalForm(existing, identifier);
|
const media = await mediaFromLocalForm(existing, identifier);
|
||||||
const savedPoi = {
|
const savedPoi = {
|
||||||
...(existing || {}),
|
...existing,
|
||||||
...values,
|
...values,
|
||||||
...media,
|
...media,
|
||||||
id: identifier,
|
id: identifier,
|
||||||
|
|||||||
+43
-15
@@ -34,9 +34,32 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
storage().setItem(INDEX_KEY, JSON.stringify([...new Set(ids.map(String))]));
|
storage().setItem(INDEX_KEY, JSON.stringify([...new Set(ids.map(String))]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function uuidFromRandomValues(cryptoApi) {
|
||||||
|
const bytes = new Uint8Array(16);
|
||||||
|
cryptoApi.getRandomValues(bytes);
|
||||||
|
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||||
|
|
||||||
|
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0'));
|
||||||
|
return [
|
||||||
|
hex.slice(0, 4).join(''),
|
||||||
|
hex.slice(4, 6).join(''),
|
||||||
|
hex.slice(6, 8).join(''),
|
||||||
|
hex.slice(8, 10).join(''),
|
||||||
|
hex.slice(10, 16).join('')
|
||||||
|
].join('-');
|
||||||
|
}
|
||||||
|
|
||||||
function createRouteId() {
|
function createRouteId() {
|
||||||
if (globalThis.crypto?.randomUUID) return `local-${globalThis.crypto.randomUUID()}`;
|
const cryptoApi = globalThis.crypto;
|
||||||
return `local-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
if (typeof cryptoApi?.randomUUID === 'function') {
|
||||||
|
return `local-${cryptoApi.randomUUID()}`;
|
||||||
|
}
|
||||||
|
if (typeof cryptoApi?.getRandomValues === 'function') {
|
||||||
|
return `local-${uuidFromRandomValues(cryptoApi)}`;
|
||||||
|
}
|
||||||
|
throw new Error('Keine sichere Zufallsquelle für eine lokale Strecken-ID verfügbar.');
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPoiId() {
|
function createPoiId() {
|
||||||
@@ -138,13 +161,14 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
|
|
||||||
const orderedPois = [...(pois || [])].sort((left, right) => Number(left.sequence) - Number(right.sequence));
|
const orderedPois = [...(pois || [])].sort((left, right) => Number(left.sequence) - Number(right.sequence));
|
||||||
orderedPois.forEach((poi, index) => {
|
orderedPois.forEach((poi, index) => {
|
||||||
|
const title = poi.title || `Station ${index + 1}`;
|
||||||
if (!Number.isFinite(Number(poi.lat)) || !Number.isFinite(Number(poi.lon))) {
|
if (!Number.isFinite(Number(poi.lat)) || !Number.isFinite(Number(poi.lon))) {
|
||||||
throw new Error(`Für ${poi.title || `Station ${index + 1}`} fehlen gültige Koordinaten.`);
|
throw new TypeError(`Für ${title} fehlen gültige Koordinaten.`);
|
||||||
}
|
}
|
||||||
const waypoint = createElement(document, 'wpt');
|
const waypoint = createElement(document, 'wpt');
|
||||||
waypoint.setAttribute('lat', String(Number(poi.lat)));
|
waypoint.setAttribute('lat', String(Number(poi.lat)));
|
||||||
waypoint.setAttribute('lon', String(Number(poi.lon)));
|
waypoint.setAttribute('lon', String(Number(poi.lon)));
|
||||||
waypoint.appendChild(createElement(document, 'name', poi.title || `Station ${index + 1}`));
|
waypoint.appendChild(createElement(document, 'name', title));
|
||||||
if (poi.description) waypoint.appendChild(createElement(document, 'desc', poi.description));
|
if (poi.description) waypoint.appendChild(createElement(document, 'desc', poi.description));
|
||||||
const extensions = createElement(document, 'extensions');
|
const extensions = createElement(document, 'extensions');
|
||||||
const poiId = document.createElementNS(POI_NAMESPACE, 'wegwichtel:poiId');
|
const poiId = document.createElementNS(POI_NAMESPACE, 'wegwichtel:poiId');
|
||||||
@@ -154,7 +178,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
const routeOrTrack = directChildrenByLocalName(root, 'rte')[0]
|
const routeOrTrack = directChildrenByLocalName(root, 'rte')[0]
|
||||||
|| directChildrenByLocalName(root, 'trk')[0]
|
|| directChildrenByLocalName(root, 'trk')[0]
|
||||||
|| null;
|
|| null;
|
||||||
root.insertBefore(waypoint, routeOrTrack);
|
if (routeOrTrack) routeOrTrack.before(waypoint);
|
||||||
|
else root.appendChild(waypoint);
|
||||||
});
|
});
|
||||||
|
|
||||||
return new XMLSerializer().serializeToString(document);
|
return new XMLSerializer().serializeToString(document);
|
||||||
@@ -375,25 +400,28 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
|
|
||||||
function readFileAsText(file) {
|
function readFileAsText(file) {
|
||||||
if (!file) return Promise.resolve('');
|
if (!file) return Promise.resolve('');
|
||||||
if (typeof file.text === 'function') return file.text();
|
if (typeof file.text !== 'function') {
|
||||||
return new Promise((resolve, reject) => {
|
return Promise.reject(new TypeError('Die ausgewählte Datei unterstützt Blob.text() nicht.'));
|
||||||
const reader = new FileReader();
|
}
|
||||||
reader.addEventListener('load', () => resolve(String(reader.result || '')), { once: true });
|
return file.text();
|
||||||
reader.addEventListener('error', () => reject(reader.error || new Error('Datei konnte nicht gelesen werden.')), { once: true });
|
|
||||||
reader.readAsText(file);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fileToDataUrl(file) {
|
function fileToDataUrl(file) {
|
||||||
if (!file) return Promise.resolve(null);
|
if (!file) return Promise.resolve(null);
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.addEventListener('load', () => resolve({
|
reader.addEventListener('load', () => {
|
||||||
dataUrl: String(reader.result || ''),
|
if (typeof reader.result !== 'string') {
|
||||||
|
reject(new TypeError('Die Datei konnte nicht als Data-URL gelesen werden.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({
|
||||||
|
dataUrl: reader.result,
|
||||||
name: file.name || 'Datei',
|
name: file.name || 'Datei',
|
||||||
size: Number(file.size) || 0,
|
size: Number(file.size) || 0,
|
||||||
type: file.type || 'application/octet-stream'
|
type: file.type || 'application/octet-stream'
|
||||||
}), { once: true });
|
});
|
||||||
|
}, { once: true });
|
||||||
reader.addEventListener('error', () => reject(reader.error || new Error('Datei konnte nicht gelesen werden.')), { once: true });
|
reader.addEventListener('error', () => reject(reader.error || new Error('Datei konnte nicht gelesen werden.')), { once: true });
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -64,6 +64,14 @@ class XmlElement {
|
|||||||
return child;
|
return child;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
before(...nodes) {
|
||||||
|
if (!this.parentNode) return;
|
||||||
|
const index = this.parentNode.children.indexOf(this);
|
||||||
|
if (index < 0) return;
|
||||||
|
for (const node of nodes) node.parentNode = this.parentNode;
|
||||||
|
this.parentNode.children.splice(index, 0, ...nodes);
|
||||||
|
}
|
||||||
|
|
||||||
remove() {
|
remove() {
|
||||||
if (!this.parentNode) return;
|
if (!this.parentNode) return;
|
||||||
this.parentNode.children = this.parentNode.children.filter(child => child !== this);
|
this.parentNode.children = this.parentNode.children.filter(child => child !== this);
|
||||||
@@ -227,13 +235,13 @@ class FakeFileReader extends EventTarget {
|
|||||||
readAsDataURL(file) {
|
readAsDataURL(file) {
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
const type = file.type || 'application/octet-stream';
|
const type = file.type || 'application/octet-stream';
|
||||||
this.result = `data:${type};base64,VEVTVA==`;
|
this.result = file.invalidReaderResult ? { type } : `data:${type};base64,VEVTVA==`;
|
||||||
this.dispatchEvent(new Event('load'));
|
this.dispatchEvent(new Event('load'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadLocalRoutes() {
|
async function loadLocalRoutes(options = {}) {
|
||||||
const sourcePath = path.join(root, 'public/js/local-routes.js');
|
const sourcePath = path.join(root, 'public/js/local-routes.js');
|
||||||
const source = await fs.readFile(sourcePath, 'utf8');
|
const source = await fs.readFile(sourcePath, 'utf8');
|
||||||
const localStorage = new FakeStorage();
|
const localStorage = new FakeStorage();
|
||||||
@@ -248,7 +256,7 @@ async function loadLocalRoutes() {
|
|||||||
EventTarget,
|
EventTarget,
|
||||||
FileReader: FakeFileReader,
|
FileReader: FakeFileReader,
|
||||||
XMLSerializer: FakeXMLSerializer,
|
XMLSerializer: FakeXMLSerializer,
|
||||||
crypto: { randomUUID: () => '11111111-2222-4333-8444-555555555555' },
|
crypto: options.crypto || { randomUUID: () => '11111111-2222-4333-8444-555555555555' },
|
||||||
document,
|
document,
|
||||||
globalThis: null,
|
globalThis: null,
|
||||||
localStorage,
|
localStorage,
|
||||||
@@ -270,6 +278,45 @@ const sampleGpx = `<?xml version="1.0" encoding="UTF-8"?>
|
|||||||
</trkseg></trk>
|
</trkseg></trk>
|
||||||
</gpx>`;
|
</gpx>`;
|
||||||
|
|
||||||
|
test('route id generation uses randomUUID when available', async () => {
|
||||||
|
const { api } = await loadLocalRoutes();
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
api.createRouteId(),
|
||||||
|
'local-11111111-2222-4333-8444-555555555555'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('route id generation falls back to crypto.getRandomValues with UUID v4 bits', async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
const { api } = await loadLocalRoutes({
|
||||||
|
crypto: {
|
||||||
|
getRandomValues(bytes) {
|
||||||
|
callCount += 1;
|
||||||
|
bytes.set([
|
||||||
|
0x00, 0x11, 0x22, 0x33,
|
||||||
|
0x44, 0x55, 0x66, 0x77,
|
||||||
|
0x88, 0x99, 0xaa, 0xbb,
|
||||||
|
0xcc, 0xdd, 0xee, 0xff
|
||||||
|
]);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(api.createRouteId(), 'local-00112233-4455-4677-8899-aabbccddeeff');
|
||||||
|
assert.equal(callCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('route id generation fails when no secure crypto source is available', async () => {
|
||||||
|
const { api } = await loadLocalRoutes({ crypto: {} });
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => api.createRouteId(),
|
||||||
|
/Keine sichere Zufallsquelle/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('local route package stores GPX and JSON separately and reconstructs an app route', async () => {
|
test('local route package stores GPX and JSON separately and reconstructs an app route', async () => {
|
||||||
const { api, events, localStorage } = await loadLocalRoutes();
|
const { api, events, localStorage } = await loadLocalRoutes();
|
||||||
const routeId = api.createRouteId();
|
const routeId = api.createRouteId();
|
||||||
@@ -347,6 +394,13 @@ test('GPX waypoint import reads stable POI ids and validates coordinates', async
|
|||||||
assert.match(api.emptyGpx('A & B'), /<name>A & B<\/name>/);
|
assert.match(api.emptyGpx('A & B'), /<name>A & B<\/name>/);
|
||||||
assert.throws(() => api.parseWaypoints('<not-gpx></not-gpx>'), /GPX-Datei ist nicht gültig/);
|
assert.throws(() => api.parseWaypoints('<not-gpx></not-gpx>'), /GPX-Datei ist nicht gültig/);
|
||||||
assert.throws(() => api.syncGpx(sampleGpx, { name: 'Fehler' }, [{ id: 1, title: 'Ohne Position' }]), /gültige Koordinaten/);
|
assert.throws(() => api.syncGpx(sampleGpx, { name: 'Fehler' }, [{ id: 1, title: 'Ohne Position' }]), /gültige Koordinaten/);
|
||||||
|
|
||||||
|
const waypointOnly = api.syncGpx(
|
||||||
|
`<gpx version="1.1" xmlns="${GPX_NAMESPACE}"></gpx>`,
|
||||||
|
{ name: 'Nur POI' },
|
||||||
|
[{ id: 2, title: 'Einzelner POI', lat: 50.2, lon: 10.2, sequence: 0 }]
|
||||||
|
);
|
||||||
|
assert.match(waypointOnly, /<wpt lat="50.2" lon="10.2">/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('remove updates index and dispatches a local-route change event', async () => {
|
test('remove updates index and dispatches a local-route change event', async () => {
|
||||||
@@ -386,16 +440,18 @@ test('quota failure rolls back package writes and keeps the previous package rea
|
|||||||
assert.equal(api.get('local-quota').route.name, 'Vorher');
|
assert.equal(api.get('local-quota').route.name, 'Vorher');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('file helpers support native text and FileReader-backed media data URLs', async () => {
|
test('file helpers support Blob.text and FileReader-backed media data URLs', async () => {
|
||||||
const { api } = await loadLocalRoutes();
|
const { api } = await loadLocalRoutes();
|
||||||
|
|
||||||
assert.equal(await api.readFileAsText({ text: async () => '<gpx></gpx>' }), '<gpx></gpx>');
|
assert.equal(await api.readFileAsText({ text: async () => '<gpx></gpx>' }), '<gpx></gpx>');
|
||||||
assert.equal(await api.readFileAsText(null), '');
|
assert.equal(await api.readFileAsText(null), '');
|
||||||
|
await assert.rejects(api.readFileAsText({}), { name: 'TypeError' });
|
||||||
|
|
||||||
const media = await api.fileToDataUrl({ name: 'ton.mp3', size: 4, type: 'audio/mpeg' });
|
const media = await api.fileToDataUrl({ name: 'ton.mp3', size: 4, type: 'audio/mpeg' });
|
||||||
assert.equal(media.name, 'ton.mp3');
|
assert.equal(media.name, 'ton.mp3');
|
||||||
assert.equal(media.size, 4);
|
assert.equal(media.size, 4);
|
||||||
assert.equal(media.type, 'audio/mpeg');
|
assert.equal(media.type, 'audio/mpeg');
|
||||||
assert.match(media.dataUrl, /^data:audio\/mpeg;base64,/);
|
assert.match(media.dataUrl, /^data:audio\/mpeg;base64,/);
|
||||||
|
await assert.rejects(api.fileToDataUrl({ invalidReaderResult: true }), { name: 'TypeError' });
|
||||||
assert.equal(await api.fileToDataUrl(null), null);
|
assert.equal(await api.fileToDataUrl(null), null);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user