-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathflying.ts
More file actions
297 lines (261 loc) · 8.64 KB
/
Copy pathflying.ts
File metadata and controls
297 lines (261 loc) · 8.64 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
import { google } from 'googleapis';
import IGCParser from 'igc-parser';
import { distanceTo } from 'geolocation-utils';
import { readFileSync, writeFileSync } from 'node:fs';
import { glob } from 'glob';
import path from 'path';
import fs from 'node:fs';
import matter from 'gray-matter';
import { DateTime } from 'luxon';
import { basename } from 'path';
import { GscWaypoints } from './waypoints';
import { marked } from 'marked';
import type { Flight, FlightIgcFile, Launch, Sites } from './flyingTypes';
// Cache of all the flights in the database
let flights: Flight[] = [];
async function getExcerpt(content: string, length = 100) {
const EXCERPT_SEPERATOR = '{/* --- */}';
let excerpt = content.split(' ').slice(0, length).join(' ') + '...';
if (content.includes(EXCERPT_SEPERATOR)) {
excerpt = content.split(EXCERPT_SEPERATOR)[0].trim();
}
excerpt = excerpt.replace(/^(import.*)\s*$/gm, '');
return await marked.parse(excerpt);
}
function truncateComments(comments: string, maxLength: number): string {
if (comments.length <= maxLength) {
return comments;
}
const truncatedText = comments.substring(0, maxLength);
const lastSpaceIndex = truncatedText.lastIndexOf(' ');
if (lastSpaceIndex !== -1) {
return truncatedText.substring(0, lastSpaceIndex) + '...';
}
return truncatedText + '...';
}
// Replaces a location with URL unfriendly names, with friendly ones
function urlFriendlyLocationName(location: string): string {
const specialCharactersRegex = /[^\w\-./?=&]/g;
return location.replace(specialCharactersRegex, '-').replaceAll('/', '-');
}
// Gets flights from google docs
async function getSpreadsheetFlights(): Promise<Flight[]> {
// Authenticate using your credentials
const auth = new google.auth.GoogleAuth({
credentials: {
private_key: Buffer.from(
import.meta.env.PRIVATE_KEY as string,
'base64'
).toString(),
client_email: import.meta.env.CLIENT_EMAIL,
client_id: import.meta.env.CLIENT_ID,
},
projectId: import.meta.env.PROJECT_ID,
scopes: ['https://www.googleapis.com/auth/spreadsheets.readonly'],
});
// Create a Google Sheets API instance
const sheets = google.sheets({ version: 'v4', auth: auth });
// Specify the spreadsheet ID and range of cells you want to retrieve
const spreadsheetId = '1-rd2b-_l5IgavBxVe50O817AjAbI5qAVc0leU2bxUM8';
const range = 'Sheet1!A:G';
// Retrieve the values from the specified range
const response = await sheets.spreadsheets.values.get({
spreadsheetId,
range,
});
// Extract the values from the response
const values = response.data.values as string[][];
// Extract the keys from the first entry
const keys = values[0];
// Create a list of objects
const results = values.slice(1).map((entry, j) => {
j = j + 2; // Account for index based 0, and the header row
const obj: Record<string, string> = {};
keys.forEach((key, index) => {
obj[key] = entry[index];
});
obj['id'] = `g${j}`; // Set the ID to be the record number
return obj;
});
return results.map((r, i) => {
const date = r['date'] ?? '01-01-1980';
return {
id: r.id,
date: date,
launchTime: DateTime.fromISO(date).toMillis() + i,
launchName: r.launchName ?? '',
durationSeconds: Number(r.durationSeconds ?? 0),
wing: r.wing,
fileName: r.fileName,
comments: r.comment,
location: r.location,
locationUrl: urlFriendlyLocationName(r.location),
};
});
}
/*
* Parse an IGC file into a flight record
*/
function parseFile(
igc: IGCParser.IGCFile,
launches: Launch[],
fileInfo: FlightIgcFile,
rawFile: string
): Flight {
// Calculate the closest launch (within a 1km margin)
const launchDistances = launches.map((l) => distanceTo(igc.fixes[0], l));
const shortestDistance = Math.min(...launchDistances);
const closestLaunch =
shortestDistance < 1000
? launches[launchDistances.indexOf(shortestDistance)]
: undefined;
// Build the flight to return
return {
id: fileInfo.fileNameWitoutExtension,
date: igc.date,
wing: igc.gliderType!,
durationSeconds:
(igc.fixes[igc.fixes.length - 1].timestamp - igc.fixes[0].timestamp) /
1000,
maxDistanceMeters: Math.max(
...igc.fixes.map((f) => distanceTo(igc.fixes[0], f))
),
maxAltitudeMeters: Math.max(...igc.fixes.map((f) => f.gpsAltitude!)),
trackLengthMeters: igc.fixes.reduce(
(previousValue, currentValue, currentIndex) => {
if (currentIndex === 0) {
return 0;
}
// Work out the distance to the last point
return (
previousValue + distanceTo(igc.fixes[currentIndex - 1], currentValue)
);
},
0
),
altitudeGainMeters:
Math.max(...igc.fixes.map((f) => f.gpsAltitude || 0)) -
(igc.fixes[0].gpsAltitude || 0),
launchTime: igc.fixes[0].timestamp,
waypoints: GscWaypoints(rawFile, igc),
...(igc?.task?.comment && { comment: igc?.task?.comment }),
launchName: closestLaunch?.name,
};
}
/*
* Replace locations from a file
*/
function replaceLocations(fileName: string, logbook: Flight[]): Flight[] {
const locations: Sites = JSON.parse(
readFileSync(fileName, {
flag: 'r',
encoding: 'utf8',
})
);
return logbook.map((r) => {
if (r.location != undefined) {
return r;
}
// Find location mame that matches (any really) Set the flying type
Object.entries(locations).forEach(([location, locationInfo]) => {
if (locationInfo.aliases.some((l) => r.launchName?.includes(l))) {
r.location = location;
r.type = locationInfo.type;
return;
}
});
// If there's a location, set the URL friendly location
if (r.location) r.locationUrl = urlFriendlyLocationName(r.location);
return r;
});
}
/*
* Gets all flights from gsc files on disk
*/
async function gscFlights(): Promise<Flight[]> {
// Load up the launches
let buffer = readFileSync('public/logbook/launches.json', {
flag: 'r',
encoding: 'utf8',
});
const launches: Launch[] = JSON.parse(buffer);
const igcs = await glob('public/logbook/flights/**/*.igc', { nocase: true });
const flights = await Promise.all(
igcs.map(async (f) => {
// Load the igc file into memory
const buffer = readFileSync(f, { flag: 'r', encoding: 'utf8' });
const igc = IGCParser.parse(buffer);
const igcFileInfo: FlightIgcFile = {
filePath: f,
fileName: basename(f),
fileNameWitoutExtension: basename(f).split('.')[0],
filePathUrl: f.replace(/\/?public/, ''),
};
let ret = parseFile(igc, launches, igcFileInfo, buffer);
ret.igcFile = igcFileInfo;
// Load in the comments from disk
const igcDirectory = path.dirname(f);
const commentsFile = path.join(igcDirectory, 'comments.mdx');
if (fs.existsSync(commentsFile)) {
const m = matter.read(commentsFile, {
excerpt: true,
excerpt_separator: '{/* EXCERPT */}',
});
ret.comments = m.excerpt || m.content;
ret.commentsFileName = commentsFile;
if (m.data.sportsTrackLiveUrl) {
ret.sportsTrackLiveUrl = m.data.sportsTrackLiveUrl;
}
}
return ret;
})
);
return replaceLocations('public/logbook/sites.json', flights);
}
/*
* Builds a cache for all local flights
*/
export async function PopulateFlights() {
console.log('Pulling flights (...slow)');
let spreadsheetFlights: Flight[] = [];
try {
spreadsheetFlights = await getSpreadsheetFlights();
} catch (error) {
console.error(
'Unable to load flights from spreadsheet. Carrying on without any. Check your google auth settings'
);
}
// Build comments and badges
const igcFlights = await gscFlights();
flights = [...spreadsheetFlights, ...igcFlights];
flights.sort((a, b) => a.launchTime - b.launchTime);
flights.forEach(async (f, i) => {
f.number = i + 1;
if (f.comments !== undefined) {
f.commentsTruncated = truncateComments(f.comments, 100);
f.excerpt = await getExcerpt(f.comments);
}
});
// Sort from most recent first
flights = flights.reverse();
}
// Gets, caches, and returns flights
export async function GetFlights(): Promise<Flight[]> {
if (flights.length == 0) {
// Check if we can load it up from disk
if (fs.existsSync('./flights.json')) {
flights = JSON.parse(readFileSync('./flights.json', 'utf-8'));
} else {
await PopulateFlights();
}
}
return flights;
}
/*
* Builds a local flight database
*/
export async function DownloadFlights() {
// Populate the flight database
await PopulateFlights();
writeFileSync('./flights.json', JSON.stringify(flights));
}