182 lines
6.9 KiB
TypeScript
182 lines
6.9 KiB
TypeScript
import { db } from './db.js'
|
|
import { getActiveMasterKey } from './auth.js'
|
|
import { getLogbookKey } from './logbookKeys.js'
|
|
import { decryptJson } from './crypto.js'
|
|
|
|
function escapeCsvValue(val: string | number | undefined | null): string {
|
|
if (val === null || val === undefined) return '';
|
|
const str = String(val);
|
|
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
|
return `"${str.replace(/"/g, '""')}"`;
|
|
}
|
|
return str;
|
|
}
|
|
|
|
export async function exportLogbookToCsv(logbookId: string, preloadedData?: { yacht: any; entries: any[] }): Promise<string> {
|
|
let yachtName = '', homePort = '', owner = '', charter = '', registration = '', callsign = '', atis = '', mmsi = '';
|
|
let decryptedEntries: any[] = [];
|
|
|
|
if (preloadedData) {
|
|
const yacht = preloadedData.yacht || {};
|
|
yachtName = yacht.name || '';
|
|
homePort = yacht.port || '';
|
|
owner = yacht.owner || '';
|
|
charter = yacht.charter || '';
|
|
registration = yacht.registration || '';
|
|
callsign = yacht.callsign || '';
|
|
atis = yacht.atis || '';
|
|
mmsi = yacht.mmsi || '';
|
|
decryptedEntries = [...preloadedData.entries];
|
|
} else {
|
|
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
|
if (!masterKey) {
|
|
throw new Error('Encryption key not found. User must log in.')
|
|
}
|
|
|
|
// 1. Fetch Yacht details
|
|
const yachtRecord = await db.yachts.get(logbookId);
|
|
if (yachtRecord) {
|
|
try {
|
|
const yacht = await decryptJson(yachtRecord.encryptedData, yachtRecord.iv, yachtRecord.tag, masterKey);
|
|
yachtName = yacht.name || '';
|
|
homePort = yacht.port || '';
|
|
owner = yacht.owner || '';
|
|
charter = yacht.charter || '';
|
|
registration = yacht.registration || '';
|
|
callsign = yacht.callsign || '';
|
|
atis = yacht.atis || '';
|
|
mmsi = yacht.mmsi || '';
|
|
} catch (e) {
|
|
console.error('Failed to decrypt yacht details for CSV:', e);
|
|
}
|
|
}
|
|
|
|
// 2. Fetch logbook entries
|
|
const localEntries = await db.entries.where({ logbookId }).toArray();
|
|
for (const entry of localEntries) {
|
|
try {
|
|
const dec = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey);
|
|
if (dec) {
|
|
decryptedEntries.push(dec);
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to decrypt entry for CSV:', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort chronological ascending (by date, and then dayOfTravel numerical)
|
|
decryptedEntries.sort((a, b) => {
|
|
const timeA = new Date(a.date || '').getTime() || 0;
|
|
const timeB = new Date(b.date || '').getTime() || 0;
|
|
if (timeA !== timeB) return timeA - timeB;
|
|
return Number(a.dayOfTravel || 0) - Number(b.dayOfTravel || 0);
|
|
});
|
|
|
|
// Headers matching the requested event fields & metadata
|
|
const headers = [
|
|
'Date', 'Day of Travel', 'Departure Port', 'Destination Port',
|
|
'Skipper Signature', 'Crew Signature',
|
|
'Event Time', 'MgK Course', 'RwK Course',
|
|
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State',
|
|
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
|
|
'Latitude', 'Longitude', 'Remarks',
|
|
'Freshwater Morning (L)', 'Freshwater Refilled (L)', 'Freshwater Evening (L)', 'Freshwater Consumption (L)',
|
|
'Fuel Morning (L)', 'Fuel Refilled (L)', 'Fuel Evening (L)', 'Fuel Consumption (L)',
|
|
'Yacht Name', 'Home Port', 'Owner', 'Charter Company', 'Registration', 'Callsign', 'ATIS', 'MMSI'
|
|
];
|
|
|
|
const rows: string[][] = [headers];
|
|
|
|
for (const entry of decryptedEntries) {
|
|
const dateVal = entry.date || '';
|
|
const travelDay = entry.dayOfTravel || '';
|
|
const dep = entry.departure || '';
|
|
const dest = entry.destination || '';
|
|
const signS = entry.signSkipper || '';
|
|
const signC = entry.signCrew || '';
|
|
const fwM = entry.freshwater?.morning ?? '';
|
|
const fwR = entry.freshwater?.refilled ?? '';
|
|
const fwE = entry.freshwater?.evening ?? '';
|
|
const fwCons = entry.freshwater?.consumption ?? '';
|
|
const fuelM = entry.fuel?.morning ?? '';
|
|
const fuelR = entry.fuel?.refilled ?? '';
|
|
const fuelE = entry.fuel?.evening ?? '';
|
|
const fuelCons = entry.fuel?.consumption ?? '';
|
|
|
|
const eventsList = entry.events || [];
|
|
if (eventsList.length === 0) {
|
|
// Create one row even if there are no events for the day
|
|
rows.push([
|
|
dateVal, travelDay, dep, dest,
|
|
signS, signC,
|
|
'', '', '',
|
|
'', '', '', '',
|
|
'', '', '', '', '',
|
|
'', '', '',
|
|
fwM, fwR, fwE, fwCons,
|
|
fuelM, fuelR, fuelE, fuelCons,
|
|
yachtName, homePort, owner, charter, registration, callsign, atis, mmsi
|
|
].map(escapeCsvValue));
|
|
} else {
|
|
// Sort events chronologically by time
|
|
const sortedEvents = [...eventsList].sort((a, b) => (a.time || '').localeCompare(b.time || ''));
|
|
for (const ev of sortedEvents) {
|
|
rows.push([
|
|
dateVal, travelDay, dep, dest,
|
|
signS, signC,
|
|
ev.time || '', ev.mgk || '', ev.rwk || '',
|
|
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
|
|
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
|
|
ev.gpsLat || '', ev.gpsLng || '', ev.remarks || '',
|
|
fwM, fwR, fwE, fwCons,
|
|
fuelM, fuelR, fuelE, fuelCons,
|
|
yachtName, homePort, owner, charter, registration, callsign, atis, mmsi
|
|
].map(escapeCsvValue));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Convert array of arrays to CSV string
|
|
return rows.map(r => r.join(',')).join('\n');
|
|
}
|
|
|
|
export async function downloadCsv(logbookId: string, title: string, preloadedData?: { yacht: any; entries: any[] }): Promise<void> {
|
|
const csvContent = await exportLogbookToCsv(logbookId, preloadedData);
|
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
|
|
// Sanitize filename
|
|
const filename = `${title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_logbook.csv`;
|
|
link.setAttribute('download', filename);
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
}
|
|
|
|
export async function shareCsv(logbookId: string, title: string, preloadedData?: { yacht: any; entries: any[] }): Promise<void> {
|
|
const csvContent = await exportLogbookToCsv(logbookId, preloadedData);
|
|
const filename = `${title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_logbook.csv`;
|
|
|
|
const file = new File([csvContent], filename, { type: 'text/csv' });
|
|
|
|
if (navigator.canShare && navigator.canShare({ files: [file] })) {
|
|
try {
|
|
await navigator.share({
|
|
files: [file],
|
|
title: `Kapteins Daagbox - ${title}`,
|
|
text: `Logbook export for yacht ${title}`
|
|
});
|
|
} catch (e: any) {
|
|
if (e.name !== 'AbortError') {
|
|
console.error('Sharing failed, falling back to download:', e);
|
|
await downloadCsv(logbookId, title, preloadedData);
|
|
}
|
|
}
|
|
} else {
|
|
throw new Error('share_unsupported');
|
|
}
|
|
}
|