Merge PR #488: KMZ/KML place import

Resolves conflicts with Naver list import (PR #662) — kept both unified
list-import dialog and new KMZ/KML dialog. Dropped duplicate react-dom
import and unused CustomSelect import from PlacesSidebar.
This commit is contained in:
jubnl
2026-04-15 05:09:45 +02:00
25 changed files with 983 additions and 28 deletions
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<kml>
<Document>
<Placemark>
<name>Broken Placemark</name>
<Point><coordinates>2.1,48.1,0</coordinates></Point>
</Document>
</kml>
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
<Folder>
<name>Food</name>
<Folder>
<name>Parks</name>
<Placemark>
<name>Nested Place</name>
<description>Nested <i>folder</i> placemark<br/>line 2</description>
<Point>
<coordinates>13.4050,52.5200,15</coordinates>
</Point>
</Placemark>
</Folder>
<Placemark>
<name>Empty Placemark</name>
</Placemark>
<Placemark>
<Point>
<coordinates>13.4010,52.5210,0</coordinates>
</Point>
</Placemark>
</Folder>
</Document>
</kml>
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
<Folder>
<name>Museums</name>
<Placemark>
<name>Eiffel Tower View</name>
<description><![CDATA[Great spot<br>for photos <b>and</b> skyline.]]></description>
<Point>
<coordinates>2.2945,48.8584,0</coordinates>
</Point>
</Placemark>
<Placemark>
<description>Coordinates only placemark</description>
<Point>
<coordinates>2.3333,48.8600,0</coordinates>
</Point>
</Placemark>
</Folder>
</Document>
</kml>
BIN
View File
Binary file not shown.
+123
View File
@@ -63,6 +63,10 @@ import { invalidatePermissionsCache } from '../../src/services/permissions';
const app: Application = createApp();
const GPX_FIXTURE = path.join(__dirname, '../fixtures/test.gpx');
const KML_FIXTURE = path.join(__dirname, '../fixtures/test.kml');
const KML_NESTED_FIXTURE = path.join(__dirname, '../fixtures/test-nested.kml');
const KML_MALFORMED_FIXTURE = path.join(__dirname, '../fixtures/test-malformed.kml');
const KMZ_FIXTURE = path.join(__dirname, '../fixtures/test.kmz');
beforeAll(() => {
createTables(testDb);
@@ -734,6 +738,125 @@ describe('GPX Import', () => {
});
});
// ─────────────────────────────────────────────────────────────────────────────
// KML / KMZ Import
// ─────────────────────────────────────────────────────────────────────────────
describe('KML/KMZ Import', () => {
it('PLACE-020 — POST /import/kml with valid KML creates places and returns summary', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare('INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)')
.run('Museums', '#3b82f6', 'Landmark', user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/places/import/map`)
.set('Cookie', authCookie(user.id))
.attach('file', KML_FIXTURE);
expect(res.status).toBe(201);
expect(res.body.count).toBe(2);
expect(res.body.summary).toBeDefined();
expect(res.body.summary.totalPlacemarks).toBe(2);
expect(res.body.summary.createdCount).toBe(2);
const first = res.body.places.find((p: any) => p.name === 'Eiffel Tower View');
expect(first).toBeDefined();
expect(first.description).toContain('Great spot');
expect(first.description).toContain('\n');
expect(first.description).not.toContain('<b>');
expect(first.category?.name).toBe('Museums');
});
it('PLACE-021 — nested folders, empty placemark, and coordinates-only placemark are handled', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare('INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)')
.run('Parks', '#22c55e', 'Trees', user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/places/import/map`)
.set('Cookie', authCookie(user.id))
.attach('file', KML_NESTED_FIXTURE);
expect(res.status).toBe(201);
expect(res.body.count).toBe(2);
expect(res.body.summary.totalPlacemarks).toBe(3);
expect(res.body.summary.skippedCount).toBe(1);
expect(Array.isArray(res.body.summary.errors)).toBe(true);
expect(res.body.summary.errors.join(' ')).toContain('missing Point coordinates');
const nested = res.body.places.find((p: any) => p.name === 'Nested Place');
expect(nested).toBeDefined();
expect(nested.category?.name).toBe('Parks');
const fallback = res.body.places.find((p: any) => String(p.name).startsWith('Placemark'));
expect(fallback).toBeDefined();
});
it('PLACE-022 — malformed KML returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/places/import/map`)
.set('Cookie', authCookie(user.id))
.attach('file', KML_MALFORMED_FIXTURE);
expect(res.status).toBe(400);
expect(res.body.error).toBeDefined();
});
it('PLACE-023 — non-UTF8 KML continues with warning', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const prefix = Buffer.from('<?xml version="1.0"?><kml><Document><Placemark><name>Caf');
const invalidByte = Buffer.from([0xe9]); // invalid UTF-8 sequence when used standalone
const suffix = Buffer.from('</name><Point><coordinates>2.1,48.1,0</coordinates></Point></Placemark></Document></kml>');
const nonUtf8Kml = Buffer.concat([prefix, invalidByte, suffix]);
const res = await request(app)
.post(`/api/trips/${trip.id}/places/import/map`)
.set('Cookie', authCookie(user.id))
.attach('file', nonUtf8Kml, 'non-utf8.kml');
expect(res.status).toBe(201);
expect(res.body.count).toBe(1);
expect(Array.isArray(res.body.summary.warnings)).toBe(true);
expect(res.body.summary.warnings.join(' ')).toContain('not valid UTF-8');
});
it('PLACE-024 — POST /import/kmz with valid KMZ creates places', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/places/import/map`)
.set('Cookie', authCookie(user.id))
.attach('file', KMZ_FIXTURE);
expect(res.status).toBe(201);
expect(res.body.count).toBeGreaterThan(0);
expect(res.body.summary).toBeDefined();
});
it('PLACE-025 — invalid KMZ returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/places/import/map`)
.set('Cookie', authCookie(user.id))
.attach('file', Buffer.from('not-a-zip-archive'), 'invalid.kmz');
expect(res.status).toBe(400);
expect(String(res.body.error || '')).toContain('KMZ');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// GPX import — no waypoints
// ─────────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest';
import {
buildCategoryNameLookup,
decodeUtf8WithWarning,
extractKmlPlacemarkNodes,
parseKmlPointCoordinates,
parsePlacemarkNode,
resolveCategoryIdForFolder,
sanitizeKmlDescription,
stripXmlNamespaces,
} from '../../../src/services/kmlImport';
describe('kmlImportUtils', () => {
it('strips KML namespaces and prefixes', () => {
const xml = '<kml xmlns="http://www.opengis.net/kml/2.2"><kml:Document><kml:Placemark /></kml:Document></kml>';
const stripped = stripXmlNamespaces(xml);
expect(stripped).not.toContain('xmlns');
expect(stripped).toContain('<Document>');
expect(stripped).toContain('<Placemark');
});
it('sanitizes HTML descriptions with br to newline', () => {
const input = 'Line 1<br>Line <b>2</b> &amp; more';
const output = sanitizeKmlDescription(input);
expect(output).toBe('Line 1\nLine 2 & more');
});
it('parses KML coordinate order lng,lat,alt', () => {
const parsed = parseKmlPointCoordinates('13.4050,52.5200,15');
expect(parsed).toEqual({ lat: 52.52, lng: 13.405 });
});
it('extracts placemarks from nested folders', () => {
const root = {
Document: {
Folder: {
name: 'Parent',
Folder: {
name: 'Child',
Placemark: { name: 'Nested', Point: { coordinates: '13.4,52.5,0' } },
},
},
},
};
const nodes = extractKmlPlacemarkNodes(root);
expect(nodes).toHaveLength(1);
expect(nodes[0].folderName).toBe('Child');
const parsed = parsePlacemarkNode(nodes[0]);
expect(parsed.name).toBe('Nested');
expect(parsed.lat).toBe(52.5);
expect(parsed.lng).toBe(13.4);
});
it('builds exact case-insensitive category lookup', () => {
const lookup = buildCategoryNameLookup([
{ id: 3, name: 'Museums' },
{ id: 4, name: 'Parks' },
]);
expect(resolveCategoryIdForFolder('museums', lookup)).toBe(3);
expect(resolveCategoryIdForFolder('Museum', lookup)).toBeNull();
expect(resolveCategoryIdForFolder('parks', lookup)).toBe(4);
});
it('returns warning for non-UTF8 payload', () => {
const buffer = Buffer.concat([
Buffer.from('<?xml version="1.0"?><kml><Document><Placemark><name>Caf'),
Buffer.from([0xe9]),
Buffer.from('</name></Placemark></Document></kml>'),
]);
const decoded = decodeUtf8WithWarning(buffer);
expect(decoded.warning).toContain('not valid UTF-8');
expect(decoded.text).toContain('<kml>');
});
});