feat: implement Phase 4 (CSV export, share, sync indicators, OS themes) and add dev starter script
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.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): Promise<string> {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) {
|
||||
throw new Error('Master key not found. User must log in.')
|
||||
}
|
||||
|
||||
// 1. Fetch Yacht details
|
||||
let yachtName = '', homePort = '', owner = '', charter = '', registration = '', callsign = '', atis = '', mmsi = '';
|
||||
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();
|
||||
const decryptedEntries = [];
|
||||
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): Promise<void> {
|
||||
const csvContent = await exportLogbookToCsv(logbookId);
|
||||
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): Promise<void> {
|
||||
const csvContent = await exportLogbookToCsv(logbookId);
|
||||
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);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error('share_unsupported');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user