mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
39113e12de
Unnamed routes and tracks all fell back to the same generic 'GPX Route' / 'GPX Track' label, so the name-based import dedup dropped every one after the first - importing several files (or one file with several tracks) only kept a single place. Derive the default name from the source filename with an index suffix when a file holds more than one geometry, thread the filename down through the controller, and let the import modal take more than one file at a time. Adds PLACE-SVC-037/038.
866 lines
31 KiB
TypeScript
866 lines
31 KiB
TypeScript
import { XMLParser, XMLValidator } from 'fast-xml-parser';
|
|
import unzipper from 'unzipper';
|
|
import { db, getPlaceWithTags } from '../db/database';
|
|
import { loadTagsByPlaceIds } from './queryHelpers';
|
|
import { checkSsrf, safeFetchFollow, SsrfBlockedError } from '../utils/ssrfGuard';
|
|
import { Place } from '../types';
|
|
import {
|
|
buildCategoryNameLookup,
|
|
createKmlImportSummary,
|
|
decodeUtf8WithWarning,
|
|
extractKmlPlacemarkNodes,
|
|
parsePlacemarkNode,
|
|
resolveCategoryIdForFolder,
|
|
type KmlImportSummary,
|
|
} from './kmlImport';
|
|
|
|
interface PlaceWithCategory extends Place {
|
|
category_name: string | null;
|
|
category_color: string | null;
|
|
category_icon: string | null;
|
|
}
|
|
|
|
interface UnsplashSearchResponse {
|
|
results?: { id: string; urls?: { regular?: string; thumb?: string }; description?: string; alt_description?: string; user?: { name?: string }; links?: { html?: string } }[];
|
|
errors?: string[];
|
|
}
|
|
|
|
export interface PlaceImportResult {
|
|
places: any[];
|
|
count: number;
|
|
summary: KmlImportSummary;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// List places
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function listPlaces(
|
|
tripId: string,
|
|
filters: { search?: string; category?: string; tag?: string; assignment?: 'all' | 'unassigned' | 'assigned' },
|
|
) {
|
|
let query = `
|
|
SELECT DISTINCT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
|
|
FROM places p
|
|
LEFT JOIN categories c ON p.category_id = c.id
|
|
WHERE p.trip_id = ?
|
|
`;
|
|
const params: (string | number)[] = [tripId];
|
|
|
|
if (filters.search) {
|
|
query += ' AND (p.name LIKE ? OR p.address LIKE ? OR p.description LIKE ?)';
|
|
const searchParam = `%${filters.search}%`;
|
|
params.push(searchParam, searchParam, searchParam);
|
|
}
|
|
|
|
if (filters.category) {
|
|
query += ' AND p.category_id = ?';
|
|
params.push(filters.category);
|
|
}
|
|
|
|
if (filters.tag) {
|
|
query += ' AND p.id IN (SELECT place_id FROM place_tags WHERE tag_id = ?)';
|
|
params.push(filters.tag);
|
|
}
|
|
|
|
if (filters.assignment === 'unassigned') {
|
|
query += ` AND p.id NOT IN (SELECT da.place_id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE d.trip_id = ?)`;
|
|
params.push(tripId);
|
|
} else if (filters.assignment === 'assigned') {
|
|
query += ` AND p.id IN (SELECT da.place_id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE d.trip_id = ?)`;
|
|
params.push(tripId);
|
|
}
|
|
|
|
query += ' ORDER BY p.created_at DESC';
|
|
|
|
const places = db.prepare(query).all(...params) as PlaceWithCategory[];
|
|
|
|
const placeIds = places.map(p => p.id);
|
|
const tagsByPlaceId = loadTagsByPlaceIds(placeIds);
|
|
|
|
return places.map(p => ({
|
|
...p,
|
|
category: p.category_id ? {
|
|
id: p.category_id,
|
|
name: p.category_name,
|
|
color: p.category_color,
|
|
icon: p.category_icon,
|
|
} : null,
|
|
tags: tagsByPlaceId[p.id] || [],
|
|
}));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Create place
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function createPlace(
|
|
tripId: string,
|
|
body: {
|
|
name: string; description?: string; lat?: number; lng?: number; address?: string;
|
|
category_id?: number; price?: number; currency?: string;
|
|
place_time?: string; end_time?: string;
|
|
duration_minutes?: number; notes?: string; image_url?: string;
|
|
google_place_id?: string; osm_id?: string; website?: string; phone?: string;
|
|
transport_mode?: string; tags?: number[];
|
|
},
|
|
) {
|
|
const {
|
|
name, description, lat, lng, address, category_id, price, currency,
|
|
place_time, end_time,
|
|
duration_minutes, notes, image_url, google_place_id, osm_id, website, phone,
|
|
transport_mode, tags = [],
|
|
} = body;
|
|
|
|
const result = db.prepare(`
|
|
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency,
|
|
place_time, end_time,
|
|
duration_minutes, notes, image_url, google_place_id, osm_id, website, phone, transport_mode)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
tripId, name, description || null, lat || null, lng || null, address || null,
|
|
category_id || null, price || null, currency || null,
|
|
place_time || null, end_time || null, duration_minutes || 60, notes || null, image_url || null,
|
|
google_place_id || null, osm_id || null, website || null, phone || null, transport_mode || 'walking',
|
|
);
|
|
|
|
const placeId = result.lastInsertRowid;
|
|
|
|
if (tags && tags.length > 0) {
|
|
const insertTag = db.prepare('INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES (?, ?)');
|
|
for (const tagId of tags) {
|
|
insertTag.run(placeId, tagId);
|
|
}
|
|
}
|
|
|
|
return getPlaceWithTags(Number(placeId));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Get single place
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function getPlace(tripId: string, placeId: string) {
|
|
const placeCheck = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId);
|
|
if (!placeCheck) return null;
|
|
return getPlaceWithTags(placeId);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Update place
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function updatePlace(
|
|
tripId: string,
|
|
placeId: string,
|
|
body: {
|
|
name?: string; description?: string; lat?: number; lng?: number; address?: string;
|
|
category_id?: number; price?: number; currency?: string;
|
|
place_time?: string; end_time?: string;
|
|
duration_minutes?: number; notes?: string; image_url?: string;
|
|
google_place_id?: string; osm_id?: string; website?: string; phone?: string;
|
|
transport_mode?: string; tags?: number[];
|
|
},
|
|
) {
|
|
const existingPlace = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId) as Place | undefined;
|
|
if (!existingPlace) return null;
|
|
|
|
const {
|
|
name, description, lat, lng, address, category_id, price, currency,
|
|
place_time, end_time,
|
|
duration_minutes, notes, image_url, google_place_id, osm_id, website, phone,
|
|
transport_mode, tags,
|
|
} = body;
|
|
|
|
db.prepare(`
|
|
UPDATE places SET
|
|
name = COALESCE(?, name),
|
|
description = ?,
|
|
lat = ?,
|
|
lng = ?,
|
|
address = ?,
|
|
category_id = ?,
|
|
price = ?,
|
|
currency = COALESCE(?, currency),
|
|
place_time = ?,
|
|
end_time = ?,
|
|
duration_minutes = COALESCE(?, duration_minutes),
|
|
notes = ?,
|
|
image_url = ?,
|
|
google_place_id = ?,
|
|
osm_id = ?,
|
|
website = ?,
|
|
phone = ?,
|
|
transport_mode = COALESCE(?, transport_mode),
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?
|
|
`).run(
|
|
name || null,
|
|
description !== undefined ? description : existingPlace.description,
|
|
lat !== undefined ? lat : existingPlace.lat,
|
|
lng !== undefined ? lng : existingPlace.lng,
|
|
address !== undefined ? address : existingPlace.address,
|
|
category_id !== undefined ? category_id : existingPlace.category_id,
|
|
price !== undefined ? price : existingPlace.price,
|
|
currency || null,
|
|
place_time !== undefined ? place_time : existingPlace.place_time,
|
|
end_time !== undefined ? end_time : existingPlace.end_time,
|
|
duration_minutes || null,
|
|
notes !== undefined ? notes : existingPlace.notes,
|
|
image_url !== undefined ? image_url : existingPlace.image_url,
|
|
google_place_id !== undefined ? google_place_id : existingPlace.google_place_id,
|
|
osm_id !== undefined ? osm_id : existingPlace.osm_id,
|
|
website !== undefined ? website : existingPlace.website,
|
|
phone !== undefined ? phone : existingPlace.phone,
|
|
transport_mode || null,
|
|
placeId,
|
|
);
|
|
|
|
if (tags !== undefined) {
|
|
db.prepare('DELETE FROM place_tags WHERE place_id = ?').run(placeId);
|
|
if (tags.length > 0) {
|
|
const insertTag = db.prepare('INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES (?, ?)');
|
|
for (const tagId of tags) {
|
|
insertTag.run(placeId, tagId);
|
|
}
|
|
}
|
|
}
|
|
|
|
return getPlaceWithTags(placeId);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Delete place
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function deletePlace(tripId: string, placeId: string): boolean {
|
|
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId);
|
|
if (!place) return false;
|
|
db.prepare('DELETE FROM places WHERE id = ?').run(placeId);
|
|
return true;
|
|
}
|
|
|
|
export function deletePlacesMany(tripId: string, ids: number[]): number[] {
|
|
if (ids.length === 0) return [];
|
|
const selectStmt = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?');
|
|
const deleteStmt = db.prepare('DELETE FROM places WHERE id = ?');
|
|
const deleted: number[] = [];
|
|
const run = db.transaction((list: number[]) => {
|
|
for (const id of list) {
|
|
if (!selectStmt.get(id, tripId)) continue;
|
|
deleteStmt.run(id);
|
|
deleted.push(id);
|
|
}
|
|
});
|
|
run(ids);
|
|
return deleted;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Import GPX
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const gpxParser = new XMLParser({
|
|
ignoreAttributes: false,
|
|
attributeNamePrefix: '@_',
|
|
isArray: (name) => ['wpt', 'trkpt', 'rtept', 'trk', 'trkseg', 'rte'].includes(name),
|
|
});
|
|
|
|
const kmlParser = new XMLParser({
|
|
ignoreAttributes: false,
|
|
attributeNamePrefix: '@_',
|
|
removeNSPrefix: true,
|
|
isArray: (name) => ['Placemark', 'Folder', 'Document'].includes(name),
|
|
// Treat <description> as raw text so mixed-content HTML (e.g. <br/>, <i>)
|
|
// is returned as a string instead of a parsed object.
|
|
stopNodes: ['*.description'],
|
|
});
|
|
|
|
export const KMZ_DECOMPRESSED_SIZE_LIMIT = 50 * 1024 * 1024; // 50 MB
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Import deduplication helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const COORD_DEDUP_TOLERANCE = 0.0001; // ≈ 11 m
|
|
|
|
interface DedupSet {
|
|
names: Set<string>;
|
|
coords: Array<{ lat: number; lng: number }>;
|
|
}
|
|
|
|
/** Build a lookup of names/coords for places already in a trip. */
|
|
function buildDedupSet(tripId: string): DedupSet {
|
|
const rows = db.prepare('SELECT name, lat, lng FROM places WHERE trip_id = ?').all(tripId) as Array<{
|
|
name: string | null;
|
|
lat: number | null;
|
|
lng: number | null;
|
|
}>;
|
|
const names = new Set<string>();
|
|
const coords: Array<{ lat: number; lng: number }> = [];
|
|
for (const row of rows) {
|
|
if (row.name) {
|
|
names.add(row.name.trim().toLowerCase());
|
|
} else if (row.lat != null && row.lng != null) {
|
|
coords.push({ lat: row.lat, lng: row.lng });
|
|
}
|
|
}
|
|
return { names, coords };
|
|
}
|
|
|
|
/**
|
|
* Returns true if a candidate place is already represented in the dedup set.
|
|
* Named places match by case-insensitive name; unnamed places fall back to
|
|
* coordinate proximity.
|
|
*/
|
|
function isPlaceDuplicate(
|
|
candidate: { name: string | null | undefined; lat: number | null; lng: number | null },
|
|
dedup: DedupSet,
|
|
): boolean {
|
|
const normalizedName = candidate.name?.trim().toLowerCase();
|
|
if (normalizedName) return dedup.names.has(normalizedName);
|
|
if (candidate.lat != null && candidate.lng != null) {
|
|
return dedup.coords.some(
|
|
(c) =>
|
|
Math.abs(c.lat - candidate.lat!) <= COORD_DEDUP_TOLERANCE &&
|
|
Math.abs(c.lng - candidate.lng!) <= COORD_DEDUP_TOLERANCE,
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** Record a newly inserted place so subsequent candidates in the same batch are checked against it. */
|
|
function trackInsertedInDedupSet(
|
|
place: { name: string | null | undefined; lat: number | null; lng: number | null },
|
|
dedup: DedupSet,
|
|
): void {
|
|
const normalizedName = place.name?.trim().toLowerCase();
|
|
if (normalizedName) {
|
|
dedup.names.add(normalizedName);
|
|
} else if (place.lat != null && place.lng != null) {
|
|
dedup.coords.push({ lat: place.lat, lng: place.lng });
|
|
}
|
|
}
|
|
|
|
export interface GpxImportOptions {
|
|
importWaypoints?: boolean;
|
|
importRoutes?: boolean;
|
|
importTracks?: boolean;
|
|
/** Source filename used to name unnamed routes/tracks (keeps multiple imports distinct). */
|
|
defaultName?: string;
|
|
}
|
|
|
|
export interface KmlImportOptions {
|
|
importPoints?: boolean;
|
|
importPaths?: boolean;
|
|
}
|
|
|
|
export function importGpx(tripId: string, fileBuffer: Buffer, opts: GpxImportOptions = {}) {
|
|
const { importWaypoints = true, importRoutes = true, importTracks = true, defaultName } = opts;
|
|
|
|
const parsed = gpxParser.parse(fileBuffer.toString('utf-8'));
|
|
const gpx = parsed?.gpx;
|
|
if (!gpx) return null;
|
|
|
|
const str = (v: unknown) => (v != null ? String(v).trim() : null);
|
|
const num = (v: unknown) => { const n = parseFloat(String(v)); return isNaN(n) ? null : n; };
|
|
|
|
// Routes and tracks rarely carry their own <name>. Without one they all fall back to the
|
|
// same generic label, so name-based dedup drops every import after the first. Derive a
|
|
// base from the source filename (the requested behaviour) and suffix an index so multiple
|
|
// geometries from one file stay distinct.
|
|
const rawName = str(defaultName);
|
|
const baseName = rawName ? rawName.replace(/\.[^.]+$/, '').trim() || rawName : null;
|
|
let geoSeq = 0;
|
|
const geoName = (explicit: string | null, fallback: string): string => {
|
|
if (explicit) return explicit;
|
|
geoSeq++;
|
|
const base = baseName || fallback;
|
|
return geoSeq === 1 ? base : `${base} ${geoSeq}`;
|
|
};
|
|
|
|
type WaypointEntry = { name: string; lat: number; lng: number; description: string | null; routeGeometry?: string };
|
|
const waypoints: WaypointEntry[] = [];
|
|
|
|
// 1) Parse <wpt> elements (named waypoints / POIs)
|
|
if (importWaypoints) {
|
|
for (const wpt of gpx.wpt ?? []) {
|
|
const lat = num(wpt['@_lat']);
|
|
const lng = num(wpt['@_lon']);
|
|
if (lat === null || lng === null) continue;
|
|
waypoints.push({ lat, lng, name: str(wpt.name) || `Waypoint ${waypoints.length + 1}`, description: str(wpt.desc) });
|
|
}
|
|
}
|
|
|
|
// 2) Parse <rte> routes as polyline-places (one place per route with route_geometry)
|
|
if (importRoutes) {
|
|
for (const rte of gpx.rte ?? []) {
|
|
const pts = (rte.rtept ?? [])
|
|
.map((pt: Record<string, unknown>) => ({ lat: num(pt['@_lat']), lng: num(pt['@_lon']), ele: num(pt['ele']) }))
|
|
.filter((p: { lat: number | null; lng: number | null; ele: number | null }) => p.lat !== null && p.lng !== null) as Array<{ lat: number; lng: number; ele: number | null }>;
|
|
if (pts.length === 0) continue;
|
|
const hasAllEle = pts.every(p => p.ele !== null);
|
|
const routeGeometry = pts.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]);
|
|
waypoints.push({ lat: pts[0].lat, lng: pts[0].lng, name: geoName(str(rte.name), 'GPX Route'), description: str(rte.desc), routeGeometry: JSON.stringify(routeGeometry) });
|
|
}
|
|
}
|
|
|
|
// 3) Extract full track geometry from <trk>
|
|
if (importTracks) {
|
|
for (const trk of gpx.trk ?? []) {
|
|
const trackPoints: { lat: number; lng: number; ele: number | null }[] = [];
|
|
for (const seg of trk.trkseg ?? []) {
|
|
for (const pt of seg.trkpt ?? []) {
|
|
const lat = num(pt['@_lat']);
|
|
const lng = num(pt['@_lon']);
|
|
if (lat === null || lng === null) continue;
|
|
trackPoints.push({ lat, lng, ele: num(pt.ele) });
|
|
}
|
|
}
|
|
if (trackPoints.length === 0) continue;
|
|
const start = trackPoints[0];
|
|
const hasAllEle = trackPoints.every(p => p.ele !== null);
|
|
const routeGeometry = trackPoints.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]);
|
|
waypoints.push({ lat: start.lat, lng: start.lng, name: geoName(str(trk.name), 'GPX Track'), description: str(trk.desc), routeGeometry: JSON.stringify(routeGeometry) });
|
|
}
|
|
}
|
|
|
|
if (waypoints.length === 0) return null;
|
|
|
|
const dedup = buildDedupSet(tripId);
|
|
const insertStmt = db.prepare(`
|
|
INSERT INTO places (trip_id, name, description, lat, lng, transport_mode, route_geometry)
|
|
VALUES (?, ?, ?, ?, ?, 'walking', ?)
|
|
`);
|
|
const created: any[] = [];
|
|
let skipped = 0;
|
|
const insertAll = db.transaction(() => {
|
|
for (const wp of waypoints) {
|
|
if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup)) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
const result = insertStmt.run(tripId, wp.name, wp.description, wp.lat, wp.lng, wp.routeGeometry || null);
|
|
const place = getPlaceWithTags(Number(result.lastInsertRowid));
|
|
created.push(place);
|
|
trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup);
|
|
}
|
|
});
|
|
insertAll();
|
|
|
|
return { places: created, count: created.length, skipped };
|
|
}
|
|
|
|
export function importKmlPlaces(tripId: string, fileBuffer: Buffer, opts: KmlImportOptions = {}): PlaceImportResult {
|
|
const { importPoints = true, importPaths = true } = opts;
|
|
const decoded = decodeUtf8WithWarning(fileBuffer);
|
|
|
|
const validationResult = XMLValidator.validate(decoded.text);
|
|
if (validationResult !== true) {
|
|
throw new Error('Malformed KML: invalid XML structure');
|
|
}
|
|
|
|
const parsed = kmlParser.parse(decoded.text);
|
|
const kmlRoot = parsed?.kml ?? parsed;
|
|
|
|
if (!kmlRoot || typeof kmlRoot !== 'object') {
|
|
throw new Error('Malformed KML: could not parse XML');
|
|
}
|
|
|
|
const placemarkNodes = extractKmlPlacemarkNodes(kmlRoot);
|
|
const summary = createKmlImportSummary(placemarkNodes.length);
|
|
|
|
if (decoded.warning) {
|
|
summary.warnings.push(decoded.warning);
|
|
}
|
|
|
|
const categories = db.prepare('SELECT id, name FROM categories').all() as { id: number; name: string }[];
|
|
const categoryLookup = buildCategoryNameLookup(categories);
|
|
const dedup = buildDedupSet(tripId);
|
|
const created: any[] = [];
|
|
let dupCount = 0;
|
|
|
|
const insertStmt = db.prepare(`
|
|
INSERT INTO places (trip_id, name, description, lat, lng, category_id, transport_mode, route_geometry)
|
|
VALUES (?, ?, ?, ?, ?, ?, 'walking', ?)
|
|
`);
|
|
|
|
const insertAll = db.transaction(() => {
|
|
let fallbackIndex = 1;
|
|
for (const node of placemarkNodes) {
|
|
const parsedPlacemark = parsePlacemarkNode(node);
|
|
const isPath = parsedPlacemark.routeGeometry !== null;
|
|
|
|
// Unsupported geometry type (polygon, multi-geometry, no geometry, etc.)
|
|
if (parsedPlacemark.lat === null || parsedPlacemark.lng === null) {
|
|
summary.skippedCount += 1;
|
|
summary.errors.push(`Skipped Placemark ${fallbackIndex}: unsupported geometry type.`);
|
|
fallbackIndex += 1;
|
|
continue;
|
|
}
|
|
|
|
// Type filtering: respect importPoints / importPaths opts
|
|
if (isPath && !importPaths) {
|
|
summary.skippedCount += 1;
|
|
fallbackIndex += 1;
|
|
continue;
|
|
}
|
|
if (!isPath && !importPoints) {
|
|
summary.skippedCount += 1;
|
|
fallbackIndex += 1;
|
|
continue;
|
|
}
|
|
|
|
const fallbackName = `Placemark ${fallbackIndex}`;
|
|
const name = parsedPlacemark.name || fallbackName;
|
|
|
|
if (isPlaceDuplicate({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng }, dedup)) {
|
|
summary.skippedCount += 1;
|
|
dupCount++;
|
|
fallbackIndex += 1;
|
|
continue;
|
|
}
|
|
|
|
const categoryId = resolveCategoryIdForFolder(parsedPlacemark.folderName, categoryLookup);
|
|
|
|
const result = insertStmt.run(
|
|
tripId,
|
|
name,
|
|
parsedPlacemark.description,
|
|
parsedPlacemark.lat,
|
|
parsedPlacemark.lng,
|
|
categoryId,
|
|
parsedPlacemark.routeGeometry,
|
|
);
|
|
|
|
const place = getPlaceWithTags(Number(result.lastInsertRowid));
|
|
created.push(place);
|
|
trackInsertedInDedupSet({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng }, dedup);
|
|
summary.createdCount += 1;
|
|
fallbackIndex += 1;
|
|
}
|
|
});
|
|
|
|
insertAll();
|
|
|
|
if (dupCount > 0) {
|
|
summary.warnings.push(`${dupCount} place${dupCount > 1 ? 's' : ''} skipped (already in trip).`);
|
|
}
|
|
|
|
if (summary.totalPlacemarks === 0) {
|
|
summary.errors.push('No Placemarks found in KML file.');
|
|
}
|
|
|
|
return { places: created, count: created.length, summary };
|
|
}
|
|
|
|
export async function unpackKmzToKml(
|
|
kmzBuffer: Buffer,
|
|
decompressedSizeLimit = KMZ_DECOMPRESSED_SIZE_LIMIT,
|
|
): Promise<Buffer> {
|
|
let zip;
|
|
try {
|
|
zip = await unzipper.Open.buffer(kmzBuffer);
|
|
} catch {
|
|
throw new Error('Invalid KMZ archive.');
|
|
}
|
|
|
|
const kmlEntries = zip.files.filter((entry) => !entry.path.endsWith('/') && entry.path.toLowerCase().endsWith('.kml'));
|
|
if (kmlEntries.length === 0) {
|
|
throw new Error('KMZ archive does not contain a KML file.');
|
|
}
|
|
|
|
const preferredEntry = kmlEntries.find((entry) => entry.path.toLowerCase().endsWith('doc.kml')) || kmlEntries[0];
|
|
|
|
if (preferredEntry.uncompressedSize > decompressedSizeLimit) {
|
|
throw new Error('KMZ archive exceeds the maximum allowed decompressed size.');
|
|
}
|
|
|
|
return preferredEntry.buffer();
|
|
}
|
|
|
|
export async function importKmzPlaces(tripId: string, kmzBuffer: Buffer, opts: KmlImportOptions = {}): Promise<PlaceImportResult> {
|
|
const kmlBuffer = await unpackKmzToKml(kmzBuffer);
|
|
return importKmlPlaces(tripId, kmlBuffer, opts);
|
|
}
|
|
|
|
export async function importMapFile(tripId: string, fileBuffer: Buffer, filename: string, opts: KmlImportOptions = {}): Promise<PlaceImportResult> {
|
|
const ext = filename.toLowerCase().split('.').pop();
|
|
if (ext === 'kmz') return importKmzPlaces(tripId, fileBuffer, opts);
|
|
if (ext === 'kml') return importKmlPlaces(tripId, fileBuffer, opts);
|
|
throw new Error(`Unsupported map file format: .${ext}. Please upload a .kml or .kmz file.`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Import Google Maps list
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function importGoogleList(tripId: string, url: string) {
|
|
let listId: string | null = null;
|
|
let resolvedUrl = url;
|
|
|
|
// SSRF guard: validate user-supplied URL before fetching
|
|
const ssrf = await checkSsrf(url);
|
|
if (!ssrf.allowed) return { error: 'URL is not allowed', status: 400 };
|
|
|
|
// Follow redirects for short URLs (maps.app.goo.gl, goo.gl). Redirects are
|
|
// followed manually so every hop is re-checked against the SSRF guard — a
|
|
// short link that 302s to an internal IP is blocked even though the initial
|
|
// host is public.
|
|
if (url.includes('goo.gl') || url.includes('maps.app')) {
|
|
try {
|
|
const redirectRes = await safeFetchFollow(url, { signal: AbortSignal.timeout(10000) });
|
|
resolvedUrl = redirectRes.url;
|
|
} catch (err) {
|
|
if (err instanceof SsrfBlockedError) return { error: 'URL is not allowed', status: 400 };
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// Pattern: /placelists/list/{ID}
|
|
const plMatch = resolvedUrl.match(/placelists\/list\/([A-Za-z0-9_-]+)/);
|
|
if (plMatch) listId = plMatch[1];
|
|
|
|
// Pattern: !2s{ID} in data URL params
|
|
if (!listId) {
|
|
const dataMatch = resolvedUrl.match(/!2s([A-Za-z0-9_-]{15,})/);
|
|
if (dataMatch) listId = dataMatch[1];
|
|
}
|
|
|
|
if (!listId) {
|
|
return { error: 'Could not extract list ID from URL. Please use a shared Google Maps list link.', status: 400 };
|
|
}
|
|
|
|
// Fetch list data from Google Maps internal API
|
|
const apiUrl = `https://www.google.com/maps/preview/entitylist/getlist?authuser=0&hl=en&gl=us&pb=!1m1!1s${encodeURIComponent(listId)}!2e2!3e2!4i500!16b1`;
|
|
const apiRes = await fetch(apiUrl, {
|
|
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' },
|
|
signal: AbortSignal.timeout(15000),
|
|
});
|
|
|
|
if (!apiRes.ok) {
|
|
return { error: 'Failed to fetch list from Google Maps', status: 502 };
|
|
}
|
|
|
|
const rawText = await apiRes.text();
|
|
const jsonStr = rawText.substring(rawText.indexOf('\n') + 1);
|
|
const listData = JSON.parse(jsonStr);
|
|
|
|
const meta = listData[0];
|
|
if (!meta) {
|
|
return { error: 'Invalid list data received from Google Maps', status: 400 };
|
|
}
|
|
|
|
const listName = meta[4] || 'Google Maps List';
|
|
const items = meta[8];
|
|
|
|
if (!Array.isArray(items) || items.length === 0) {
|
|
return { error: 'List is empty or could not be read', status: 400 };
|
|
}
|
|
|
|
// Parse place data from items
|
|
const places: { name: string; lat: number; lng: number; notes: string | null }[] = [];
|
|
for (const item of items) {
|
|
const coords = item?.[1]?.[5];
|
|
const lat = coords?.[2];
|
|
const lng = coords?.[3];
|
|
const name = item?.[2];
|
|
const note = item?.[3] || null;
|
|
|
|
if (name && typeof lat === 'number' && typeof lng === 'number' && !isNaN(lat) && !isNaN(lng)) {
|
|
places.push({ name, lat, lng, notes: note || null });
|
|
}
|
|
}
|
|
|
|
if (places.length === 0) {
|
|
return { error: 'No places with coordinates found in list', status: 400 };
|
|
}
|
|
|
|
const dedup = buildDedupSet(tripId);
|
|
const insertStmt = db.prepare(`
|
|
INSERT INTO places (trip_id, name, lat, lng, notes, transport_mode)
|
|
VALUES (?, ?, ?, ?, ?, 'walking')
|
|
`);
|
|
const created: any[] = [];
|
|
let skipped = 0;
|
|
const insertAll = db.transaction(() => {
|
|
for (const p of places) {
|
|
if (isPlaceDuplicate({ name: p.name, lat: p.lat, lng: p.lng }, dedup)) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.notes);
|
|
const place = getPlaceWithTags(Number(result.lastInsertRowid));
|
|
created.push(place);
|
|
trackInsertedInDedupSet({ name: p.name, lat: p.lat, lng: p.lng }, dedup);
|
|
}
|
|
});
|
|
insertAll();
|
|
|
|
return { places: created, listName, skipped };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Import Naver Maps list
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function importNaverList(
|
|
tripId: string,
|
|
url: string,
|
|
): Promise<{ places: any[]; listName: string; skipped: number } | { error: string; status: number }> {
|
|
let resolvedUrl = url;
|
|
const limit = 20;
|
|
|
|
// SSRF guard: validate user-supplied URL before fetching
|
|
const ssrf = await checkSsrf(url);
|
|
if (!ssrf.allowed) return { error: 'URL is not allowed', status: 400 };
|
|
|
|
// Resolve naver.me short links to the canonical map.naver.com folder URL.
|
|
// Redirects are followed manually so each hop is re-validated against the
|
|
// SSRF guard (a short link could otherwise 302 to an internal address).
|
|
let parsedUrl: URL;
|
|
try { parsedUrl = new URL(url); } catch { return { error: 'Invalid URL', status: 400 }; }
|
|
if (parsedUrl.hostname === 'naver.me') {
|
|
try {
|
|
const redirectRes = await safeFetchFollow(url, { signal: AbortSignal.timeout(10000) });
|
|
resolvedUrl = redirectRes.url;
|
|
} catch (err) {
|
|
if (err instanceof SsrfBlockedError) return { error: 'URL is not allowed', status: 400 };
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
const folderMatch = resolvedUrl.match(/favorite\/myPlace\/folder\/([A-Za-z0-9_-]+)/i);
|
|
const folderId = folderMatch?.[1] || null;
|
|
if (!folderId) {
|
|
return { error: 'Could not extract folder ID from URL. Please use a shared Naver Maps list link.', status: 400 };
|
|
}
|
|
|
|
const fetchPage = async (start: number) => {
|
|
const apiUrl = `https://pages.map.naver.com/save-pages/api/maps-bookmark/v3/shares/${encodeURIComponent(folderId)}/bookmarks?placeInfo=true&start=${start}&limit=${limit}&sort=lastUseTime&mcids=ALL&createIdNo=true`;
|
|
const apiRes = await fetch(apiUrl, {
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
},
|
|
signal: AbortSignal.timeout(15000),
|
|
});
|
|
|
|
if (!apiRes.ok) {
|
|
return { error: 'Failed to fetch list from Naver Maps', status: 502 } as const;
|
|
}
|
|
|
|
try {
|
|
const data = await apiRes.json() as {
|
|
folder?: { bookmarkCount?: number; name?: string };
|
|
bookmarkList?: any[];
|
|
};
|
|
return { data } as const;
|
|
} catch {
|
|
return { error: 'Invalid list data received from Naver Maps', status: 400 } as const;
|
|
}
|
|
};
|
|
|
|
const firstPage = await fetchPage(0);
|
|
if ('error' in firstPage) {
|
|
return { error: firstPage.error, status: firstPage.status };
|
|
}
|
|
|
|
const listName = firstPage.data.folder?.name || 'Naver Maps List';
|
|
const totalCount = typeof firstPage.data.folder?.bookmarkCount === 'number'
|
|
? firstPage.data.folder.bookmarkCount
|
|
: (firstPage.data.bookmarkList?.length || 0);
|
|
|
|
const allItems: any[] = [...(firstPage.data.bookmarkList || [])];
|
|
for (let start = limit; start < totalCount; start += limit) {
|
|
const page = await fetchPage(start);
|
|
if ('error' in page) {
|
|
return { error: page.error, status: page.status };
|
|
}
|
|
const pageItems = page.data.bookmarkList || [];
|
|
if (!Array.isArray(pageItems) || pageItems.length === 0) break;
|
|
allItems.push(...pageItems);
|
|
}
|
|
|
|
if (allItems.length === 0) {
|
|
return { error: 'List is empty or could not be read', status: 400 };
|
|
}
|
|
|
|
const places: { name: string; lat: number; lng: number; notes: string | null; address: string | null }[] = [];
|
|
for (const item of allItems) {
|
|
const lat = Number(item?.py);
|
|
const lng = Number(item?.px);
|
|
const name = typeof item?.name === 'string' && item.name.trim()
|
|
? item.name.trim()
|
|
: (typeof item?.displayName === 'string' ? item.displayName.trim() : '');
|
|
const note = typeof item?.memo === 'string' && item.memo.trim() ? item.memo.trim() : null;
|
|
const address = typeof item?.address === 'string' && item.address.trim() ? item.address.trim() : null;
|
|
|
|
if (name && Number.isFinite(lat) && Number.isFinite(lng)) {
|
|
places.push({ name, lat, lng, notes: note, address });
|
|
}
|
|
}
|
|
|
|
if (places.length === 0) {
|
|
return { error: 'No places with coordinates found in list', status: 400 };
|
|
}
|
|
|
|
const dedup = buildDedupSet(tripId);
|
|
const insertStmt = db.prepare(`
|
|
INSERT INTO places (trip_id, name, lat, lng, address, notes, transport_mode)
|
|
VALUES (?, ?, ?, ?, ?, ?, 'walking')
|
|
`);
|
|
const created: any[] = [];
|
|
let skipped = 0;
|
|
const insertAll = db.transaction(() => {
|
|
for (const p of places) {
|
|
if (isPlaceDuplicate({ name: p.name, lat: p.lat, lng: p.lng }, dedup)) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.address, p.notes);
|
|
const place = getPlaceWithTags(Number(result.lastInsertRowid));
|
|
created.push(place);
|
|
trackInsertedInDedupSet({ name: p.name, lat: p.lat, lng: p.lng }, dedup);
|
|
}
|
|
});
|
|
insertAll();
|
|
|
|
return { places: created, listName, skipped };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Search place image (Unsplash)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function searchPlaceImage(tripId: string, placeId: string, userId: number) {
|
|
const place = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId) as Place | undefined;
|
|
if (!place) return { error: 'Place not found', status: 404 };
|
|
|
|
const user = db.prepare('SELECT unsplash_api_key FROM users WHERE id = ?').get(userId) as { unsplash_api_key: string | null } | undefined;
|
|
if (!user || !user.unsplash_api_key) {
|
|
return { error: 'No Unsplash API key configured', status: 400 };
|
|
}
|
|
|
|
const query = encodeURIComponent(place.name + (place.address ? ' ' + place.address : ''));
|
|
const response = await fetch(
|
|
`https://api.unsplash.com/search/photos?query=${query}&per_page=5&client_id=${user.unsplash_api_key}`,
|
|
);
|
|
const data = await response.json() as UnsplashSearchResponse;
|
|
|
|
if (!response.ok) {
|
|
return { error: data.errors?.[0] || 'Unsplash API error', status: response.status };
|
|
}
|
|
|
|
const photos = (data.results || []).map((p: NonNullable<UnsplashSearchResponse['results']>[number]) => ({
|
|
id: p.id,
|
|
url: p.urls?.regular,
|
|
thumb: p.urls?.thumb,
|
|
description: p.description || p.alt_description,
|
|
photographer: p.user?.name,
|
|
link: p.links?.html,
|
|
}));
|
|
|
|
return { photos };
|
|
}
|