feat(tours): import KML and KMZ routes as tours, with a preview first

A route planned in Google My Maps or Google Earth can now become a tour
without converting it to GPX first. The import button and a file dropped on
the tours workspace take .kml and .kmz files beside .gpx, through the same
preview, the same import routes and the same transaction as a GPX file.

The preview shows every line of the file on its own, numbered, with its
distance and heights; two lines of one placemark are numbered after its name
and never joined. Heights count only where they are heights: kept for an
absolute line, dropped for any other altitude mode and for the all-zero
placeholder My Maps writes, so a missing height stays unknown. What the file
holds besides its lines (pins, areas, overlays, links to other files) is
counted in a dashed card, and nothing is fetched from elsewhere.

The KML reading reuses the place import's codec. A KMZ only ever has its KML
entry unpacked, counted byte by byte against the decompressed cap whatever
its header claims. A file that cannot be read is refused with its reason
(damaged, an archive without KML, too large unpacked), and the toast names
the file and the reason. A KML or KMZ import keeps the gpx origin of a file
import; its provenance names the real format from the stored file name.
This commit is contained in:
Maurice
2026-10-20 12:28:57 +02:00
parent f57a0dbd31
commit 3659297af1
58 changed files with 1883 additions and 400 deletions
+16 -2
View File
@@ -1,6 +1,6 @@
// FE-W4UTL-001 to FE-W4UTL-006
// FE-W4UTL-001 to FE-W4UTL-008
import { describe, it, expect } from 'vitest'
import { getApiErrorMessage } from './apiError'
import { getApiErrorCode, getApiErrorMessage } from './apiError'
describe('getApiErrorMessage', () => {
it('FE-W4UTL-001: returns the server-provided error string', () => {
@@ -33,3 +33,17 @@ describe('getApiErrorMessage', () => {
expect(getApiErrorMessage({ response: { data: { error: ' boom ' } } }, 'fallback')).toBe(' boom ')
})
})
describe('getApiErrorCode', () => {
it('FE-W4UTL-007: returns the code a refusal names its reason with', () => {
expect(getApiErrorCode({ response: { data: { error: 'Invalid KMZ archive.', code: 'archive_invalid' } } })).toBe('archive_invalid')
})
it('FE-W4UTL-008: is null without a code, for an empty or non-string one, and for no error at all', () => {
expect(getApiErrorCode({ response: { data: { error: 'boom' } } })).toBeNull()
expect(getApiErrorCode({ response: { data: { code: '' } } })).toBeNull()
expect(getApiErrorCode({ response: { data: { code: 42 } } })).toBeNull()
expect(getApiErrorCode(null)).toBeNull()
expect(getApiErrorCode(undefined)).toBeNull()
})
})
+6
View File
@@ -7,3 +7,9 @@ export function getApiErrorMessage(err: unknown, fallback: string): string {
const message = (err as { response?: { data?: { error?: unknown } } })?.response?.data?.error
return typeof message === 'string' && message.trim() ? message : fallback
}
/** The `code` a refusal names its reason with, beside its message; null when it carries none. */
export function getApiErrorCode(err: unknown): string | null {
const code = (err as { response?: { data?: { code?: unknown } } } | null)?.response?.data?.code
return typeof code === 'string' && code ? code : null
}