Trip: export to ics or csv, sort order

This commit is contained in:
itskovacs 2025-10-25 13:47:06 +02:00
parent a30e7eaec4
commit 56b1d008f0
2 changed files with 28 additions and 85 deletions

View File

@ -1,93 +1,41 @@
import { FlattenedTripItem } from '../../types/trip'; import { FlattenedTripItem } from '../../types/trip';
import { UtilsService } from '../../services/utils.service';
export function generateTripICSFile( export function generateTripCSVFile(tripItems: readonly FlattenedTripItem[], tripName: string = 'Trip Calendar'): void {
tripItems: FlattenedTripItem[], const headers = ['date', 'label', 'time', 'text', 'place', 'comment', 'latlng', 'price', 'status'];
tripName: string = 'Trip Calendar',
utilsService: UtilsService,
): void {
const now = new Date();
const tsz = now.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
let icsContent = [ let csvContent = headers.join(',') + '\n';
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Trip//Trip Calendar//EN',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
`X-WR-CALNAME:${tripName}`,
'X-WR-TIMEZONE:Europe/Paris',
].join('\r\n');
if (tripItems.some((i) => !i.td_date)) tripItems.forEach((item) => {
utilsService.toast('warn', 'Caution', 'You have date-less days, they will not be included in your export'); const row = [
item.td_date ?? '',
tripItems.forEach((item, index) => { item.td_label,
if (!item.td_date) return; item.time ?? '',
escape_rfc4180(item.text),
const eventDate = item.td_date; escape_rfc4180(item.comment ?? ''),
const eventTime = item.time ?? '00:00'; escape_rfc4180(item.place?.name ?? ''),
const [year, month, day] = eventDate.split('-'); item.lat ?? item.place?.lat ?? '',
const [hours, minutes] = eventTime.split(':'); item.lng ?? item.place?.lng ?? '',
const dtStart = `${year}${month}${day}T${hours.padStart(2, '0')}${minutes.padStart(2, '0')}00`; item.price ?? '',
item.status?.label ?? '',
const startDateTime = new Date(`${eventDate}T${eventTime}`); ];
const nextItemSameDay = tripItems.slice(index + 1).find((i) => i.td_date === item.td_date && i.time); csvContent += row.join(',') + '\n';
const endDateTime = nextItemSameDay?.time
? new Date(`${nextItemSameDay.td_date}T${nextItemSameDay.time}`)
: new Date(startDateTime.getTime() + 60 * 60 * 1000);
const dtEnd = endDateTime.toISOString().replace(/[-:]/g, '').split('.')[0];
const eventDescription: string[] = [];
if (item.comment) eventDescription.push(`Comment: ${item.comment}`);
if (item.place?.name) eventDescription.push(`Place: ${item.place.name}`);
const lat = item.lat ?? item.place?.lat;
const lng = item.lng ?? item.place?.lng;
if (lat && lng) {
eventDescription.push(`Coordinates: ${lat}, ${lng}`);
eventDescription.push(`GMaps: https://www.google.com/maps?q=${lat},${lng}`);
}
if (item.price) eventDescription.push(`Price: ${item.price}`);
const description = eventDescription.join('\\n').replace(/\n/g, '\\n');
const location = item.place?.name ?? (lat && lng ? `${lat}, ${lng}` : '');
const geo = lat && lng ? `GEO:${lat};${lng}` : '';
const uid = `Trip-${tripName.replace(/[^a-zA-Z0-9-_]/g, '_')}-item-${item.id}-${tsz}`;
icsContent +=
'\r\n' +
[
'BEGIN:VEVENT',
`UID:${uid}`,
`DTSTAMP:${tsz}`,
`DTSTART:${dtStart}`,
`DTEND:${dtEnd}`,
`SUMMARY:${escapeICSText(item.text)}`,
description ? `DESCRIPTION:${escapeICSText(description)}` : '',
location ? `LOCATION:${escapeICSText(location)}` : '',
geo,
item.status ? `STATUS:${item.status.label.toUpperCase()}` : '',
'END:VEVENT',
]
.filter((line) => line)
.join('\r\n');
}); });
icsContent += '\r\n' + 'END:VCALENDAR'; const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const blob = new Blob([icsContent], { type: 'text/calendar;charset=utf-8' }); const url = URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement('a');
link.href = URL.createObjectURL(blob); link.href = url;
link.download = `${tripName.replace(/\s+/g, '_')}.ics`; link.download = `${tripName.replace(/\s+/g, '_')}.csv`;
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
URL.revokeObjectURL(link.href); URL.revokeObjectURL(url);
} }
function escapeICSText(text: string): string { function escape_rfc4180(field: string): string {
if (!text) return ''; if (!field) return '';
return text.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n').replace(/\r/g, ''); if (field.includes(',') || field.includes('"') || field.includes('\n')) {
return `"${field.replace(/"/g, '""')}"`;
}
return field;
} }

View File

@ -32,8 +32,6 @@ export function generateTripICSFile(
const dtStart = `${year}${month}${day}T${hours.padStart(2, '0')}${minutes.padStart(2, '0')}00`; const dtStart = `${year}${month}${day}T${hours.padStart(2, '0')}${minutes.padStart(2, '0')}00`;
const startDateTime = new Date(`${eventDate}T${eventTime}`); const startDateTime = new Date(`${eventDate}T${eventTime}`);
const nextItem = tripItems.slice(index + 1).find((i) => i.td_date);
const nextItemSameDay = tripItems.slice(index + 1).find((i) => i.td_date === item.td_date && i.time); const nextItemSameDay = tripItems.slice(index + 1).find((i) => i.td_date === item.td_date && i.time);
const endDateTime = nextItemSameDay?.time const endDateTime = nextItemSameDay?.time
@ -41,11 +39,8 @@ export function generateTripICSFile(
: new Date(startDateTime.getTime() + 60 * 60 * 1000); : new Date(startDateTime.getTime() + 60 * 60 * 1000);
const dtEnd = endDateTime.toISOString().replace(/[-:]/g, '').split('.')[0]; const dtEnd = endDateTime.toISOString().replace(/[-:]/g, '').split('.')[0];
// Build description
const eventDescription: string[] = []; const eventDescription: string[] = [];
if (item.comment) eventDescription.push(`Comment: ${item.comment}`); if (item.comment) eventDescription.push(`Comment: ${item.comment}`);
if (item.place?.name) eventDescription.push(`Place: ${item.place.name}`); if (item.place?.name) eventDescription.push(`Place: ${item.place.name}`);
const lat = item.lat ?? item.place?.lat; const lat = item.lat ?? item.place?.lat;