mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
ed18c37903
Every file the release touches was read once more, and what survived an independent check against the code is fixed here, with a test for each. Data and credentials - A document sync connection edited to a new address no longer carries the stored token along: a blank form under another origin is refused, the same rule the probe route already had. Dawarich and AirTrail testConnection send the stored key only to the host it was stored for. - The key rotation script now covers document_connections.secrets and trip_document_links.webhook_secret, which it had skipped. - Turning a booked night into a pause in the road trip popup asks first and says that the booking and its expense go with it. Deleting a place says the same when a night is booked there, on the desktop and on the phone. - A moved version marker over unchanged bytes no longer replaces the TREK file and its attachments; a real revision carries the attachments over. Planner - A drop in the day plan names the row it lands ahead of in the full day, so a hidden hotel stop no longer shifts it one slot early; the store sorts before it splices. Via re-anchoring, the booked-night guard and the sidebar's day membership read the stored day, the index space the server uses. - A hotel stop placed mid-day re-pins the road trip vias of that day, and the booking session receives the new stop order too. The Public API and the iCal feed list a booked hotel once. A plugin can no longer write a stay against another trip's day. Road trip and map - The route overview paces its requests to the public routers and retries a rate limit, and it says which legs it could not route instead of quietly reporting a shorter trip. A click on a via handle no longer drops a second via; handles hide when zooming out. Read-only members cannot edit the driving limits. The phone shows a way back when no day is picked and prints the night drive departure in the chosen clock format. Integrations and search - Google transit keeps conventional trains when the train chip is on. TREK_PLACES_ENABLED=false stops the details and enrichment calls too, and accepts the whole boolean family. A caller-supplied description no longer lands in the instance-wide enrichment cache. Nominatim searches carry a timeout. An OSM place id is checked for shape before it reaches Overpass. - Dawarich puts a stay on the trip whose dates hold it, the Atlas hand-off knows what the Atlas already shows, and a host change with a new key clears the old instance's state. - Document sync: a webhook address pasted by hand works on the token alone, deleting a connection unregisters its subscriptions, Nextcloud under a sub-path connects, Synology answers a name collision before uploading and verifies a move into the bin, Paperless titles are capped at 128 characters, a DNS failure reads as unreachable rather than as a policy verdict, videos from the store are accepted like uploads, and remote_missing has a way out. Conflict buttons only for the owner, a refused choice is reported, a refused credential gets a Reconnect button, disconnecting asks first on the desktop as it does on the phone, and the folder picker no longer spins forever. Release - The server holds the release notice back from a bundle that cannot draw it, so the previous shell served from the service worker cache after an update no longer shows bare keys and no longer lets a click use the notice up. - Import into an existing list gets the same body limit as a new list. Chat images say when the limit is reached and the new controls are translated. The transit backend switch reports a failed save. The Helm chart carries the Amap credentials in its Secret; the shipped templates document the Places API and Amap variables and the Overpass default. The wiki follows.
760 lines
38 KiB
TypeScript
760 lines
38 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
||
import { DatabaseService } from '../database/database.service';
|
||
import { ReservationsService } from '../reservations/reservations.service';
|
||
import { publicReservationSql, publicStaySql } from '../reservations/reservation-visibility';
|
||
import { addDays } from '../days/days.service';
|
||
import { resolveTimeZone } from '../common/timezoneService';
|
||
import { NotFoundError } from '../common/domain-errors';
|
||
|
||
/** The VCALENDAR preamble every TREK calendar starts with, single-trip or merged. */
|
||
export const CALENDAR_HEADER =
|
||
'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//TREK//Travel Planner//EN\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\n';
|
||
|
||
/** One trip's calendar in parts, so callers can merge several without re-parsing text. */
|
||
export interface TripCalendar {
|
||
/** Already escaped for the X-WR-CALNAME line. */
|
||
calName: string;
|
||
/** Filename for the Content-Disposition of the one-time download. */
|
||
filename: string;
|
||
/** VTIMEZONE block per TZID. Merging calendars means merging these by key. */
|
||
timezones: Map<string, string>;
|
||
/** One complete BEGIN:VEVENT…END:VEVENT block each, unfolded, in emission order. */
|
||
events: string[];
|
||
}
|
||
|
||
// ── ICS folding ─────────────────────────────────────────────────────────────
|
||
|
||
// RFC 5545 §3.1: content lines longer than 75 octets must be folded with a CRLF
|
||
// followed by a single leading space. We fold on UTF-8 *octet* boundaries and
|
||
// never split a multi-byte codepoint, so non-ASCII titles/notes (accents, CJK,
|
||
// emoji) stay intact. Applied to the whole calendar, so both the one-time
|
||
// download and the subscribable feed emit spec-compliant output.
|
||
export function foldICS(ics: string): string {
|
||
const foldLine = (line: string): string => {
|
||
const bytes = Buffer.from(line, 'utf8');
|
||
if (bytes.length <= 75) return line;
|
||
const parts: Buffer[] = [];
|
||
let start = 0;
|
||
let limit = 75; // first physical line may use 75 octets
|
||
while (start < bytes.length) {
|
||
let end = Math.min(start + limit, bytes.length);
|
||
// Back off so we never cut a multi-byte UTF-8 sequence (0x80–0xBF = continuation byte).
|
||
while (end < bytes.length && (bytes[end] & 0xc0) === 0x80) end--;
|
||
parts.push(bytes.subarray(start, end));
|
||
start = end;
|
||
limit = 74; // continuation lines spend one octet on the leading space
|
||
}
|
||
return parts.map((b, i) => (i === 0 ? '' : ' ') + b.toString('utf8')).join('\r\n');
|
||
};
|
||
return ics.split('\r\n').map(foldLine).join('\r\n');
|
||
}
|
||
|
||
// ── ICS time-zone helpers ────────────────────────────────────────────────────
|
||
// Timed events must carry an explicit IANA zone; a bare "YYYYMMDDTHHMMSS" is an
|
||
// RFC 5545 "floating" time that clients render in the *subscriber's* zone (#1453).
|
||
|
||
// A stored/plugin-provided timezone (e.g. a transport endpoint's `timezone`) is a
|
||
// free string that need not be a real IANA zone. Intl.DateTimeFormat throws a
|
||
// RangeError on an unknown zone, which — via buildVTimezone → tzOffsetString —
|
||
// would crash the whole ICS export (and drop the trip from the all-trips feed).
|
||
// Validate once so an invalid zone degrades to a floating local time instead.
|
||
// Module-scoped on purpose (like the permissions/FX caches): the bridge instance
|
||
// and the DI singleton share one cache.
|
||
const _tzValidCache = new Map<string, boolean>();
|
||
function isValidTimeZone(zone: string): boolean {
|
||
const cached = _tzValidCache.get(zone);
|
||
if (cached !== undefined) return cached;
|
||
let ok = false;
|
||
try {
|
||
new Intl.DateTimeFormat('en-US', { timeZone: zone });
|
||
ok = true;
|
||
} catch {
|
||
// Unknown/invalid zone → ok stays false.
|
||
}
|
||
// Bound the cache — the key is a free-form (plugin/importer-written) zone string,
|
||
// so cap distinct entries rather than growing for the process lifetime.
|
||
if (_tzValidCache.size >= 1000) _tzValidCache.clear();
|
||
_tzValidCache.set(zone, ok);
|
||
return ok;
|
||
}
|
||
|
||
// UTC offset ("+0200") the zone uses on the given YYYYMMDD date. Only feeds the
|
||
// fallback VTIMEZONE offset; iOS/Google resolve the named zone from their own
|
||
// IANA database, so a single representative offset is sufficient.
|
||
function tzOffsetString(zone: string, yyyymmdd: string): string {
|
||
const iso = `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}T12:00:00Z`;
|
||
const probe = new Date(iso);
|
||
if (Number.isNaN(probe.getTime())) return '+0000';
|
||
const parts = new Intl.DateTimeFormat('en-US', {
|
||
timeZone: zone,
|
||
timeZoneName: 'longOffset',
|
||
}).formatToParts(probe);
|
||
const raw = parts.find((p) => p.type === 'timeZoneName')?.value ?? 'GMT';
|
||
const m = raw.match(/GMT([+-])(\d{2}):?(\d{2})?/);
|
||
if (!m) return '+0000'; // "GMT" (UTC) has no offset digits
|
||
return `${m[1]}${m[2]}${m[3] ?? '00'}`;
|
||
}
|
||
|
||
// Minimal but RFC-valid VTIMEZONE. Smart clients override it with their own tz
|
||
// rules; dumb clients fall back to this fixed offset.
|
||
function buildVTimezone(zone: string, yyyymmdd: string): string {
|
||
const off = tzOffsetString(zone, yyyymmdd);
|
||
return (
|
||
'BEGIN:VTIMEZONE\r\n' +
|
||
`TZID:${zone}\r\n` +
|
||
'BEGIN:STANDARD\r\n' +
|
||
'DTSTART:19700101T000000\r\n' +
|
||
`TZOFFSETFROM:${off}\r\n` +
|
||
`TZOFFSETTO:${off}\r\n` +
|
||
`TZNAME:${zone}\r\n` +
|
||
'END:STANDARD\r\n' +
|
||
'END:VTIMEZONE\r\n'
|
||
);
|
||
}
|
||
/**
|
||
* Everything TREK knows how to say in iCalendar. Moved out of TripsService
|
||
* unchanged: same statements, same escaping, same folding, same VTIMEZONE
|
||
* fallback, so the emitted bytes are identical for both consumers (the one-time
|
||
* download on the trip route and the subscribable feeds).
|
||
*
|
||
* It is its own domain rather than a method on trips because FeedsService needs
|
||
* calendars without needing the trip aggregate, and because the ICS rules
|
||
* (RFC 5545 folding, floating vs. zoned times) have nothing to do with trips.
|
||
*/
|
||
@Injectable()
|
||
export class CalendarService {
|
||
constructor(
|
||
private readonly dbs: DatabaseService,
|
||
private readonly reservations: ReservationsService,
|
||
) {}
|
||
|
||
private get db() {
|
||
return this.dbs.connection;
|
||
}
|
||
|
||
// ── ICS export ────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* A trip's calendar as parts rather than as one string.
|
||
*
|
||
* The all-trips feed needs to merge many trips into one document: it has to
|
||
* drop each trip's VCALENDAR wrapper, keep the VEVENTs, and emit each
|
||
* VTIMEZONE once. It used to do that by scanning the finished text back apart
|
||
* line by line — and that scan had to be structural rather than a regex,
|
||
* because a user-supplied SUMMARY can legitimately contain the literal
|
||
* "END:VEVENT". Handing out the parts removes the need to reassemble them.
|
||
*/
|
||
buildTripCalendar(tripId: string | number): TripCalendar {
|
||
const trip = this.db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as any;
|
||
if (!trip) throw new NotFoundError('Trip not found');
|
||
|
||
// A hotel keeps its dates on the linked stay, not on the reservation: the
|
||
// booking form writes reservation_time = NULL for type 'hotel' and lets
|
||
// day_accommodations carry start day, end day and the check-in/out clock.
|
||
// Joining them here is what lets a stay span its whole range (#1586).
|
||
const reservations = this.db
|
||
.prepare(
|
||
`SELECT r.*, pl.lat AS place_lat, pl.lng AS place_lng,
|
||
sd.date AS stay_start_date, ed.date AS stay_end_date,
|
||
a.check_in AS stay_check_in, a.check_out AS stay_check_out,
|
||
(SELECT MIN(r2.id) FROM reservations r2
|
||
WHERE r2.accommodation_id = a.id) AS stay_first_reservation_id,
|
||
rd.date AS day_date, red.date AS end_day_date
|
||
FROM reservations r
|
||
LEFT JOIN places pl ON r.place_id = pl.id
|
||
LEFT JOIN day_accommodations a ON r.accommodation_id = a.id
|
||
LEFT JOIN days sd ON a.start_day_id = sd.id
|
||
LEFT JOIN days ed ON a.end_day_id = ed.id
|
||
LEFT JOIN days rd ON r.day_id = rd.id
|
||
LEFT JOIN days red ON r.end_day_id = red.id
|
||
WHERE r.trip_id = ? AND ${publicReservationSql('r')}`,
|
||
)
|
||
.all(tripId) as any[];
|
||
|
||
const esc = (s: string) => s
|
||
.replaceAll(/\\/g, '\\\\')
|
||
.replaceAll(';', '\\;')
|
||
.replaceAll(',', '\\,')
|
||
.replace(/\r?\n/g, '\\n')
|
||
.replaceAll(/\r/g, '');
|
||
const fmtDate = (d: string) => d.replaceAll('-', '');
|
||
const now = new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
||
const uid = (id: number, type: string) => `trek-${type}-${id}@trek`;
|
||
|
||
// Format datetime: handles full ISO "2026-03-30T09:00" and time-only "10:00"
|
||
// iCal requires exactly YYYYMMDDTHHMMSS format
|
||
const fmtDateTime = (d: string, refDate?: string) => {
|
||
if (d.includes('T')) {
|
||
const raw = d.replace(/[-:]/g, '').split('.')[0];
|
||
// Pad to 15 chars (YYYYMMDDTHHMMSS) — add missing seconds
|
||
return raw.length === 13 ? raw + '00' : raw;
|
||
}
|
||
// Time-only: combine with reference date. Pad the same way the branch above
|
||
// does rather than unconditionally, because the value can already carry
|
||
// seconds ("10:00:00" is what the booking import stores). Appending
|
||
// regardless made it 17 characters, which fails dtLine's shape check, drops
|
||
// the TZID and leaves a floating time in the feed (#1453 again).
|
||
if (refDate && d.match(/^\d{2}:\d{2}/)) {
|
||
const datePart = refDate.split('T')[0];
|
||
const raw = `${datePart}T${d.replaceAll(':', '')}`.replaceAll('-', '');
|
||
return raw.length === 13 ? raw + '00' : raw;
|
||
}
|
||
return d.replace(/[-:]/g, '');
|
||
};
|
||
|
||
// Zones referenced by timed events → representative YYYYMMDD (for the fallback
|
||
// VTIMEZONE offset). Populated by dtLine; emitted once as VTIMEZONE blocks.
|
||
const usedZones = new Map<string, string>();
|
||
|
||
// Emit a DTSTART/DTEND line, attaching TZID when the event's zone is known so
|
||
// subscribers see the time in TREK's zone. Falls back to a floating local time
|
||
// (unchanged behavior) when no zone resolves or the value is not a date-time.
|
||
const dtLine = (
|
||
prop: 'DTSTART' | 'DTEND',
|
||
wallClock: string,
|
||
zone: string | null,
|
||
refDate?: string,
|
||
): string => {
|
||
const val = fmtDateTime(wallClock, refDate);
|
||
if (zone && isValidTimeZone(zone) && /^\d{8}T\d{6}$/.test(val)) {
|
||
if (!usedZones.has(zone)) usedZones.set(zone, val.slice(0, 8));
|
||
return `${prop};TZID=${zone}:${val}\r\n`;
|
||
}
|
||
return `${prop}:${val}\r\n`;
|
||
};
|
||
|
||
const events: string[] = [];
|
||
|
||
// Trip as all-day event. DTEND is exclusive, so it must be the day *after* the last
|
||
// day. addDays() stays in UTC — building a local-time Date here dropped the trip's
|
||
// last day on any server east of Greenwich (#1453).
|
||
if (trip.start_date && trip.end_date) {
|
||
const endStr = fmtDate(addDays(trip.end_date, 1));
|
||
let ev = `BEGIN:VEVENT\r\nUID:${uid(trip.id, 'trip')}\r\nDTSTAMP:${now}\r\nDTSTART;VALUE=DATE:${fmtDate(trip.start_date)}\r\nDTEND;VALUE=DATE:${endStr}\r\nSUMMARY:${esc(trip.title || 'Trip')}\r\n`;
|
||
if (trip.description) ev += `DESCRIPTION:${esc(trip.description)}\r\n`;
|
||
ev += `END:VEVENT\r\n`;
|
||
events.push(ev);
|
||
}
|
||
|
||
// Days with assignments and notes
|
||
const days = this.db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId) as any[];
|
||
for (const day of days) {
|
||
if (!day.date) continue;
|
||
|
||
// A booked night puts a stop of its own on its check-in day, so the route
|
||
// can reach the hotel. That stop is the booking, not a place the traveller
|
||
// planned to visit, and the booking already comes through below as the
|
||
// stay block or its check-in and check-out markers. Read here as well it
|
||
// would put the hotel on the day a second time.
|
||
const assignments = this.db.prepare(`
|
||
SELECT da.*, p.name as place_name, p.address as place_address,
|
||
p.lat as place_lat, p.lng as place_lng,
|
||
COALESCE(da.assignment_time, p.place_time) as effective_time,
|
||
COALESCE(da.assignment_end_time, p.end_time) as effective_end_time
|
||
FROM day_assignments da
|
||
JOIN places p ON da.place_id = p.id
|
||
WHERE da.day_id = ?
|
||
AND da.accommodation_id IS NULL
|
||
ORDER BY da.order_index ASC, da.created_at ASC
|
||
`).all(day.id) as any[];
|
||
|
||
const notes = this.db.prepare(
|
||
'SELECT * FROM day_notes WHERE day_id = ? ORDER BY sort_order ASC, created_at ASC'
|
||
).all(day.id) as any[];
|
||
|
||
const timed = assignments.filter(a => a.effective_time);
|
||
const untimed = assignments.filter(a => !a.effective_time);
|
||
|
||
// Timed assignments → individual events
|
||
for (const a of timed) {
|
||
const zone = resolveTimeZone(a.place_lat, a.place_lng);
|
||
let ev = `BEGIN:VEVENT\r\nUID:${uid(a.id, 'assign')}\r\nDTSTAMP:${now}\r\n`;
|
||
ev += dtLine('DTSTART', a.effective_time, zone, day.date + 'T00:00');
|
||
if (a.effective_end_time) {
|
||
ev += dtLine('DTEND', a.effective_end_time, zone, day.date + 'T00:00');
|
||
}
|
||
ev += `SUMMARY:${esc(a.place_name)}\r\n`;
|
||
let desc = '';
|
||
if (a.notes) desc += a.notes;
|
||
if (a.place_address) desc += (desc ? '\n' : '') + a.place_address;
|
||
if (desc) ev += `DESCRIPTION:${esc(desc)}\r\n`;
|
||
if (a.place_address) ev += `LOCATION:${esc(a.place_address)}\r\n`;
|
||
ev += `END:VEVENT\r\n`;
|
||
events.push(ev);
|
||
}
|
||
|
||
// Build all-day summary event if there are untimed activities or notes
|
||
if (untimed.length > 0 || notes.length > 0) {
|
||
const dayTitle = day.title || `Day ${day.day_number}`;
|
||
const endStr = fmtDate(addDays(day.date, 1));
|
||
|
||
let ev = `BEGIN:VEVENT\r\nUID:${uid(day.id, 'day')}\r\nDTSTAMP:${now}\r\n`;
|
||
ev += `DTSTART;VALUE=DATE:${fmtDate(day.date)}\r\nDTEND;VALUE=DATE:${endStr}\r\n`;
|
||
ev += `SUMMARY:${esc(dayTitle)}\r\n`;
|
||
|
||
let desc = '';
|
||
if (untimed.length > 0) {
|
||
desc += untimed.map(a => {
|
||
let line = `• ${a.place_name}`;
|
||
if (a.place_address) line += ` (${a.place_address})`;
|
||
if (a.notes) line += ` — ${a.notes}`;
|
||
return line;
|
||
}).join('\n');
|
||
}
|
||
if (notes.length > 0) {
|
||
if (desc) desc += '\n\n';
|
||
desc += 'Notes:\n' + notes.map(n => {
|
||
const line = n.time ? `${n.time} — ${n.text}` : `• ${n.text}`;
|
||
return line;
|
||
}).join('\n');
|
||
}
|
||
if (desc) ev += `DESCRIPTION:${esc(desc)}\r\n`;
|
||
ev += `END:VEVENT\r\n`;
|
||
events.push(ev);
|
||
}
|
||
}
|
||
|
||
// Transport/flight reservations carry no top-level reservation_time; their
|
||
// times live per endpoint (local_date + local_time) in reservation_endpoints.
|
||
const endpointsMap = this.reservations.loadEndpointsByTrip(tripId);
|
||
const isDate = (s: string | null | undefined) => !!s && /^\d{4}-\d{2}-\d{2}$/.test(s);
|
||
const isTime = (s: string | null | undefined) => !!s && /^\d{2}:\d{2}/.test(s);
|
||
|
||
// "YYYY-MM-DD", full ISO, or a bare "HH:MM" → date/time parts. Endpoints carry
|
||
// local_date/local_time separately; reservation_time and reservation_end_time
|
||
// are combined strings, and the end is frequently just a bare clock alongside
|
||
// the reservation's own date.
|
||
const dateOf = (s: string | null | undefined): string | null => {
|
||
if (!s) return null;
|
||
const d = s.includes('T') ? s.split('T')[0] : s;
|
||
return isDate(d) ? d : null;
|
||
};
|
||
const timeOf = (s: string | null | undefined): string | null => {
|
||
if (!s) return null;
|
||
const t = s.includes('T') ? s.split('T')[1] : s;
|
||
return t && isTime(t) ? t : null;
|
||
};
|
||
|
||
// Per stay, the booking whose all-day block its two markers replace. Decided
|
||
// once here rather than twice, because the markers have to hand on exactly
|
||
// what the block they replaced would have said (#2136).
|
||
const stayCarriedByMarkers = new Map<number, any>();
|
||
for (const r of reservations) {
|
||
if (isTime(r.stay_check_in) && isTime(r.stay_check_out) && r.stay_first_reservation_id === r.id) {
|
||
stayCarriedByMarkers.set(Number(r.accommodation_id), r);
|
||
}
|
||
}
|
||
|
||
// Build the DTSTART/DTEND lines for a reservation, or null when it has no
|
||
// calendar-placeable time. Transports keep a per-side wall clock + IANA zone
|
||
// on their endpoints, so consult a timed departure endpoint FIRST: transports
|
||
// also carry a top-level reservation_time (TransportModal stamps it), whose
|
||
// only zone is the linked place — flights have none — so that branch floats
|
||
// both ends in the subscribed feed (#1453). Hotels/restaurants have no
|
||
// endpoints and fall through to reservation_time.
|
||
const buildReservationTimeLines = (r: any): string | null => {
|
||
// A stay wins over everything below: it is the only source that knows how
|
||
// long the booking lasts. Emitted as an all-day range over every night, so
|
||
// the hotel sits above the day in a subscribed calendar instead of
|
||
// appearing once on the arrival day (#1586).
|
||
// end_day_id is the CHECK-OUT day, and the block is meant to cover it too:
|
||
// a 26th-to-30th booking should read as a five-day block, not as the four
|
||
// nights it contains. DTEND is exclusive, so it is check-out plus one day.
|
||
// Dropping that +1 hides the departure day; it is not a stray off-by-one
|
||
// (#1869).
|
||
// Unless the stay records BOTH ends of its clock, in which case the two
|
||
// timed markers below already say everything the block would, and saying
|
||
// it twice buries a week of calendar under a bar nobody can act on
|
||
// (#2136). Knowing only one end still needs the block: it is the only
|
||
// thing carrying the other end's date.
|
||
//
|
||
// Only for the booking the markers actually stand in for, though. They are
|
||
// emitted once per stay and titled from its lowest-id reservation, so a
|
||
// second room on the same stay is represented by nothing else and keeps
|
||
// its block.
|
||
const markersCover = stayCarriedByMarkers.get(Number(r.accommodation_id)) === r;
|
||
if (isDate(r.stay_start_date) && !markersCover) {
|
||
const lastDay = isDate(r.stay_end_date) && r.stay_end_date >= r.stay_start_date
|
||
? r.stay_end_date
|
||
: r.stay_start_date;
|
||
return `DTSTART;VALUE=DATE:${fmtDate(r.stay_start_date)}\r\n` +
|
||
`DTEND;VALUE=DATE:${fmtDate(addDays(lastDay, 1))}\r\n`;
|
||
}
|
||
// A fully timed stay is carried by its markers alone, so the booking row
|
||
// itself has nothing left to place.
|
||
if (isDate(r.stay_start_date)) return null;
|
||
|
||
const eps = endpointsMap.get(r.id);
|
||
const ordered = eps && eps.length > 0 ? [...eps].sort((a, b) => a.sequence - b.sequence) : null;
|
||
const first = ordered?.[0];
|
||
|
||
if (first && isDate(first.local_date) && isTime(first.local_time)) {
|
||
// Transport: departure endpoint zone drives DTSTART, arrival drives DTEND.
|
||
// Prefer the stored IANA zone; fall back to the endpoint's coordinates.
|
||
const last = ordered![ordered!.length - 1];
|
||
const startZone = first.timezone || resolveTimeZone(first.lat, first.lng);
|
||
const startWallClock = `${first.local_date}T${first.local_time}`;
|
||
let out = dtLine('DTSTART', startWallClock, startZone);
|
||
if (last !== first && isDate(last.local_date) && isTime(last.local_time)) {
|
||
const endZone = last.timezone || resolveTimeZone(last.lat, last.lng);
|
||
out += dtLine('DTEND', `${last.local_date}T${last.local_time}`, endZone);
|
||
} else if (r.reservation_end_time) {
|
||
// No second timed endpoint, so the arrival side still lives in
|
||
// reservation_end_time — a rental car imported with only a geocoded
|
||
// pickup is the common shape. Taking the endpoint branch without this
|
||
// would drop the DTEND the reservation_time branch used to emit and
|
||
// shrink a multi-day booking to a point. The departure zone is the
|
||
// best one on hand for that end too.
|
||
const endDt = fmtDateTime(r.reservation_end_time, startWallClock);
|
||
if (endDt.length >= 15) out += dtLine('DTEND', r.reservation_end_time, startZone, startWallClock);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
if (r.reservation_time) {
|
||
const datePart = r.reservation_time.includes('T') ? r.reservation_time.split('T')[0] : r.reservation_time;
|
||
if (!isDate(datePart)) return null; // time-only (relative "Day N" trips)
|
||
if (r.reservation_time.includes('T')) {
|
||
// Hotels/restaurants: derive the zone from the linked place, if any.
|
||
const zone = resolveTimeZone(r.place_lat, r.place_lng);
|
||
let out = dtLine('DTSTART', r.reservation_time, zone);
|
||
if (r.reservation_end_time) {
|
||
const endDt = fmtDateTime(r.reservation_end_time, r.reservation_time);
|
||
if (endDt.length >= 15) out += dtLine('DTEND', r.reservation_end_time, zone, r.reservation_time);
|
||
}
|
||
return out;
|
||
}
|
||
// Date-only start. RFC 5545 makes a DATE event without DTEND exactly one
|
||
// day long, so a booking that does record an end date still collapsed
|
||
// onto its first day (#1869); an all-day multi-day event coming through
|
||
// the booking import (kitinerary-mapper passes date-only start/end
|
||
// straight through) is the common shape. Same rule as the stay branch:
|
||
// DTEND is exclusive, hence the end day plus one. Only the date part is
|
||
// used, because DTSTART;VALUE=DATE and a timed DTEND may not be mixed.
|
||
const endDatePart = r.reservation_end_time ? String(r.reservation_end_time).split('T')[0] : '';
|
||
if (isDate(endDatePart) && endDatePart >= r.reservation_time) {
|
||
return `DTSTART;VALUE=DATE:${fmtDate(r.reservation_time)}\r\n` +
|
||
`DTEND;VALUE=DATE:${fmtDate(addDays(endDatePart, 1))}\r\n`;
|
||
}
|
||
return `DTSTART;VALUE=DATE:${fmtDate(r.reservation_time)}\r\n`;
|
||
}
|
||
|
||
// Untimed transport (endpoint has a date but no clock, no reservation_time).
|
||
if (first && isDate(first.local_date)) {
|
||
return `DTSTART;VALUE=DATE:${fmtDate(first.local_date)}\r\n`;
|
||
}
|
||
|
||
// Last resort: the days the booking is pinned to. TransportModal writes
|
||
// reservation_time = NULL when its optional time pickers are left blank
|
||
// while still setting day_id/end_day_id, so for a hand-typed rental car —
|
||
// or bus, train, ferry, anything from that form — the day rows are the ONLY
|
||
// record of when it happens, and reading nothing else kept those bookings
|
||
// out of the subscribed calendar entirely (#2068).
|
||
if (isDate(r.day_date)) {
|
||
const lastDay = isDate(r.end_day_date) && r.end_day_date >= r.day_date ? r.end_day_date : r.day_date;
|
||
return `DTSTART;VALUE=DATE:${fmtDate(r.day_date)}\r\n` +
|
||
`DTEND;VALUE=DATE:${fmtDate(addDays(lastDay, 1))}\r\n`;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
// A booking that is a WINDOW rather than an activity: something is handed over
|
||
// and handed back, and the days in between carry nothing. Read as one block
|
||
// from drop-off to pick-up, a week of airport parking sat across the whole
|
||
// week of a subscribed calendar (#2068). The day plan already draws this
|
||
// distinction for parking (client/src/utils/dayMerge.ts, #1937); the exporter
|
||
// never learned it. A rental is the mirror image — you pick it up first.
|
||
const WINDOW_WORDING: Record<string, { start: string; end: string }> = {
|
||
car: { start: 'Pickup', end: 'Drop-off' },
|
||
parking: { start: 'Drop-off', end: 'Pickup' },
|
||
};
|
||
/**
|
||
* How long a hand-over — and a check-in/check-out marker without an
|
||
* until-clock — reads as. Zero-length events collapse in most clients.
|
||
*/
|
||
const HANDOVER_MINUTES = 60;
|
||
|
||
const plusMinutes = (date: string, time: string, minutes: number): { date: string; time: string } => {
|
||
const [h, m] = time.slice(0, 5).split(':').map(Number);
|
||
const total = h * 60 + m + minutes;
|
||
const dayShift = Math.floor(total / (24 * 60));
|
||
const rest = ((total % (24 * 60)) + 24 * 60) % (24 * 60);
|
||
return {
|
||
date: dayShift ? addDays(date, dayShift) : date,
|
||
time: `${String(Math.floor(rest / 60)).padStart(2, '0')}:${String(rest % 60).padStart(2, '0')}`,
|
||
};
|
||
};
|
||
|
||
interface WindowSide { date: string; time: string | null; zone: string | null }
|
||
|
||
// Which endpoint is which side. The ROLE decides, because an import can drop
|
||
// one (failed geocoding) and a surviving return endpoint must not masquerade
|
||
// as the pickup. Positional first/last is only a fallback when no role is
|
||
// present at all, and only with more than one endpoint, so a lone endpoint is
|
||
// never both sides (#1721).
|
||
const windowSidesOf = (r: any): { start: WindowSide | null; end: WindowSide | null } => {
|
||
const eps = endpointsMap.get(r.id);
|
||
const ordered = eps && eps.length > 0 ? [...eps].sort((a, b) => a.sequence - b.sequence) : [];
|
||
const roleFrom = ordered.find(e => e.role === 'from');
|
||
const roleTo = ordered.find(e => e.role === 'to');
|
||
const noRoles = !roleFrom && !roleTo;
|
||
const startEp = roleFrom ?? (noRoles && ordered.length > 1 ? ordered[0] : undefined);
|
||
const endEp = roleTo ?? (noRoles && ordered.length > 1 ? ordered[ordered.length - 1] : undefined);
|
||
|
||
const fromEp = (ep: typeof startEp): WindowSide | null =>
|
||
ep && isDate(ep.local_date) && isTime(ep.local_time)
|
||
? { date: ep.local_date!, time: ep.local_time!, zone: ep.timezone || resolveTimeZone(ep.lat, ep.lng) }
|
||
: null;
|
||
|
||
const placeZone = resolveTimeZone(r.place_lat, r.place_lng);
|
||
const start = fromEp(startEp) ?? (() => {
|
||
const date = dateOf(r.reservation_time) ?? (isDate(r.day_date) ? r.day_date : null);
|
||
return date ? { date, time: timeOf(r.reservation_time), zone: placeZone } : null;
|
||
})();
|
||
const end = fromEp(endEp) ?? (() => {
|
||
const date = dateOf(r.reservation_end_time)
|
||
?? (isDate(r.end_day_date) ? r.end_day_date : null)
|
||
?? (timeOf(r.reservation_end_time) ? start?.date ?? null : null);
|
||
// The return side rarely carries a zone of its own. Inheriting the
|
||
// pickup's is what the single block did, and letting it float instead
|
||
// renders it in the subscriber's zone rather than the trip's (#1453).
|
||
return date ? { date, time: timeOf(r.reservation_end_time), zone: placeZone ?? start?.zone ?? null } : null;
|
||
})();
|
||
return { start, end };
|
||
};
|
||
|
||
// Split only when BOTH hand-overs resolve on DIFFERENT days. A same-day
|
||
// booking is one sitting and keeps its single event; a one-sided one — only
|
||
// the pickup was geocoded — keeps the block, which is then the only carrier
|
||
// of the return time at all.
|
||
const windowSplit = new Map<number, { start: WindowSide; end: WindowSide }>();
|
||
for (const r of reservations) {
|
||
if (!WINDOW_WORDING[r.type as string]) continue;
|
||
const { start, end } = windowSidesOf(r);
|
||
if (start && end && start.date !== end.date) windowSplit.set(r.id, { start, end });
|
||
}
|
||
|
||
// Reservations as events
|
||
for (const r of reservations) {
|
||
// The two hand-over events below stand in for this booking entirely.
|
||
if (windowSplit.has(r.id)) continue;
|
||
const timeLines = buildReservationTimeLines(r);
|
||
if (!timeLines) continue;
|
||
|
||
let ev = `BEGIN:VEVENT\r\nUID:${uid(r.id, 'res')}\r\nDTSTAMP:${now}\r\n`;
|
||
ev += timeLines;
|
||
ev += `SUMMARY:${esc(r.title)}\r\n`;
|
||
|
||
const desc = describeReservation(r);
|
||
if (desc) ev += `DESCRIPTION:${esc(desc)}\r\n`;
|
||
if (r.location) ev += `LOCATION:${esc(r.location)}\r\n`;
|
||
ev += `END:VEVENT\r\n`;
|
||
events.push(ev);
|
||
}
|
||
|
||
// Everything a booking says about itself, minus its times. Hoisted out of the
|
||
// event loop because a window booking's two hand-over events carry the same
|
||
// lines: dropping the block must not drop the route or the confirmation with
|
||
// it (#2068).
|
||
function describeReservation(r: any): string {
|
||
const meta = r.metadata ? (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata) : {};
|
||
let desc = r.type ? `Type: ${r.type}` : '';
|
||
if (r.confirmation_number) desc += `\nConfirmation: ${r.confirmation_number}`;
|
||
if (meta.airline) desc += `\nAirline: ${meta.airline}`;
|
||
if (meta.flight_number) desc += `\nFlight: ${meta.flight_number}`;
|
||
if (Array.isArray(meta.legs) && meta.legs.length > 1) {
|
||
// Multi-leg flight: show the whole route (FRA → BER → HND) on one event.
|
||
const stops = [meta.legs[0]?.from, ...meta.legs.map((l: { to?: string }) => l.to)].filter(Boolean);
|
||
if (stops.length) desc += `\nRoute: ${stops.join(' → ')}`;
|
||
// A segment booked under its own reference gets its own line (#1943): at
|
||
// the gate it is that code the airline asks for, not the booking's. No
|
||
// leg reference, no extra line, so an existing feed stays byte-identical.
|
||
for (const leg of meta.legs as { from?: string; to?: string; confirmation_number?: string }[]) {
|
||
if (!leg.confirmation_number) continue;
|
||
const segment = [leg.from, leg.to].filter(Boolean).join('-');
|
||
desc += `\nConfirmation${segment ? ` ${segment}` : ''}: ${leg.confirmation_number}`;
|
||
}
|
||
} else if (meta.departure_airport || meta.arrival_airport) {
|
||
if (meta.departure_airport) desc += `\nFrom: ${meta.departure_airport}`;
|
||
if (meta.arrival_airport) desc += `\nTo: ${meta.arrival_airport}`;
|
||
} else {
|
||
// Endpoint-based transport without route metadata: derive it from endpoints.
|
||
const eps = endpointsMap.get(r.id);
|
||
if (eps && eps.length > 1) {
|
||
const stops = [...eps].sort((a, b) => a.sequence - b.sequence).map(e => e.code || e.name).filter(Boolean);
|
||
if (stops.length > 1) desc += `\nRoute: ${stops.join(' → ')}`;
|
||
}
|
||
}
|
||
if (meta.train_number) desc += `\nTrain: ${meta.train_number}`;
|
||
if (r.notes) desc += `\n${r.notes}`;
|
||
return desc;
|
||
};
|
||
|
||
// Check-in and check-out as their own timed events, when the stay records the
|
||
// clock (#1586). They are separate from the all-day stay above on purpose: an
|
||
// all-day event cannot carry a time, and "be there at 15:00" is the part a
|
||
// subscriber actually wants a reminder for. Emitted per stay rather than per
|
||
// reservation so a stay whose reservation was deleted still shows them.
|
||
// The reservation is read through a subquery rather than a LEFT JOIN: nothing
|
||
// stops two bookings from pointing at the same accommodation, and a join fans
|
||
// the stay out into one row per booking. Since the check-in/check-out UIDs are
|
||
// keyed by the stay, that emitted the same UID twice and clients then pick one
|
||
// of the duplicates at random (#1869). Lowest id wins so the title is stable
|
||
// across exports.
|
||
const stays = this.db.prepare(`
|
||
SELECT a.id, a.check_in, a.check_in_end, a.check_out,
|
||
sd.date AS start_date, ed.date AS end_date,
|
||
p.name AS place_name, p.address AS place_address, p.lat AS place_lat, p.lng AS place_lng,
|
||
(SELECT r.title FROM reservations r
|
||
WHERE r.accommodation_id = a.id AND ${publicReservationSql('r')}
|
||
ORDER BY r.id ASC LIMIT 1) AS reservation_title
|
||
FROM day_accommodations a
|
||
LEFT JOIN days sd ON a.start_day_id = sd.id
|
||
LEFT JOIN days ed ON a.end_day_id = ed.id
|
||
LEFT JOIN places p ON a.place_id = p.id
|
||
WHERE a.trip_id = ? AND ${publicStaySql('a')}
|
||
ORDER BY a.id ASC
|
||
`).all(tripId) as any[];
|
||
|
||
for (const stay of stays) {
|
||
const name = stay.reservation_title || stay.place_name || 'Accommodation';
|
||
const zone = resolveTimeZone(stay.place_lat, stay.place_lng);
|
||
// No end day (the day row was removed) → check out on the arrival day.
|
||
const checkOutDate = isDate(stay.end_date) ? stay.end_date : stay.start_date;
|
||
// Where these markers ARE the booking, they inherit what its block used to
|
||
// carry: confirmation number, notes, and (for a stay whose place holds no
|
||
// address) the booking's own location. Otherwise the block still says all
|
||
// of that, and repeating it would change a feed that already went out: the
|
||
// same rule the hand-overs follow (#2068, #2136).
|
||
const booking = stayCarriedByMarkers.get(stay.id);
|
||
const desc = booking ? describeReservation(booking) : '';
|
||
const address = stay.place_address || (booking ? booking.location : null);
|
||
|
||
const marker = (
|
||
kind: 'checkin' | 'checkout',
|
||
date: string,
|
||
time: string,
|
||
summary: string,
|
||
endTime?: string | null,
|
||
) => {
|
||
const ref = `${date}T00:00`;
|
||
let ev = `BEGIN:VEVENT\r\nUID:${uid(stay.id, kind)}\r\nDTSTAMP:${now}\r\n`;
|
||
ev += dtLine('DTSTART', time, zone, ref);
|
||
if (isTime(endTime)) {
|
||
// An until-clock that is not later than the check-in is a late-arrival
|
||
// window running past midnight ("22:00 to 02:00"): nothing on the way
|
||
// in orders the two, and emitting it as recorded puts the DTEND before
|
||
// the DTSTART, which clients drop outright. Since the stay lost its
|
||
// all-day block there is nothing left to fall back on, so the window
|
||
// ends on the following day (#2136).
|
||
const endDate = endTime!.slice(0, 5) <= time.slice(0, 5) ? addDays(date, 1) : date;
|
||
ev += dtLine('DTEND', endTime!, zone, `${endDate}T00:00`);
|
||
} else {
|
||
// No recorded end → the same default hour the hand-overs read as.
|
||
// Without a DTEND the marker is a zero-length point, and since a fully
|
||
// timed stay is represented by nothing else, several clients rendered
|
||
// the whole booking as clutter or hid it outright (#2136). plusMinutes
|
||
// carries the date across midnight, so a late check-out ends on the
|
||
// following day instead of before it started.
|
||
const stop = plusMinutes(date, time, HANDOVER_MINUTES);
|
||
ev += dtLine('DTEND', stop.time, zone, `${stop.date}T00:00`);
|
||
}
|
||
ev += `SUMMARY:${esc(summary)}\r\n`;
|
||
if (desc) ev += `DESCRIPTION:${esc(desc)}\r\n`;
|
||
if (address) ev += `LOCATION:${esc(address)}\r\n`;
|
||
ev += `END:VEVENT\r\n`;
|
||
events.push(ev);
|
||
};
|
||
|
||
if (isDate(stay.start_date) && isTime(stay.check_in)) {
|
||
marker('checkin', stay.start_date, stay.check_in, `Check-in: ${name}`, stay.check_in_end);
|
||
}
|
||
if (isDate(checkOutDate) && isTime(stay.check_out)) {
|
||
marker('checkout', checkOutDate, stay.check_out, `Check-out: ${name}`);
|
||
}
|
||
}
|
||
|
||
// The two hand-overs of a window booking, as their own events.
|
||
//
|
||
// For a rental this is the car-rental analogue of the check-in/check-out
|
||
// markers above (#1721): additive, sitting beside the booking's own block.
|
||
// For a booking whose two ends fall on DIFFERENT days it is not additive —
|
||
// `windowSplit` dropped the block, and these two ARE the booking (#2068), so
|
||
// they carry its description and a real duration instead of being points.
|
||
//
|
||
// Parking only ever appears here split. A same-day parking is one sitting and
|
||
// keeps the single event it has always had.
|
||
for (const r of reservations) {
|
||
const wording = WINDOW_WORDING[r.type as string];
|
||
if (!wording) continue;
|
||
const split = windowSplit.get(r.id);
|
||
if (!split && r.type !== 'car') continue;
|
||
|
||
const { start, end } = split ?? windowSidesOf(r);
|
||
const desc = split ? describeReservation(r) : '';
|
||
|
||
const handover = (kind: 'pickup' | 'dropoff', side: WindowSide, summary: string) => {
|
||
let ev = `BEGIN:VEVENT\r\nUID:${uid(r.id, kind)}\r\nDTSTAMP:${now}\r\n`;
|
||
if (side.time) {
|
||
const ref = `${side.date}T00:00`;
|
||
ev += dtLine('DTSTART', side.time, side.zone, ref);
|
||
// Only where these events replace the block: adding a DTEND to the
|
||
// additive markers would change a feed that already went out.
|
||
if (split) {
|
||
const stop = plusMinutes(side.date, side.time, HANDOVER_MINUTES);
|
||
ev += dtLine('DTEND', stop.time, side.zone, `${stop.date}T00:00`);
|
||
}
|
||
} else if (split) {
|
||
// No clock anywhere — a rental typed into the planner with the optional
|
||
// time pickers left blank. One all-day event per hand-over still beats a
|
||
// block across the whole window.
|
||
ev += `DTSTART;VALUE=DATE:${fmtDate(side.date)}\r\n`;
|
||
ev += `DTEND;VALUE=DATE:${fmtDate(addDays(side.date, 1))}\r\n`;
|
||
} else {
|
||
return;
|
||
}
|
||
ev += `SUMMARY:${esc(summary)}\r\n`;
|
||
if (desc) ev += `DESCRIPTION:${esc(desc)}\r\n`;
|
||
if (r.location) ev += `LOCATION:${esc(r.location)}\r\n`;
|
||
ev += `END:VEVENT\r\n`;
|
||
events.push(ev);
|
||
};
|
||
|
||
// The wording is per type, the UID is per side: a rental is picked up first
|
||
// and dropped off at the end, a parking is the other way round, but the
|
||
// first side of the window keeps the same UID either way so an existing
|
||
// subscription does not grow a duplicate.
|
||
if (start) handover('pickup', start, `${wording.start}: ${r.title}`);
|
||
if (end) handover('dropoff', end, `${wording.end}: ${r.title}`);
|
||
}
|
||
|
||
// Every referenced zone gets a VTIMEZONE. They are emitted before the first
|
||
// event so the TZID references resolve; keyed by TZID so a merged calendar
|
||
// can define each one once.
|
||
const timezones = new Map<string, string>();
|
||
for (const [zone, yyyymmdd] of usedZones) timezones.set(zone, buildVTimezone(zone, yyyymmdd));
|
||
|
||
// \w + space/tab, not \s: JS \s admits U+3000 and friends — codepoints
|
||
// Node's header validation refuses, so they 500'd the export (#2165).
|
||
const safeFilename = (trip.title || 'trek-trip').replace(/["\r\n]/g, '').replace(/[^\w \t.-]/g, '_');
|
||
return {
|
||
calName: esc(trip.title || 'TREK Trip'),
|
||
filename: `${safeFilename}.ics`,
|
||
timezones,
|
||
events,
|
||
};
|
||
}
|
||
|
||
/** One trip's calendar as a finished, foldable VCALENDAR document. */
|
||
exportICS(tripId: string | number): { ics: string; filename: string } {
|
||
const cal = this.buildTripCalendar(tripId);
|
||
const ics =
|
||
CALENDAR_HEADER +
|
||
`X-WR-CALNAME:${cal.calName}\r\n` +
|
||
[...cal.timezones.values()].join('') +
|
||
cal.events.join('') +
|
||
'END:VCALENDAR\r\n';
|
||
return { ics: foldICS(ics), filename: cal.filename };
|
||
}
|
||
}
|