From 12da5452dccc076dd5d117bed9d8e8c41256ff44 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 10:39:18 +0200 Subject: [PATCH 1/2] feat(core): export GeoJSON, CSV ed E7 (formato del blog) - exportToGeoJson: point/visit come Point, activity come LineString (path o start/end), coordinate [lng, lat] - exportToCsv: una riga per punto, visita e punto di path (RFC 4180) - exportToE7: { locations: [{ latitudeE7, longitudeE7, timestamp }] } da punti grezzi e path delle activity, ordinato per tempo; sostituisce la conversione manuale del blog - exportDataset / exportFormatFromPath: dispatcher comune a CLI e app (.kml, .geojson, .csv, .e7.json, altrimenti JSON) - CLI: nuovi formati e --export-format; app: filtri nel dialog di export - README e AGENTS.md aggiornati Closes #16 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CecUKcJiSVnehcZEiXLgtk --- AGENTS.md | 6 +- README.md | 12 +- packages/app/src/main/index.ts | 13 +- packages/app/src/renderer/main.ts | 3 + packages/core/src/cli.ts | 33 +-- packages/core/src/exporters/csv-exporter.ts | 91 +++++++++ packages/core/src/exporters/e7-exporter.ts | 70 +++++++ .../core/src/exporters/geojson-exporter.ts | 117 +++++++++++ packages/core/src/exporters/index.ts | 51 ++++- packages/core/src/index.ts | 14 ++ packages/core/tests/cli.test.ts | 45 +++++ packages/core/tests/exporters/formats.test.ts | 188 ++++++++++++++++++ 12 files changed, 610 insertions(+), 33 deletions(-) create mode 100644 packages/core/src/exporters/csv-exporter.ts create mode 100644 packages/core/src/exporters/e7-exporter.ts create mode 100644 packages/core/src/exporters/geojson-exporter.ts create mode 100644 packages/core/tests/exporters/formats.test.ts diff --git a/AGENTS.md b/AGENTS.md index 416fc17..d7b8ea9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,11 +35,11 @@ npm run typecheck --workspace=packages/app npm run dist --workspace=packages/app # build + electron-builder (anche dist:win / dist:mac / dist:linux) # CLI dopo la build del core -node packages/core/dist/cli.js load [--filter-date A B] [--filter-area s,w,n,e] [--filter-activity X,Y] [--stats] [--export out.json|out.kml] +node packages/core/dist/cli.js load [--filter-date A B] [--filter-area s,w,n,e] [--filter-activity X,Y] [--stats] [--quality] [--sanitize] [--export out.json|.kml|.geojson|.csv|.e7.json] [--export-format F] ``` ## Struttura -- `packages/core/src/` — `loaders/` (un loader per formato Google + auto-detect), `filters.ts`, `stats.ts`, `transforms.ts`, `exporters/` (JSON, KML), `activity-mapping.ts`, `types.ts`, `cli.ts`, `index.ts` +- `packages/core/src/` — `loaders/` (un loader per formato Google + auto-detect), `filters.ts`, `stats.ts`, `quality.ts`, `transforms.ts`, `exporters/` (JSON, KML, GeoJSON, CSV, E7 + dispatcher `exportDataset`), `activity-mapping.ts`, `types.ts`, `cli.ts`, `index.ts` - `packages/core/tests/` — test Vitest, con la stessa struttura di `src/` - `packages/app/src/main/` — main process Electron (dataset in memoria, dialog, IPC) - `packages/app/src/preload/` — espone `window.api` tipizzato via `contextBridge` @@ -67,5 +67,5 @@ node packages/core/dist/cli.js load [--filter-date A B] [--filter-area s, - I filtri sono immutabili (restituiscono un nuovo dataset); `filterByActivityType` mantiene sempre le visite; il filtro date tiene le entry che si sovrappongono al range - Packaging: l'app pacchettizzata non contiene `node_modules`. Il main include `mappit-core` nel bundle dai sorgenti (alias in `electron.vite.config.ts`) e electron-builder ha `npmRebuild: false`, perché altrimenti lancia `npm install --production` alla radice del monorepo e cancella le devDependencies - Electron: `contextIsolation: true`, `nodeIntegration: false`, `sandbox: false` (necessario per il preload). Il main tiene `originalDataset` (mai modificato) e `currentDataset` -- Formato condiviso con il blog: il blog si aspetta `{ locations: [{ latitudeE7, longitudeE7, timestamp }] }`, mentre l'export di mappit produce `lat`/`lng` decimali. Oggi la conversione è manuale (vedi `PLAN.md` nella repo blog) +- Formato condiviso con il blog: il blog si aspetta `{ locations: [{ latitudeE7, longitudeE7, timestamp }] }`. Lo produce l'exporter E7 (`exportToE7`, CLI `--export route.e7.json`): punti grezzi + punti dei path delle activity, ordinati per tempo; è l'unico punto in cui mappit usa coordinate E7 - Prettier: virgolette singole, `printWidth` 100. TODO: `.prettierrc` dice `tabWidth: 2` ma il codice usa 4 spazi diff --git a/README.md b/README.md index a13b02f..7966c39 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,13 @@ MappIt is a privacy-first toolkit for exploring your Google Location History locally: -- **Electron desktop app** with interactive map (deck.gl + MapLibre), timeline sidebar, activity filters, area search, and KML/JSON export +- **Electron desktop app** with interactive map (deck.gl + MapLibre), timeline sidebar, activity filters, area search, and KML/JSON/GeoJSON/CSV/E7 export - **Node.js CLI** for headless processing, filtering, and exporting - **Core library** (`mappit-core`) usable in your own scripts - Auto-detects all Google Takeout location formats (Records.json, Timeline.json standard/semantic/iOS, monthly Semantic Location History files) - Filters by date range, bounding box, or activity type - Computes stats: distance per activity, monthly summaries, yearly breakdowns -- Exports to JSON and KML +- Exports to JSON, KML, GeoJSON, CSV and E7 locations (`latitudeE7`/`longitudeE7`) @@ -134,6 +134,7 @@ npx mappit-core load ./data --stats # Esportazione npx mappit-core load ./data --filter-date 2024-01-01 2024-06-30 --export output.json npx mappit-core load ./data --filter-date 2024-01-01 2024-06-30 --export output.kml +npx mappit-core load ./data --filter-date 2024-01-01 2024-06-30 --export route.e7.json # E7 per il blog ``` Tutte le opzioni: @@ -152,7 +153,8 @@ Options: -a, --filter-area Filtra per bounding box: south,west,north,east [string] --filter-activity Filtra per tipo di attività (separati da virgola) [string] - -e, --export Esporta su file (.json o .kml) [string] + -e, --export Esporta su file (.json, .kml, .geojson, .csv, .e7.json) [string] + --export-format Forza il formato (json, kml, geojson, csv, e7) [string] -s, --stats Mostra statistiche di riepilogo [boolean] [default: false] ``` @@ -194,7 +196,7 @@ App desktop per esplorare i dati di Google Location History. Usa `mappit-core` c - Auto-detect del formato (tutti e 5 i formati supportati dal core) - Visualizzazione summary: statistiche generali, distanza per attività, breakdown annuale - Filtro per intervallo date con ricalcolo statistiche -- Export dataset filtrato in JSON o KML +- Export dataset filtrato in JSON, KML, GeoJSON, CSV o E7 ### Architettura @@ -234,7 +236,7 @@ dist/ - **Vite** v7 come bundler per il renderer - **Vanilla TypeScript** per la UI (nessun framework) -> L'app include mappa interattiva (deck.gl + MapLibre), sidebar timeline, filtri attività, ricerca luoghi, area search, summary con grafici, e export KML/JSON. +> L'app include mappa interattiva (deck.gl + MapLibre), sidebar timeline, filtri attività, ricerca luoghi, area search, summary con grafici, e export KML/JSON/GeoJSON/CSV/E7. ### Distribuzione diff --git a/packages/app/src/main/index.ts b/packages/app/src/main/index.ts index 5be52f5..857d52b 100644 --- a/packages/app/src/main/index.ts +++ b/packages/app/src/main/index.ts @@ -24,8 +24,8 @@ import { computeSummary, computeYearlySummary, computeMonthlySummary, - exportToJson, - exportToKml, + exportDataset, + exportFormatFromPath, } from 'mappit-core'; import type { MappitDataset, DataSource, BoundingBox, PlaceVisit } from 'mappit-core'; import type { PlaceSearchHit } from '../shared/ipc-channels'; @@ -315,13 +315,8 @@ function registerIpcHandlers(): void { throw new Error('No dataset loaded'); } - const ext = path.extname(args.filePath).toLowerCase(); - const content = - ext === '.kml' - ? exportToKml(currentDataset) - : exportToJson(currentDataset); - - fs.writeFileSync(args.filePath, content, 'utf-8'); + const format = exportFormatFromPath(args.filePath); + fs.writeFileSync(args.filePath, exportDataset(currentDataset, format), 'utf-8'); return true; }, ); diff --git a/packages/app/src/renderer/main.ts b/packages/app/src/renderer/main.ts index 22e8221..33afb4f 100644 --- a/packages/app/src/renderer/main.ts +++ b/packages/app/src/renderer/main.ts @@ -187,6 +187,9 @@ async function handleExport(): Promise { defaultName: 'mappit-export.kml', filters: [ { name: 'KML files', extensions: ['kml'] }, + { name: 'GeoJSON files', extensions: ['geojson'] }, + { name: 'CSV files', extensions: ['csv'] }, + { name: 'E7 locations (travel blog)', extensions: ['e7.json'] }, { name: 'JSON files', extensions: ['json'] }, { name: 'All files', extensions: ['*'] }, ], diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 2b7b4cd..cb40456 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -10,6 +10,9 @@ * mappit-core load --filter-activity WALKING,CYCLING * mappit-core load --export output.json * mappit-core load --export output.kml + * mappit-core load --export output.geojson # also .csv + * mappit-core load --export route.e7.json # E7 locations (travel blog) + * mappit-core load --export out.json --export-format e7 * mappit-core load --stats * mappit-core load --quality # validation + quality metrics * mappit-core load --sanitize --export clean.json # drop invalid items @@ -32,8 +35,8 @@ import { filterByDateRange, filterByArea, filterByActivityType } from './filters import type { BoundingBox } from './filters'; import { computeSummary, computeYearlySummary } from './stats'; import { validateDataset, sanitizeDataset, computeQualityMetrics } from './quality'; -import { exportToJson } from './exporters/json-exporter'; -import { exportToKml } from './exporters/kml-exporter'; +import { exportDataset, exportFormatFromPath, EXPORT_FORMATS } from './exporters'; +import type { ExportFormat } from './exporters'; import type { MappitDataset, DataSource } from './types'; // --------------------------------------------------------------------------- @@ -326,7 +329,13 @@ export function run(argv?: string[]): void { }) .option('export', { alias: 'e', - describe: 'Export to file (.json or .kml)', + describe: + 'Export to file; format from the extension: .json, .kml, .geojson, .csv, .e7.json (E7 locations)', + type: 'string', + }) + .option('export-format', { + describe: 'Force the export format instead of using the file extension', + choices: EXPORT_FORMATS, type: 'string', }) .option('stats', { @@ -394,18 +403,12 @@ export function run(argv?: string[]): void { // 4 — Export if (cmdArgv.export) { - spinner.start(`Exporting to ${cmdArgv.export}…`); - const ext = path.extname(cmdArgv.export).toLowerCase(); - let content: string; - - if (ext === '.kml') { - content = exportToKml(ds); - } else { - content = exportToJson(ds); - } - - fs.writeFileSync(cmdArgv.export, content, 'utf-8'); - spinner.succeed(`Written to ${cmdArgv.export}`); + const format = + (cmdArgv.exportFormat as ExportFormat | undefined) ?? + exportFormatFromPath(cmdArgv.export); + spinner.start(`Exporting ${format} to ${cmdArgv.export}…`); + fs.writeFileSync(cmdArgv.export, exportDataset(ds, format), 'utf-8'); + spinner.succeed(`Written ${format} to ${cmdArgv.export}`); } } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); diff --git a/packages/core/src/exporters/csv-exporter.ts b/packages/core/src/exporters/csv-exporter.ts new file mode 100644 index 0000000..5a25aca --- /dev/null +++ b/packages/core/src/exporters/csv-exporter.ts @@ -0,0 +1,91 @@ +/** + * Export a MappitDataset to CSV, one row per location. + * + * Rows, in dataset order: + * - `point`: a raw point + * - `visit`: a visit (`timestamp` = start, `end_time` = end) + * - `path`: a GPS point of an activity path (`timestamp` falls back to the + * activity start when the point has none) + * + * Coordinates are decimal degrees. Values are quoted per RFC 4180 when needed. + */ + +import type { MappitDataset } from '../types'; + +/** Options for {@link exportToCsv}. */ +export interface CsvExportOptions { + /** Field separator (default `,`). */ + delimiter?: string; + /** Line ending (default `\n`). */ + newline?: string; +} + +/** Column order of the CSV output. */ +export const CSV_COLUMNS = [ + 'kind', + 'timestamp', + 'end_time', + 'lat', + 'lng', + 'activity_type', + 'accuracy', + 'name', + 'place_id', +] as const; + +type Row = Partial>; + +function cell(value: string | number | undefined, delimiter: string): string { + if (value === undefined || value === null) return ''; + const s = String(value); + return s.includes(delimiter) || /["\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; +} + +/** + * Serialise a dataset as CSV text, with a header row. + */ +export function exportToCsv(dataset: MappitDataset, options: CsvExportOptions = {}): string { + const { delimiter = ',', newline = '\n' } = options; + const rows: Row[] = []; + + for (const p of dataset.points) { + rows.push({ + kind: 'point', + timestamp: p.timestamp, + lat: p.lat, + lng: p.lng, + activity_type: p.activityType, + accuracy: p.accuracy, + }); + } + + for (const e of dataset.timeline) { + if (e.type === 'visit') { + rows.push({ + kind: 'visit', + timestamp: e.startTime, + end_time: e.endTime, + lat: e.lat, + lng: e.lng, + name: e.name, + place_id: e.placeId, + }); + } else { + for (const p of e.path) { + rows.push({ + kind: 'path', + timestamp: p.timestamp ?? e.startTime, + lat: p.lat, + lng: p.lng, + activity_type: e.activityType, + }); + } + } + } + + const lines = [CSV_COLUMNS.join(delimiter)]; + for (const r of rows) { + lines.push(CSV_COLUMNS.map((c) => cell(r[c], delimiter)).join(delimiter)); + } + return lines.join(newline) + newline; +} diff --git a/packages/core/src/exporters/e7-exporter.ts b/packages/core/src/exporters/e7-exporter.ts new file mode 100644 index 0000000..5dd06e8 --- /dev/null +++ b/packages/core/src/exporters/e7-exporter.ts @@ -0,0 +1,70 @@ +/** + * Export a MappitDataset to the E7 "locations" JSON used by the travel blog + * (and by the legacy Google Records.json): + * + * ```json + * { "locations": [{ "latitudeE7": 454642000, "longitudeE7": 91900000, "timestamp": "…" }] } + * ``` + * + * E7 = decimal degrees × 10^7, rounded to an integer. This is the only place + * where mappit produces E7 coordinates: the internal model stays decimal. + * + * Locations come from raw points and from activity GPS paths (a path point + * without a timestamp takes the activity start). Visits are excluded by + * default, matching the blog's route extraction. Output is sorted by time. + */ + +import type { MappitDataset } from '../types'; + +/** Options for {@link exportToE7}. */ +export interface E7ExportOptions { + /** Pretty-print with indentation (default `2`). Set `0` for compact. */ + indent?: number; + /** Also emit one location per visit, at its start time (default `false`). */ + includeVisits?: boolean; +} + +/** One location in E7 format. */ +export interface E7Location { + latitudeE7: number; + longitudeE7: number; + timestamp: string; +} + +const toE7 = (deg: number): number => Math.round(deg * 1e7); + +/** + * Build the `{ locations }` object from a dataset. + */ +export function toE7Locations( + dataset: MappitDataset, + options: Omit = {}, +): { locations: E7Location[] } { + const { includeVisits = false } = options; + const locations: E7Location[] = []; + const push = (lat: number, lng: number, timestamp: string) => + locations.push({ latitudeE7: toE7(lat), longitudeE7: toE7(lng), timestamp }); + + for (const p of dataset.points) push(p.lat, p.lng, p.timestamp); + + for (const e of dataset.timeline) { + if (e.type === 'activity') { + for (const p of e.path) push(p.lat, p.lng, p.timestamp ?? e.startTime); + } else if (includeVisits) { + push(e.lat, e.lng, e.startTime); + } + } + + // Stable sort by instant (handles mixed UTC offsets) + const indexed = locations.map((l, i) => ({ l, i, t: Date.parse(l.timestamp) })); + indexed.sort((a, b) => (a.t || 0) - (b.t || 0) || a.i - b.i); + return { locations: indexed.map((x) => x.l) }; +} + +/** + * Serialise a dataset as E7 locations JSON. + */ +export function exportToE7(dataset: MappitDataset, options: E7ExportOptions = {}): string { + const { indent = 2, ...rest } = options; + return JSON.stringify(toE7Locations(dataset, rest), null, indent); +} diff --git a/packages/core/src/exporters/geojson-exporter.ts b/packages/core/src/exporters/geojson-exporter.ts new file mode 100644 index 0000000..32be03e --- /dev/null +++ b/packages/core/src/exporters/geojson-exporter.ts @@ -0,0 +1,117 @@ +/** + * Export a MappitDataset to GeoJSON (RFC 7946). + * + * - Raw points → `Point` features (`kind: "point"`) + * - Visits → `Point` features (`kind: "visit"`) + * - Activities → `LineString` features (`kind: "activity"`) built from the + * GPS path, or from start/end location when the path has fewer than 2 points + * + * Coordinates follow the GeoJSON order `[lng, lat]`. + */ + +import type { MappitDataset } from '../types'; + +/** Options for {@link exportToGeoJson}. */ +export interface GeoJsonExportOptions { + /** Pretty-print with indentation (default `2`). Set `0` for compact. */ + indent?: number; + /** Include raw points (default `true`). */ + includePoints?: boolean; + /** Include visits and activities (default `true`). */ + includeTimeline?: boolean; +} + +type Position = [number, number]; + +interface Feature { + type: 'Feature'; + geometry: { type: 'Point'; coordinates: Position } | { type: 'LineString'; coordinates: Position[] }; + properties: Record; +} + +/** Drop `undefined` values so the output stays compact. */ +function clean(props: Record): Record { + return Object.fromEntries(Object.entries(props).filter(([, v]) => v !== undefined)); +} + +/** + * Build a GeoJSON `FeatureCollection` object from a dataset. + */ +export function toGeoJson( + dataset: MappitDataset, + options: Omit = {}, +): { type: 'FeatureCollection'; features: Feature[] } { + const { includePoints = true, includeTimeline = true } = options; + const features: Feature[] = []; + + if (includePoints) { + for (const p of dataset.points) { + features.push({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [p.lng, p.lat] }, + properties: clean({ + kind: 'point', + timestamp: p.timestamp, + accuracy: p.accuracy, + velocity: p.velocity, + heading: p.heading, + altitude: p.altitude, + activityType: p.activityType, + }), + }); + } + } + + if (includeTimeline) { + for (const e of dataset.timeline) { + if (e.type === 'visit') { + features.push({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [e.lng, e.lat] }, + properties: clean({ + kind: 'visit', + startTime: e.startTime, + endTime: e.endTime, + name: e.name, + placeId: e.placeId, + semanticType: e.semanticType, + }), + }); + } else { + const coords: Position[] = + e.path.length >= 2 + ? e.path.map((p) => [p.lng, p.lat]) + : [ + [e.startLocation.lng, e.startLocation.lat], + [e.endLocation.lng, e.endLocation.lat], + ]; + features.push({ + type: 'Feature', + geometry: { type: 'LineString', coordinates: coords }, + properties: clean({ + kind: 'activity', + startTime: e.startTime, + endTime: e.endTime, + activityType: e.activityType, + distanceMeters: e.distanceMeters, + pathPoints: e.path.length, + timestamps: + e.path.length >= 2 && e.path.some((p) => p.timestamp) + ? e.path.map((p) => p.timestamp ?? null) + : undefined, + }), + }); + } + } + } + + return { type: 'FeatureCollection', features }; +} + +/** + * Serialise a dataset as a GeoJSON string. + */ +export function exportToGeoJson(dataset: MappitDataset, options: GeoJsonExportOptions = {}): string { + const { indent = 2, ...rest } = options; + return JSON.stringify(toGeoJson(dataset, rest), null, indent); +} diff --git a/packages/core/src/exporters/index.ts b/packages/core/src/exporters/index.ts index f999712..2fbe548 100644 --- a/packages/core/src/exporters/index.ts +++ b/packages/core/src/exporters/index.ts @@ -1,5 +1,54 @@ /** - * Barrel export for all exporters. + * Barrel export for all exporters, plus a format dispatcher shared by the + * CLI and the desktop app. */ +import type { MappitDataset } from '../types'; +import { exportToJson } from './json-exporter'; +import { exportToKml } from './kml-exporter'; +import { exportToGeoJson } from './geojson-exporter'; +import { exportToCsv } from './csv-exporter'; +import { exportToE7 } from './e7-exporter'; + export { exportToJson, type JsonExportOptions } from './json-exporter'; export { exportToKml, type KmlExportOptions } from './kml-exporter'; +export { exportToGeoJson, toGeoJson, type GeoJsonExportOptions } from './geojson-exporter'; +export { exportToCsv, CSV_COLUMNS, type CsvExportOptions } from './csv-exporter'; +export { exportToE7, toE7Locations, type E7ExportOptions, type E7Location } from './e7-exporter'; + +/** Supported export formats. */ +export type ExportFormat = 'json' | 'kml' | 'geojson' | 'csv' | 'e7'; + +export const EXPORT_FORMATS: readonly ExportFormat[] = ['json', 'kml', 'geojson', 'csv', 'e7']; + +/** + * Infer the export format from a file name: + * `.kml`, `.geojson`, `.csv`, `.e7.json` (E7 locations), anything else → JSON. + */ +export function exportFormatFromPath(filePath: string): ExportFormat { + const name = filePath.toLowerCase(); + if (name.endsWith('.e7.json')) return 'e7'; + if (name.endsWith('.kml')) return 'kml'; + if (name.endsWith('.geojson')) return 'geojson'; + if (name.endsWith('.csv')) return 'csv'; + return 'json'; +} + +/** + * Serialise a dataset in the given format with default options. + */ +export function exportDataset(dataset: MappitDataset, format: ExportFormat): string { + switch (format) { + case 'kml': + return exportToKml(dataset); + case 'geojson': + return exportToGeoJson(dataset); + case 'csv': + return exportToCsv(dataset); + case 'e7': + return exportToE7(dataset); + case 'json': + return exportToJson(dataset); + default: + throw new Error(`Unknown export format "${String(format)}"`); + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d19f11b..17a9e35 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -92,6 +92,20 @@ export { export { exportToJson, exportToKml, + exportToGeoJson, + toGeoJson, + exportToCsv, + CSV_COLUMNS, + exportToE7, + toE7Locations, + exportDataset, + exportFormatFromPath, + EXPORT_FORMATS, + type ExportFormat, type JsonExportOptions, type KmlExportOptions, + type GeoJsonExportOptions, + type CsvExportOptions, + type E7ExportOptions, + type E7Location, } from './exporters'; diff --git a/packages/core/tests/cli.test.ts b/packages/core/tests/cli.test.ts index dbdbb7d..4bab00a 100644 --- a/packages/core/tests/cli.test.ts +++ b/packages/core/tests/cli.test.ts @@ -244,3 +244,48 @@ describe('CLI — quality', () => { fs.rmSync(tmp, { recursive: true, force: true }); }); }); + +// --------------------------------------------------------------------------- +// Export formats (GeoJSON, CSV, E7) +// --------------------------------------------------------------------------- +describe('CLI — export formats', () => { + const input = path.join(FIXTURES, 'timeline-semantic-paths.json'); + const withTmp = (name: string, fn: (out: string) => void) => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mappit-')); + try { + fn(path.join(tmp, name)); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }; + + it('exports GeoJSON from the .geojson extension', () => { + withTmp('out.geojson', (out) => { + run(['load', input, '--export', out]); + expect(JSON.parse(fs.readFileSync(out, 'utf-8')).type).toBe('FeatureCollection'); + }); + }); + + it('exports CSV from the .csv extension', () => { + withTmp('out.csv', (out) => { + run(['load', input, '--export', out]); + expect(fs.readFileSync(out, 'utf-8').split('\n')[0]).toContain('kind,timestamp'); + }); + }); + + it('exports E7 locations from the .e7.json extension', () => { + withTmp('route.e7.json', (out) => { + run(['load', input, '--export', out]); + const { locations } = JSON.parse(fs.readFileSync(out, 'utf-8')); + expect(locations).toHaveLength(4); + expect(locations[0]).toHaveProperty('latitudeE7'); + }); + }); + + it('honours --export-format over the extension', () => { + withTmp('route.json', (out) => { + run(['load', input, '--export', out, '--export-format', 'e7']); + expect(JSON.parse(fs.readFileSync(out, 'utf-8'))).toHaveProperty('locations'); + }); + }); +}); diff --git a/packages/core/tests/exporters/formats.test.ts b/packages/core/tests/exporters/formats.test.ts new file mode 100644 index 0000000..260c34d --- /dev/null +++ b/packages/core/tests/exporters/formats.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + exportToGeoJson, + exportToCsv, + exportToE7, + toE7Locations, + exportDataset, + exportFormatFromPath, + CSV_COLUMNS, +} from '../../src/exporters'; +import { parseTimelineSemantic } from '../../src/loaders/timeline-semantic'; +import type { MappitDataset } from '../../src/types'; + +const FIXTURES = path.resolve(__dirname, '../../../../fixtures'); + +function makeDataset(): MappitDataset { + return { + source: 'timeline-semantic', + dateRange: { min: '2024-01-15T10:00:00Z', max: '2024-01-15T13:00:00Z' }, + points: [ + { timestamp: '2024-01-15T09:00:00Z', lat: 45.1, lng: 9.1, accuracy: 12, activityType: 'WALKING' }, + ], + timeline: [ + { + type: 'visit', + startTime: '2024-01-15T10:00:00Z', + endTime: '2024-01-15T11:00:00Z', + lat: 45.4642, + lng: 9.19, + name: 'Duomo, "Milano"', + placeId: 'p1', + }, + { + type: 'activity', + startTime: '2024-01-15T11:00:00Z', + endTime: '2024-01-15T12:00:00Z', + activityType: 'WALKING', + distanceMeters: 1000, + startLocation: { lat: 45.4642, lng: 9.19 }, + endLocation: { lat: 45.47, lng: 9.17 }, + path: [ + { lat: 45.4642, lng: 9.19, timestamp: '2024-01-15T11:00:00Z' }, + { lat: 45.4671234, lng: 9.1800005 }, + { lat: 45.47, lng: 9.17, timestamp: '2024-01-15T12:00:00+01:00' }, + ], + }, + { + type: 'activity', + startTime: '2024-01-15T12:30:00Z', + endTime: '2024-01-15T13:00:00Z', + activityType: 'IN_VEHICLE', + startLocation: { lat: 45.47, lng: 9.17 }, + endLocation: { lat: 45.5, lng: 9.2 }, + path: [], + }, + ], + }; +} + +// --------------------------------------------------------------------------- +describe('exportToGeoJson', () => { + const fc = JSON.parse(exportToGeoJson(makeDataset())); + + it('returns a FeatureCollection with one feature per point/entry', () => { + expect(fc.type).toBe('FeatureCollection'); + expect(fc.features).toHaveLength(4); + expect(fc.features.map((f: { properties: { kind: string } }) => f.properties.kind)).toEqual([ + 'point', + 'visit', + 'activity', + 'activity', + ]); + }); + + it('uses [lng, lat] order', () => { + expect(fc.features[1].geometry).toEqual({ type: 'Point', coordinates: [9.19, 45.4642] }); + }); + + it('builds activity lines from the path, or from start/end without path', () => { + expect(fc.features[2].geometry.type).toBe('LineString'); + expect(fc.features[2].geometry.coordinates).toHaveLength(3); + expect(fc.features[2].properties.timestamps).toEqual([ + '2024-01-15T11:00:00Z', + null, + '2024-01-15T12:00:00+01:00', + ]); + expect(fc.features[3].geometry.coordinates).toEqual([ + [9.17, 45.47], + [9.2, 45.5], + ]); + expect(fc.features[3].properties.pathPoints).toBe(0); + }); + + it('can skip points or timeline', () => { + expect(JSON.parse(exportToGeoJson(makeDataset(), { includePoints: false })).features).toHaveLength(3); + expect(JSON.parse(exportToGeoJson(makeDataset(), { includeTimeline: false })).features).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- +describe('exportToCsv', () => { + const lines = exportToCsv(makeDataset()).trimEnd().split('\n'); + + it('writes the header and one row per point, visit and path point', () => { + expect(lines[0]).toBe(CSV_COLUMNS.join(',')); + expect(lines).toHaveLength(1 + 1 + 1 + 3); + expect(lines.slice(1).map((l) => l.split(',')[0])).toEqual(['point', 'visit', 'path', 'path', 'path']); + }); + + it('quotes values with delimiters or quotes', () => { + expect(lines[2]).toContain('"Duomo, ""Milano"""'); + }); + + it('falls back to the activity start for path points without timestamp', () => { + expect(lines[4].split(',')[1]).toBe('2024-01-15T11:00:00Z'); + }); + + it('supports a custom delimiter', () => { + expect(exportToCsv(makeDataset(), { delimiter: ';' }).split('\n')[0]).toBe(CSV_COLUMNS.join(';')); + }); +}); + +// --------------------------------------------------------------------------- +describe('exportToE7', () => { + it('produces { locations } with integer E7 coordinates', () => { + const out = JSON.parse(exportToE7(makeDataset())); + expect(Object.keys(out)).toEqual(['locations']); + for (const l of out.locations) { + expect(Object.keys(l)).toEqual(['latitudeE7', 'longitudeE7', 'timestamp']); + expect(Number.isInteger(l.latitudeE7)).toBe(true); + expect(Number.isInteger(l.longitudeE7)).toBe(true); + } + expect(out.locations).toContainEqual({ + latitudeE7: 454671234, + longitudeE7: 91800005, + timestamp: '2024-01-15T11:00:00Z', + }); + }); + + it('includes raw points and path points, but not visits by default', () => { + expect(toE7Locations(makeDataset()).locations).toHaveLength(1 + 3); + expect(toE7Locations(makeDataset(), { includeVisits: true }).locations).toHaveLength(1 + 1 + 3); + }); + + it('sorts by instant, across UTC offsets', () => { + const ts = toE7Locations(makeDataset()).locations.map((l) => l.timestamp); + // 12:00+01:00 is 11:00Z: same instant as the activity start, kept in input order + expect(ts).toEqual([ + '2024-01-15T09:00:00Z', + '2024-01-15T11:00:00Z', + '2024-01-15T11:00:00Z', + '2024-01-15T12:00:00+01:00', + ]); + }); + + it('converts back to the original degrees (blog maps.js divides by 1e7)', () => { + const ds = parseTimelineSemantic( + JSON.parse(fs.readFileSync(path.join(FIXTURES, 'timeline-semantic-paths.json'), 'utf-8')), + ); + const { locations } = toE7Locations(ds); + expect(locations).toHaveLength(4); + expect(locations[0].latitudeE7 / 1e7).toBeCloseTo(45.4642, 7); + expect(locations[0].longitudeE7 / 1e7).toBeCloseTo(9.19, 7); + }); +}); + +// --------------------------------------------------------------------------- +describe('exportFormatFromPath / exportDataset', () => { + it('infers the format from the file name', () => { + expect(exportFormatFromPath('a/b/out.kml')).toBe('kml'); + expect(exportFormatFromPath('OUT.GEOJSON')).toBe('geojson'); + expect(exportFormatFromPath('out.csv')).toBe('csv'); + expect(exportFormatFromPath('route.e7.json')).toBe('e7'); + expect(exportFormatFromPath('out.json')).toBe('json'); + expect(exportFormatFromPath('out')).toBe('json'); + }); + + it('dispatches to the right exporter', () => { + const ds = makeDataset(); + expect(exportDataset(ds, 'geojson')).toBe(exportToGeoJson(ds)); + expect(exportDataset(ds, 'csv')).toBe(exportToCsv(ds)); + expect(exportDataset(ds, 'e7')).toBe(exportToE7(ds)); + expect(exportDataset(ds, 'kml')).toContain(' Date: Wed, 14 Oct 2026 11:05:35 +0200 Subject: [PATCH 2/2] fix(core): nel CSV le activity senza path diventano righe start/end Dalla review di Copilot sulla PR #38: prima sparivano dal CSV. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CecUKcJiSVnehcZEiXLgtk --- packages/core/src/exporters/csv-exporter.ts | 15 ++++++++++++ packages/core/tests/exporters/formats.test.ts | 24 +++++++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/core/src/exporters/csv-exporter.ts b/packages/core/src/exporters/csv-exporter.ts index 5a25aca..91a97e5 100644 --- a/packages/core/src/exporters/csv-exporter.ts +++ b/packages/core/src/exporters/csv-exporter.ts @@ -6,6 +6,8 @@ * - `visit`: a visit (`timestamp` = start, `end_time` = end) * - `path`: a GPS point of an activity path (`timestamp` falls back to the * activity start when the point has none) + * - `activity`: for activities without a path, two rows with the start and + * end location (at `startTime` and `endTime`), so no activity is lost * * Coordinates are decimal degrees. Values are quoted per RFC 4180 when needed. */ @@ -70,6 +72,19 @@ export function exportToCsv(dataset: MappitDataset, options: CsvExportOptions = name: e.name, place_id: e.placeId, }); + } else if (e.path.length === 0) { + for (const [loc, t] of [ + [e.startLocation, e.startTime], + [e.endLocation, e.endTime], + ] as const) { + rows.push({ + kind: 'activity', + timestamp: t, + lat: loc.lat, + lng: loc.lng, + activity_type: e.activityType, + }); + } } else { for (const p of e.path) { rows.push({ diff --git a/packages/core/tests/exporters/formats.test.ts b/packages/core/tests/exporters/formats.test.ts index 260c34d..6c98652 100644 --- a/packages/core/tests/exporters/formats.test.ts +++ b/packages/core/tests/exporters/formats.test.ts @@ -105,8 +105,28 @@ describe('exportToCsv', () => { it('writes the header and one row per point, visit and path point', () => { expect(lines[0]).toBe(CSV_COLUMNS.join(',')); - expect(lines).toHaveLength(1 + 1 + 1 + 3); - expect(lines.slice(1).map((l) => l.split(',')[0])).toEqual(['point', 'visit', 'path', 'path', 'path']); + expect(lines).toHaveLength(1 + 1 + 1 + 3 + 2); + expect(lines.slice(1).map((l) => l.split(',')[0])).toEqual([ + 'point', + 'visit', + 'path', + 'path', + 'path', + 'activity', + 'activity', + ]); + }); + + it('keeps pathless activities as start/end rows', () => { + expect(lines[6].split(',').slice(0, 6)).toEqual([ + 'activity', + '2024-01-15T12:30:00Z', + '', + '45.47', + '9.17', + 'IN_VEHICLE', + ]); + expect(lines[7].split(',')[1]).toBe('2024-01-15T13:00:00Z'); }); it('quotes values with delimiters or quotes', () => {