incyclist-services 1.8.0 → 1.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/lib/cjs/activities/ride/service.js +17 -3
  2. package/lib/cjs/routes/base/api/index.js +6 -2
  3. package/lib/cjs/routes/download/service.js +8 -0
  4. package/lib/cjs/routes/index.js +2 -0
  5. package/lib/cjs/routes/library/errors.js +52 -0
  6. package/lib/cjs/routes/library/index.js +18 -0
  7. package/lib/cjs/routes/library/service.js +390 -191
  8. package/lib/cjs/routes/list/cards/RouteCard.js +19 -5
  9. package/lib/cjs/routes/list/detailsQueue.js +74 -0
  10. package/lib/cjs/routes/list/lists/selected.js +3 -2
  11. package/lib/cjs/routes/list/service.js +229 -61
  12. package/lib/cjs/routes/shapes/index.js +21 -0
  13. package/lib/cjs/routes/shapes/store.js +163 -0
  14. package/lib/cjs/routes/shapes/types.js +2 -0
  15. package/lib/cjs/routes/shapes/utils.js +89 -0
  16. package/lib/esm/activities/ride/service.js +17 -3
  17. package/lib/esm/routes/base/api/index.js +6 -2
  18. package/lib/esm/routes/download/service.js +8 -0
  19. package/lib/esm/routes/index.js +2 -0
  20. package/lib/esm/routes/library/errors.js +46 -0
  21. package/lib/esm/routes/library/index.js +2 -0
  22. package/lib/esm/routes/library/service.js +390 -191
  23. package/lib/esm/routes/list/cards/RouteCard.js +19 -5
  24. package/lib/esm/routes/list/detailsQueue.js +70 -0
  25. package/lib/esm/routes/list/lists/selected.js +1 -1
  26. package/lib/esm/routes/list/service.js +230 -62
  27. package/lib/esm/routes/shapes/index.js +2 -0
  28. package/lib/esm/routes/shapes/store.js +159 -0
  29. package/lib/esm/routes/shapes/types.js +1 -0
  30. package/lib/esm/routes/shapes/utils.js +82 -0
  31. package/lib/types/activities/ride/service.d.ts +2 -0
  32. package/lib/types/routes/download/service.d.ts +1 -0
  33. package/lib/types/routes/index.d.ts +2 -0
  34. package/lib/types/routes/library/errors.d.ts +12 -0
  35. package/lib/types/routes/library/index.d.ts +3 -0
  36. package/lib/types/routes/library/service.d.ts +32 -4
  37. package/lib/types/routes/library/types.d.ts +31 -1
  38. package/lib/types/routes/list/cards/RouteCard.d.ts +1 -0
  39. package/lib/types/routes/list/cards/types.d.ts +2 -0
  40. package/lib/types/routes/list/detailsQueue.d.ts +16 -0
  41. package/lib/types/routes/list/lists/selected.d.ts +2 -0
  42. package/lib/types/routes/list/service.d.ts +45 -7
  43. package/lib/types/routes/list/types.d.ts +1 -0
  44. package/lib/types/routes/shapes/index.d.ts +3 -0
  45. package/lib/types/routes/shapes/store.d.ts +22 -0
  46. package/lib/types/routes/shapes/types.d.ts +12 -0
  47. package/lib/types/routes/shapes/utils.d.ts +8 -0
  48. package/package.json +5 -5
@@ -44,6 +44,7 @@ const types_1 = require("../../base/types");
44
44
  const parsers_1 = require("../base/parsers");
45
45
  const service_2 = require("../list/service");
46
46
  const utils_1 = require("../../utils");
47
+ const errors_1 = require("./errors");
47
48
  const db_1 = require("../list/loaders/db");
48
49
  const route_1 = require("../base/model/route");
49
50
  const sleep_1 = require("../../utils/sleep");
@@ -51,7 +52,11 @@ const i18n_1 = require("../../i18n");
51
52
  const utils_2 = require("../base/parsers/utils");
52
53
  const externalFiles_1 = require("../../fileaccess/externalFiles");
53
54
  const store_1 = require("../previews/store");
55
+ const store_2 = require("../shapes/store");
54
56
  const WAITING_FOR_ICLOUD_THRESHOLD_MS = 2_000;
57
+ const VIDEO_EXTENSIONS = ['mp4', 'avi', 'mov', 'm4v', 'mkv', 'webm', 'mpg', 'mpeg', 'wmv'];
58
+ const PATH_SEPARATOR = /[/\\]/;
59
+ const UNREADABLE_FOLDER_ERRORS = /\b(EACCES|EPERM|EIO|EBUSY|EAGAIN|ETIMEDOUT|ESTALE|EMFILE|ENFILE|EHOSTDOWN|EHOSTUNREACH|ENETDOWN|ENETUNREACH|ECONNRESET|ECONNABORTED)\b/;
55
60
  let RouteLibraryScannerService = (() => {
56
61
  let _classDecorators = [decorators_1.Singleton];
57
62
  let _classDescriptor;
@@ -63,6 +68,7 @@ let RouteLibraryScannerService = (() => {
63
68
  let _getBindings_decorators;
64
69
  let _getRoutesDBLoader_decorators;
65
70
  let _getPreviewStore_decorators;
71
+ let _getRouteShapeStore_decorators;
66
72
  let _getParsers_decorators;
67
73
  let _getUnitConverter_decorators;
68
74
  var RouteLibraryScannerService = class extends _classSuper {
@@ -73,12 +79,14 @@ let RouteLibraryScannerService = (() => {
73
79
  _getBindings_decorators = [decorators_1.Injectable];
74
80
  _getRoutesDBLoader_decorators = [decorators_1.Injectable];
75
81
  _getPreviewStore_decorators = [decorators_1.Injectable];
82
+ _getRouteShapeStore_decorators = [decorators_1.Injectable];
76
83
  _getParsers_decorators = [decorators_1.Injectable];
77
84
  _getUnitConverter_decorators = [decorators_1.Injectable];
78
85
  __esDecorate(this, null, _getRouteList_decorators, { kind: "method", name: "getRouteList", static: false, private: false, access: { has: obj => "getRouteList" in obj, get: obj => obj.getRouteList }, metadata: _metadata }, null, _instanceExtraInitializers);
79
86
  __esDecorate(this, null, _getBindings_decorators, { kind: "method", name: "getBindings", static: false, private: false, access: { has: obj => "getBindings" in obj, get: obj => obj.getBindings }, metadata: _metadata }, null, _instanceExtraInitializers);
80
87
  __esDecorate(this, null, _getRoutesDBLoader_decorators, { kind: "method", name: "getRoutesDBLoader", static: false, private: false, access: { has: obj => "getRoutesDBLoader" in obj, get: obj => obj.getRoutesDBLoader }, metadata: _metadata }, null, _instanceExtraInitializers);
81
88
  __esDecorate(this, null, _getPreviewStore_decorators, { kind: "method", name: "getPreviewStore", static: false, private: false, access: { has: obj => "getPreviewStore" in obj, get: obj => obj.getPreviewStore }, metadata: _metadata }, null, _instanceExtraInitializers);
89
+ __esDecorate(this, null, _getRouteShapeStore_decorators, { kind: "method", name: "getRouteShapeStore", static: false, private: false, access: { has: obj => "getRouteShapeStore" in obj, get: obj => obj.getRouteShapeStore }, metadata: _metadata }, null, _instanceExtraInitializers);
82
90
  __esDecorate(this, null, _getParsers_decorators, { kind: "method", name: "getParsers", static: false, private: false, access: { has: obj => "getParsers" in obj, get: obj => obj.getParsers }, metadata: _metadata }, null, _instanceExtraInitializers);
83
91
  __esDecorate(this, null, _getUnitConverter_decorators, { kind: "method", name: "getUnitConverter", static: false, private: false, access: { has: obj => "getUnitConverter" in obj, get: obj => obj.getUnitConverter }, metadata: _metadata }, null, _instanceExtraInitializers);
84
92
  __esDecorate(null, _classDescriptor = { value: _classThis }, _classDecorators, { kind: "class", name: _classThis.name, metadata: _metadata }, null, _classExtraInitializers);
@@ -118,35 +126,89 @@ let RouteLibraryScannerService = (() => {
118
126
  importSingle(fileInfo) {
119
127
  const observer = new types_1.Observer();
120
128
  this.isCancelled = false;
121
- this.importRoute(fileInfo, observer).catch(err => {
129
+ if (!this.importProps)
130
+ this.prepare();
131
+ const session = this.importProps;
132
+ delete session.error;
133
+ delete session.failure;
134
+ delete session.resultSuccess;
135
+ this.importRoute(fileInfo, observer, session).catch(err => {
122
136
  this.logError(err, 'importSingle', { file: fileInfo?.filename });
123
137
  observer.emit('error', err.message);
124
138
  });
125
139
  observer.on('success', (route) => {
126
- if (!this.importProps)
140
+ if (this.importProps !== session)
141
+ return;
142
+ session.phase = 'result';
143
+ session.resultSuccess = { routeName: route.title };
144
+ });
145
+ observer.on('error', (error, failure) => {
146
+ if (this.importProps !== session)
127
147
  return;
128
- this.importProps.phase = 'result';
129
- this.importProps.resultSuccess = { routeName: route.title };
148
+ session.phase = 'result';
149
+ session.error = error;
150
+ session.failure = failure;
151
+ });
152
+ return observer;
153
+ }
154
+ importFiles(files) {
155
+ const observer = new types_1.Observer();
156
+ this.isCancelled = false;
157
+ if (!this.importProps)
158
+ this.prepare();
159
+ this.importProps.phase = 'ingesting';
160
+ const list = this.getRouteList();
161
+ list.pauseListUpdates();
162
+ this._importFiles(files ?? [], observer)
163
+ .catch(err => {
164
+ this.logError(err, 'importFiles');
165
+ observer.emit('error', err.message);
166
+ })
167
+ .finally(() => {
168
+ list.resumeListUpdates();
169
+ list.emitLists('updated', { source: 'system' });
130
170
  });
131
- observer.on('error', (error) => {
171
+ observer.on('ingest-progress', (progress) => {
172
+ if (this.importProps)
173
+ this.importProps.ingestProgress = progress;
174
+ });
175
+ observer.on('ingest-complete', (status) => {
132
176
  if (!this.importProps)
133
177
  return;
134
- this.importProps.phase = 'result';
135
- this.importProps.error = error;
178
+ const { imported, skipped, errors, failedRoutes } = status;
179
+ this.importProps.phase = 'complete';
180
+ this.importProps.completionSummary = { imported, skipped, errors, failedRoutes };
136
181
  });
137
182
  return observer;
138
183
  }
184
+ async importRouteFile(fileInfo, props) {
185
+ const name = fileInfo?.url ?? fileInfo?.filename ?? fileInfo?.name;
186
+ this.logEvent({ message: 'import single route file', file: name, type: fileInfo?.ext });
187
+ try {
188
+ const route = await this.parseRouteFile(fileInfo, props?.isCancelled);
189
+ this.logEvent({ message: 'import single route file success', file: name });
190
+ const list = props?.list ?? this.getRouteList();
191
+ const existing = list.findCard(route) ?? null;
192
+ await this.ingestOne(route, existing, list, this.getRoutesDBLoader());
193
+ return route;
194
+ }
195
+ catch (err) {
196
+ const error = (0, errors_1.toRouteImportError)(err);
197
+ this.logEvent({ message: 'import single route file failed', file: name, reason: error.message, code: error.code, stack: err?.stack });
198
+ throw error;
199
+ }
200
+ }
139
201
  scan(folderInfo) {
140
202
  if (!this.importProps)
141
203
  this.prepare();
142
- this.logEvent({ message: 'import route library', folder: folderInfo.displayName, uri: folderInfo.uri });
204
+ this.logEvent({ message: 'import route library', folder: folderInfo.displayName });
143
205
  this.isCancelled = false;
144
206
  this.scanResult = [];
145
207
  this.importProps.phase = 'scanning';
146
- this.importProps.scanProgress = { scannedFolders: 0 };
208
+ this.importProps.scanProgress = { scannedFolders: 0, failedFolders: 0 };
147
209
  const observer = new types_1.Observer();
148
210
  this._scan(folderInfo, observer).catch(err => {
149
- this.logError(err, 'scan', { uri: folderInfo.uri });
211
+ this.logError(err, 'scan', { folder: folderInfo.displayName });
150
212
  observer.emit('error', err.message);
151
213
  });
152
214
  observer
@@ -154,8 +216,8 @@ let RouteLibraryScannerService = (() => {
154
216
  this.importProps.scanProgress = progress;
155
217
  })
156
218
  .on('scan-complete', () => {
157
- const cntScanned = this.importProps.scanProgress.scannedFolders;
158
- this.logEvent({ message: 'import route library: scan success', folder: folderInfo.displayName, cntScanned });
219
+ const { scannedFolders: cntScanned, failedFolders: cntFailed } = this.importProps.scanProgress;
220
+ this.logEvent({ message: 'import route library: scan success', folder: folderInfo.displayName, cntScanned, cntFailed });
159
221
  this.importProps.phase = 'parsing';
160
222
  });
161
223
  return observer;
@@ -228,189 +290,307 @@ let RouteLibraryScannerService = (() => {
228
290
  this.done();
229
291
  this.prepare();
230
292
  }
231
- async importRoute(fileInfo, observer) {
293
+ async importRoute(fileInfo, observer, session) {
232
294
  await (0, sleep_1.sleep)(0);
233
- observer.emit('parsing');
234
- if (!this.importProps)
295
+ const isStale = () => this.importProps !== session;
296
+ if (isStale())
235
297
  return;
236
- this.importProps.phase = 'parsing';
237
- if (fileInfo?.ext === 'gpx') {
238
- return this.importSingleGpxRoute(fileInfo, observer);
298
+ session.phase = 'parsing';
299
+ observer.emit('parsing');
300
+ const isCancelled = () => this.isCancelled || isStale();
301
+ try {
302
+ const route = await this.importRouteFile(fileInfo, { isCancelled });
303
+ if (!isCancelled())
304
+ observer.emit('success', route);
239
305
  }
240
- else {
241
- return this.importSingleVideoRoute(fileInfo, observer);
306
+ catch (err) {
307
+ const error = (0, errors_1.toRouteImportError)(err);
308
+ if (!isCancelled())
309
+ observer.emit('error', error.message, error.toFailure());
242
310
  }
243
311
  }
244
- async importSingleGpxRoute(fileInfo, observer) {
245
- const name = fileInfo.url ?? fileInfo.filename ?? fileInfo.name;
246
- this.logEvent({ message: 'import single route file', file: name, type: fileInfo.ext });
247
- const service = this.getRouteList();
248
- const db = this.getRoutesDBLoader();
249
- try {
250
- const { data, details } = await parsers_1.RouteParser.parse(fileInfo);
251
- const route = new route_1.Route(data, details);
252
- this.logEvent({ message: 'import single route file success', file: name });
253
- const existing = service.findCard(route);
254
- if (existing) {
255
- this.logEvent({ message: 'route updated (import)', route: route.title });
312
+ async _importFiles(files, observer) {
313
+ await (0, utils_1.waitNextTick)();
314
+ const targets = files.filter(f => !!f);
315
+ const total = targets.length;
316
+ const failedRoutes = [];
317
+ const importedRoutes = [];
318
+ const isCancelled = () => this.isCancelled;
319
+ for (let i = 0; i < total; i++) {
320
+ if (isCancelled())
321
+ continue;
322
+ const file = targets[i];
323
+ const name = file.name ?? file.base ?? file.filename ?? file.url;
324
+ observer.emit('ingest-progress', { current: i + 1, total, currentName: name });
325
+ try {
326
+ importedRoutes.push(await this.importRouteFile(file, { isCancelled }));
256
327
  }
257
- route.description.tsImported = Date.now();
258
- await db.save(route, true);
259
- service.addRoute(route, existing ? 'import' : 'user');
260
- const cardInfo = service.findCard(route);
261
- cardInfo?.card?.verify();
262
- if (existing) {
263
- existing.card.emitUpdate();
328
+ catch (err) {
329
+ const error = (0, errors_1.toRouteImportError)(err);
330
+ failedRoutes.push({ name, reason: error.message, code: error.code, missingExt: error.missingExt });
331
+ observer.emit('ingest-error', { name, reason: error.message });
264
332
  }
265
- observer.emit('success', route);
266
- }
267
- catch (err) {
268
- this.logEvent({ message: 'import single route file failed', file: name, reason: err.message, stack: err.stack });
269
- observer.emit('error', err.message);
270
333
  }
334
+ observer.emit('ingest-complete', { imported: importedRoutes.length, skipped: 0, errors: failedRoutes.length, failedRoutes, importedRoutes });
271
335
  }
272
- async importSingleVideoRoute(fileInfo, observer) {
273
- const name = fileInfo.url ?? fileInfo.filename ?? fileInfo.name;
274
- this.logEvent({ message: 'import single route file', file: name, type: fileInfo.ext });
336
+ async parseRouteFile(fileInfo, isCancelled) {
337
+ const ext = (fileInfo?.ext ?? '').replace(/^\./, '').toLowerCase();
338
+ if (ext === 'gpx' || !fileInfo?.dir) {
339
+ const { data, details } = await parsers_1.RouteParser.parse(fileInfo);
340
+ return new route_1.Route(data, details);
341
+ }
275
342
  const parsers = this.getParsers();
276
- const { dir, delimiter } = fileInfo;
277
- let { ext } = fileInfo;
278
- if (ext.startsWith('.'))
279
- ext = ext.slice(1);
280
- const folderUri = dir.endsWith(delimiter ?? '/') ? dir.slice(0, -delimiter.length) : dir;
343
+ if (!parsers.isPrimaryExtension(ext))
344
+ throw new errors_1.RouteImportError('not a route control file', 'UNSUPPORTED');
345
+ const target = await this.locateRouteFile(fileInfo, ext, parsers);
346
+ return this.parseLocatedRoute(target, isCancelled);
347
+ }
348
+ async locateRouteFile(fileInfo, ext, parsers) {
349
+ const { dir } = fileInfo;
350
+ const delimiter = fileInfo.delimiter ?? '/';
351
+ const folderUri = dir.endsWith(delimiter) ? dir.slice(0, -delimiter.length) : dir;
352
+ let listing = null;
281
353
  try {
282
- if (!parsers.isPrimaryExtension(ext)) {
283
- this.logEvent({ message: 'import single route file failed', file: name, reason: 'not a route control file' });
284
- observer.emit('error', 'not a route control file', ext);
285
- return;
286
- }
287
- const scanObserver = new types_1.Observer();
288
- await this.scanFolder(folderUri, folderUri, scanObserver, parsers, { scannedFolders: 0 }, { value: 0 }, false);
289
- const files = this.scanResult;
290
- this.scanResult = [];
291
- scanObserver.stop();
292
- if (!files.length) {
293
- this.logEvent({ message: 'import single route file failed', file: name, reason: 'no file found' });
294
- observer.emit('error', 'no file found');
295
- }
296
- if (files[0].scanError) {
297
- this.logEvent({ message: 'import single route file failed', file: name, reason: files[0].scanError });
298
- observer.emit('error', files[0].scanError);
299
- return;
354
+ listing = await this.listEntries(folderUri);
355
+ }
356
+ catch (err) {
357
+ this.logError(err, 'locateRouteFile');
358
+ }
359
+ const files = (listing ?? [])
360
+ .filter(e => !e.isDirectory)
361
+ .map(e => e.unknownType ? { name: e.name, uri: e.uri, isDirectory: false } : e);
362
+ const base = fileInfo.base ?? fileInfo.name;
363
+ const controlFile = base ? (files.find(f => f.name === base) ?? files.find(f => f.uri?.includes(base))) : undefined;
364
+ if (!controlFile)
365
+ throw new errors_1.RouteImportError('no file found', 'READ_FAILED');
366
+ const baseName = this.getBaseName(controlFile.name);
367
+ const missingExt = this.findMissingCompanion(parsers, ext, baseName, files);
368
+ if (missingExt)
369
+ throw new errors_1.RouteImportError(`Missing companion file (.${missingExt})`, 'MISSING_COMPANION', { missingExt });
370
+ return {
371
+ folderUri,
372
+ folderName: folderUri,
373
+ files,
374
+ controlFileUri: controlFile.uri,
375
+ format: this.getExtension(controlFile.name)
376
+ };
377
+ }
378
+ async parseLocatedRoute(target, isCancelled) {
379
+ const file = this.buildFileInfo(target.controlFileUri, target.format);
380
+ const scope = (0, externalFiles_1.useExternalFileService)().beginScope({ isCancelled });
381
+ try {
382
+ let result;
383
+ try {
384
+ result = await parsers_1.RouteParser.parse(file);
300
385
  }
301
- else {
302
- const file = files.find(file => file.controlFileUri.includes(fileInfo.base));
303
- const parseObserver = this.parse([file]);
304
- parseObserver.on('parse-result', (result) => {
305
- parseObserver.stop();
306
- if (result.parseError) {
307
- this.logEvent({ message: 'import single route file failed', file: name, reason: result.parseError });
308
- observer.emit('error', result.parseError);
309
- return;
310
- }
311
- const ingest = this.ingest([result]);
312
- let ingestError;
313
- ingest.once('ingest-error', (_, reason) => {
314
- ingest.stop();
315
- this.logEvent({ message: 'import single route file failed', file: name, reason });
316
- observer.emit('error', reason);
317
- ingestError = reason;
318
- });
319
- ingest.once('ingest-complete', (summary) => {
320
- ingest.stop();
321
- if (!ingestError && summary.imported > 0) {
322
- this.logEvent({ message: 'import single route file success', file: name });
323
- observer.emit('success', summary.importedRoutes?.[0]?.title);
324
- }
325
- else if (!ingestError && summary.imported === 0) {
326
- this.logEvent({ message: 'import single route file failed', file: name, reason: 'not imported' });
327
- observer.emit('error', 'not imported');
328
- }
329
- });
330
- });
386
+ finally {
387
+ scope.end();
331
388
  }
389
+ if (scope.lastFailure)
390
+ throw new Error(this.getReadFailureMessage(scope));
391
+ const route = new route_1.Route(result.data, result.details);
392
+ if (result.data.hasVideo)
393
+ this.validateVideoUrl(route, target.folderUri, target.files);
394
+ return route;
332
395
  }
333
396
  catch (err) {
334
- this.logEvent({ message: 'import single route file failed', file: name, reason: err.message, stack: err.stack });
335
- observer.emit('error', err.message);
397
+ throw (0, errors_1.toRouteImportError)(err, scope, scope.lastFailure ? this.getReadFailureMessage(scope) : undefined);
336
398
  }
337
399
  }
338
400
  async _scan(folderInfo, observer) {
339
401
  await (0, utils_1.waitNextTick)();
340
- const parsers = this.getParsers();
341
- const progress = { scannedFolders: 0 };
342
- const discoveredCount = { value: 0 };
343
- await this.scanFolder(folderInfo.uri, folderInfo.displayName, observer, parsers, progress, discoveredCount);
344
- await this.upsertImportHistory(folderInfo, discoveredCount.value);
345
- this.logEvent({ message: 'video scan result', scannedFolders: this.importProps.scanProgress.scannedFolders, files: this.scanResult.length });
402
+ const ctx = this.createScanContext(observer, this.getParsers(), true);
403
+ await this.scanFolder(folderInfo.uri, folderInfo.displayName, ctx);
404
+ await this.upsertImportHistory(folderInfo, ctx.discoveredCount.value);
405
+ const { scannedFolders, failedFolders } = ctx.progress;
406
+ this.logEvent({ message: 'video scan result', scannedFolders, failedFolders, files: this.scanResult.length });
346
407
  observer.emit('scan-complete', this.scanResult);
347
408
  }
348
- async scanFolder(uri, folderName, observer, parsers, progress, discoveredCount, recursive = true) {
349
- const fs = this.getBindings().fs;
350
- let entries;
351
- try {
352
- entries = await fs.readdir(uri, { recursive: false, extended: true });
353
- }
354
- catch (err) {
355
- this.logError(err, 'scanFolder', { uri });
409
+ createScanContext(observer, parsers, recursive) {
410
+ return {
411
+ observer,
412
+ parsers,
413
+ recursive,
414
+ progress: { scannedFolders: 0, failedFolders: 0 },
415
+ discoveredCount: { value: 0 }
416
+ };
417
+ }
418
+ async scanFolder(uri, folderName, ctx) {
419
+ const listing = await this.readFolder(uri, folderName, ctx);
420
+ if (!listing)
356
421
  return;
422
+ let entries = listing;
423
+ if (ctx.recursive && this.isNamesOnly(listing))
424
+ entries = await this.listRecursively(uri) ?? listing;
425
+ await this.scanListing(uri, folderName, entries, ctx);
426
+ }
427
+ async scanListing(uri, folderName, entries, ctx) {
428
+ this.logICloudPlaceholders(uri, entries);
429
+ ctx.progress.scannedFolders++;
430
+ this.emitScanProgress(ctx);
431
+ const { files, dirs } = await this.splitEntries(entries, ctx);
432
+ await this.announceRoutes(files, uri, folderName, ctx);
433
+ for (const dir of dirs) {
434
+ if (this.isCancelled || !ctx.recursive)
435
+ continue;
436
+ if (dir.listing)
437
+ await this.scanListing(dir.uri, dir.name, dir.listing, ctx);
438
+ else
439
+ await this.scanFolder(dir.uri, dir.name, ctx);
440
+ }
441
+ }
442
+ async announceRoutes(files, uri, folderName, ctx) {
443
+ const primaryFiles = files.filter(f => {
444
+ const ext = this.getExtension(f.name);
445
+ return ext && ext !== 'gpx' && ctx.parsers.isPrimaryExtension(ext);
446
+ });
447
+ for (const file of primaryFiles) {
448
+ if (!this.isCancelled) {
449
+ const routeAnnouncement = await this.buildDiscoveredRoute(file, files, uri, folderName, ctx.parsers);
450
+ ctx.discoveredCount.value++;
451
+ ctx.observer.emit('scan-result', routeAnnouncement);
452
+ this.scanResult.push(routeAnnouncement);
453
+ }
357
454
  }
455
+ }
456
+ logICloudPlaceholders(uri, entries) {
358
457
  const iCloudPlaceholders = entries.filter(e => /^\.(.+)\.icloud$/.test(e.name));
359
458
  if (iCloudPlaceholders.length > 0) {
360
- const firstName = iCloudPlaceholders[0].name;
361
459
  this.logEvent({
362
460
  message: 'iCloud placeholder files detected in folder',
363
461
  uri,
364
462
  count: iCloudPlaceholders.length,
365
- firstPlaceholder: firstName
463
+ firstPlaceholder: iCloudPlaceholders[0].name
366
464
  });
367
465
  }
368
- progress.scannedFolders++;
369
- observer.emit('scan-progress', { scannedFolders: progress.scannedFolders });
370
- const files = entries.filter(e => !e.isDirectory);
371
- const dirs = entries.filter(e => e.isDirectory);
372
- const primaryFiles = files.filter((f) => {
373
- const name = typeof (f) === 'string' ? f : f.name;
374
- const ext = this.getExtension(name);
375
- return ext && ext !== 'gpx' && parsers.isPrimaryExtension(ext);
376
- }).map((f) => {
377
- if (typeof f === 'string') {
378
- return {
379
- name: f,
380
- isDirectory: false,
381
- uri: this.getBindings().path.join(uri, f)
382
- };
383
- }
384
- else
385
- return f;
386
- });
387
- for (const file of primaryFiles) {
388
- if (!this.isCancelled) {
389
- const routeAnnouncement = await this.buildDiscoveredRoute(file, files, uri, folderName, parsers);
390
- discoveredCount.value++;
391
- observer.emit('scan-result', routeAnnouncement);
392
- this.scanResult.push(routeAnnouncement);
393
- }
466
+ }
467
+ async readFolder(uri, folderName, ctx) {
468
+ try {
469
+ const entries = await this.listEntries(uri);
470
+ if (entries)
471
+ return entries;
472
+ this.countFailedFolder(ctx, new Error('folder listing not available'), folderName);
394
473
  }
395
- for (const dir of dirs) {
396
- if (!this.isCancelled && recursive) {
397
- await this.scanFolder(dir.uri, dir.name, observer, parsers, progress, discoveredCount);
398
- }
474
+ catch (err) {
475
+ this.countFailedFolder(ctx, err, folderName);
399
476
  }
477
+ return undefined;
478
+ }
479
+ countFailedFolder(ctx, err, folderName) {
480
+ ctx.progress.failedFolders++;
481
+ this.logError(err, 'scanFolder', { folderName });
482
+ this.emitScanProgress(ctx);
483
+ }
484
+ emitScanProgress(ctx) {
485
+ const { scannedFolders, failedFolders } = ctx.progress;
486
+ ctx.observer.emit('scan-progress', { scannedFolders, failedFolders });
487
+ }
488
+ async listEntries(uri) {
489
+ const result = await this.getBindings().fs.readdir?.(uri, { recursive: false, extended: true });
490
+ if (!Array.isArray(result))
491
+ return null;
492
+ if (result.length === 0 || typeof result[0] !== 'string')
493
+ return result;
494
+ const path = this.getBindings().path;
495
+ return result.map(name => ({ name, uri: path.join(uri, name), unknownType: true }));
496
+ }
497
+ isNamesOnly(entries) {
498
+ return entries.some(e => e.unknownType);
499
+ }
500
+ async splitEntries(entries, ctx) {
501
+ if (!this.isNamesOnly(entries)) {
502
+ return {
503
+ files: entries.filter(e => !e.isDirectory),
504
+ dirs: entries.filter(e => e.isDirectory)
505
+ };
506
+ }
507
+ return this.resolveNamesOnly(entries, ctx);
508
+ }
509
+ async resolveNamesOnly(entries, ctx) {
510
+ const files = [];
511
+ const dirs = [];
512
+ const knownFileExts = this.getKnownFileExtensions(entries, ctx.parsers);
513
+ for (const entry of entries) {
514
+ const needsProbe = ctx.recursive && !this.isCancelled && !knownFileExts.has(this.getExtension(entry.name));
515
+ const listing = needsProbe ? await this.probeEntry(entry, ctx) : undefined;
516
+ if (listing)
517
+ dirs.push({ name: entry.name, uri: entry.uri, isDirectory: true, listing });
518
+ else if (listing === undefined)
519
+ files.push({ name: entry.name, uri: entry.uri, isDirectory: false });
520
+ }
521
+ return { files, dirs };
522
+ }
523
+ async probeEntry(entry, ctx) {
524
+ try {
525
+ return (await this.listEntries(entry.uri)) ?? undefined;
526
+ }
527
+ catch (err) {
528
+ if (!this.isUnreadableFolderError(err))
529
+ return undefined;
530
+ this.countFailedFolder(ctx, err, entry.name);
531
+ return null;
532
+ }
533
+ }
534
+ isUnreadableFolderError(err) {
535
+ const { code, message } = (err ?? {});
536
+ const text = [code, message].filter(v => typeof v === 'string').join(' ');
537
+ return UNREADABLE_FOLDER_ERRORS.test(text);
538
+ }
539
+ getKnownFileExtensions(entries, parsers) {
540
+ const known = new Set(VIDEO_EXTENSIONS);
541
+ for (const entry of entries) {
542
+ const ext = this.getExtension(entry.name);
543
+ if (!ext || !parsers.isPrimaryExtension(ext))
544
+ continue;
545
+ known.add(ext);
546
+ for (const companion of this.getCompanionExts(parsers, ext))
547
+ known.add(companion.toLowerCase());
548
+ }
549
+ return known;
550
+ }
551
+ async listRecursively(uri) {
552
+ let result;
553
+ try {
554
+ result = await this.getBindings().fs.readdir?.(uri, { recursive: true });
555
+ }
556
+ catch {
557
+ return undefined;
558
+ }
559
+ if (!Array.isArray(result) || !result.every(p => typeof p === 'string'))
560
+ return undefined;
561
+ const paths = result;
562
+ if (!paths.some(p => PATH_SEPARATOR.test(p)))
563
+ return undefined;
564
+ return this.buildListingTree(uri, paths);
565
+ }
566
+ buildListingTree(rootUri, paths) {
567
+ const normalised = paths.map(p => p.split(PATH_SEPARATOR).join('/'));
568
+ const listed = new Set(normalised);
569
+ const children = new Map();
570
+ for (const relPath of normalised) {
571
+ const cut = relPath.lastIndexOf('/');
572
+ const parent = cut < 0 ? '' : relPath.slice(0, cut);
573
+ if (parent && !listed.has(parent))
574
+ return undefined;
575
+ const siblings = children.get(parent) ?? [];
576
+ siblings.push(relPath.slice(cut + 1));
577
+ children.set(parent, siblings);
578
+ }
579
+ const path = this.getBindings().path;
580
+ const toListing = (relDir, dirUri) => (children.get(relDir) ?? []).map(name => {
581
+ const relPath = relDir ? `${relDir}/${name}` : name;
582
+ const entryUri = path.join(dirUri, name);
583
+ return children.has(relPath)
584
+ ? { name, uri: entryUri, isDirectory: true, listing: toListing(relPath, entryUri) }
585
+ : { name, uri: entryUri, isDirectory: false };
586
+ });
587
+ return toListing('', rootUri);
400
588
  }
401
589
  async buildDiscoveredRoute(controlFile, folderFiles, folderUri, folderName, parsers) {
402
590
  const ext = this.getExtension(controlFile.name);
403
- const baseName = controlFile.name.slice(0, controlFile.name.length - ext.length - 1);
404
- let skipReason;
405
- const companionExts = this.getCompanionExts(parsers, ext);
406
- for (const compExt of companionExts) {
407
- const hasCompanion = folderFiles.some(f => this.getExtension(f.name).toLowerCase() === compExt.toLowerCase() &&
408
- f.name.toLowerCase().startsWith(baseName.toLowerCase()));
409
- if (!hasCompanion) {
410
- skipReason = `Missing companion file (.${compExt})`;
411
- break;
412
- }
413
- }
591
+ const baseName = this.getBaseName(controlFile.name);
592
+ const missingExt = this.findMissingCompanion(parsers, ext, baseName, folderFiles);
593
+ const skipReason = missingExt ? `Missing companion file (.${missingExt})` : undefined;
414
594
  return {
415
595
  folderUri,
416
596
  folderName,
@@ -431,6 +611,7 @@ let RouteLibraryScannerService = (() => {
431
611
  parseState: 'waiting',
432
612
  importable: false,
433
613
  label: file.base,
614
+ folder: target.folderName,
434
615
  id: target.controlFileUri,
435
616
  alreadyImported: false,
436
617
  observer: new types_1.Observer()
@@ -489,6 +670,7 @@ let RouteLibraryScannerService = (() => {
489
670
  alreadyImported: false,
490
671
  route,
491
672
  folderUri: target.folderUri,
673
+ folderName: target.folderName,
492
674
  controlFileUri: target.controlFileUri,
493
675
  format: target.format
494
676
  };
@@ -503,36 +685,16 @@ let RouteLibraryScannerService = (() => {
503
685
  alreadyImported: false,
504
686
  route: result ? new route_1.Route(result.data, result.details) : undefined,
505
687
  folderUri: target.folderUri,
688
+ folderName: target.folderName,
506
689
  controlFileUri: target.controlFileUri,
507
690
  format: target.format,
508
691
  parseError: scope.lastFailure ? this.getReadFailureMessage(scope) : (err?.message ?? String(err)),
509
- parseErrorCode: this.mapErrorToImportCode(err, scope)
692
+ parseErrorCode: (0, errors_1.mapErrorToImportCode)(err, scope)
510
693
  };
511
694
  this.logEvent({ message: 'could not parse route file', file: file.base, reason: err.message, stack: err.stack });
512
695
  observer.emit('parse-result', parsed);
513
696
  }
514
697
  }
515
- mapErrorToImportCode(err, scope) {
516
- switch (scope?.lastFailure?.reason) {
517
- case 'offline':
518
- return 'ICLOUD_OFFLINE';
519
- case 'timeout':
520
- case 'download-failed':
521
- return 'ICLOUD_DOWNLOAD_FAILED';
522
- case 'access-lost':
523
- return 'READ_FAILED';
524
- }
525
- const message = err?.message ?? String(err);
526
- if (/AVI/i.test(message))
527
- return 'AVI_NOT_SUPPORTED';
528
- if (/no video/i.test(message))
529
- return 'NO_VIDEO';
530
- if (/^Could not (open|read)/i.test(message))
531
- return 'READ_FAILED';
532
- if (/pars(e|ing)/i.test(message))
533
- return 'PARSE_FAILED';
534
- return 'UNSUPPORTED';
535
- }
536
698
  getReadFailureMessage(scope) {
537
699
  const name = this.getFileName(scope.failedFile);
538
700
  switch (scope.lastFailure?.reason) {
@@ -560,6 +722,7 @@ let RouteLibraryScannerService = (() => {
560
722
  validateVideoUrl(route, folderUri, folderFiles) {
561
723
  const { video } = route?.details ?? {};
562
724
  const { file, url, format } = video ?? {};
725
+ this.logEvent({ message: 'validateVideoUrl', file: this.getFileName(file), url: this.getFileName(url), format, folderFileCount: folderFiles.length });
563
726
  let videoFormat = format;
564
727
  try {
565
728
  if (videoFormat === undefined) {
@@ -642,7 +805,7 @@ let RouteLibraryScannerService = (() => {
642
805
  catch (err) {
643
806
  const reason = err?.message ?? String(err);
644
807
  errors++;
645
- failedRoutes.push({ name: route.title, reason, code: this.mapErrorToImportCode(err) });
808
+ failedRoutes.push({ name: route.title, reason, code: (0, errors_1.mapErrorToImportCode)(err) });
646
809
  observer.emit('ingest-error', { name: route.title, reason });
647
810
  }
648
811
  }
@@ -660,6 +823,7 @@ let RouteLibraryScannerService = (() => {
660
823
  catch (err) {
661
824
  this.logError(err, 'adoptPreview', { title: route?.title });
662
825
  }
826
+ await this.getRouteShapeStore().saveOnImport(route);
663
827
  await db.save(route, true);
664
828
  service.addRoute(route, existing ? 'import' : 'user');
665
829
  try {
@@ -679,6 +843,9 @@ let RouteLibraryScannerService = (() => {
679
843
  if (videoRef.startsWith('content://')) {
680
844
  return videoRef;
681
845
  }
846
+ if (!this.isMobile() && /^[a-z][a-z0-9+.-]*:\/\//i.test(videoRef)) {
847
+ return videoRef;
848
+ }
682
849
  if (videoRef.startsWith('/') || /^[A-Za-z]:[/\\]/.test(videoRef)) {
683
850
  if (this.isMobile())
684
851
  throw new Error('Absolute video path references are not supported');
@@ -691,24 +858,44 @@ let RouteLibraryScannerService = (() => {
691
858
  }
692
859
  async upsertImportHistory(folderInfo, routeCount) {
693
860
  try {
861
+ const treeUri = this.normaliseTreeUri(folderInfo.uri);
694
862
  const repo = json_1.JsonRepository.create('importedLibraries');
695
- const names = await repo.list();
696
- const all = await Promise.all((names ?? []).map(n => repo.read(n)));
697
- const existing = all
698
- .map(lib => lib)
699
- .find(lib => lib?.treeUri === folderInfo.uri);
863
+ const names = (await repo.list()) ?? [];
864
+ const all = await Promise.all(names.map(n => repo.read(n)));
865
+ const matching = names.filter((_, i) => {
866
+ const lib = all[i];
867
+ return typeof lib?.treeUri === 'string' && this.normaliseTreeUri(lib.treeUri) === treeUri;
868
+ });
869
+ const [kept, ...duplicates] = matching;
870
+ const existing = (kept === undefined ? undefined : all[names.indexOf(kept)]);
871
+ for (const duplicate of duplicates)
872
+ await repo.delete(duplicate);
700
873
  const id = existing?.id ?? (0, uuid_1.v4)();
701
874
  await repo.write(id, {
702
875
  id,
703
- treeUri: folderInfo.uri,
876
+ treeUri,
704
877
  displayName: folderInfo.displayName,
705
878
  lastScanned: new Date().toISOString(),
706
879
  routeCount
707
880
  });
708
881
  }
709
882
  catch (err) {
710
- this.logError(err, 'upsertImportHistory', { uri: folderInfo.uri });
711
- }
883
+ this.logError(err, 'upsertImportHistory', { folder: folderInfo.displayName });
884
+ }
885
+ }
886
+ normaliseTreeUri(uri) {
887
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(uri))
888
+ return uri;
889
+ let end = uri.length;
890
+ while (end > 0 && (uri[end - 1] === '/' || uri[end - 1] === '\\'))
891
+ end--;
892
+ const trimmed = uri.slice(0, end);
893
+ const withDrive = trimmed.replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
894
+ if (withDrive === '')
895
+ return uri.slice(0, 1);
896
+ if (/^[A-Z]:$/.test(withDrive))
897
+ return withDrive + uri.charAt(2);
898
+ return withDrive;
712
899
  }
713
900
  buildFileInfo(uri, ext) {
714
901
  const { dir, base, name } = this.getBindings().path.parse(uri);
@@ -727,7 +914,7 @@ let RouteLibraryScannerService = (() => {
727
914
  return this.importProps.routes.find(r => r.id === parsed.controlFileUri);
728
915
  }
729
916
  buildRouteDisplayItem(parsed, observer) {
730
- const { route, alreadyImported, parseError, format, controlFileUri } = parsed;
917
+ const { route, alreadyImported, parseError, format, controlFileUri, folderName } = parsed;
731
918
  const descr = route?.description ?? {};
732
919
  const [C, U] = this.getUnitConversionShortcuts();
733
920
  const distance = descr.distance === undefined ? undefined : {
@@ -741,6 +928,7 @@ let RouteLibraryScannerService = (() => {
741
928
  id: route?.description?.id ?? info?.base,
742
929
  distance,
743
930
  label: route?.title ?? info?.base,
931
+ folder: folderName,
744
932
  alreadyImported,
745
933
  parseState: importProps?.parseState ?? 'waiting',
746
934
  importable: parseError == null,
@@ -760,6 +948,14 @@ let RouteLibraryScannerService = (() => {
760
948
  return [];
761
949
  }
762
950
  }
951
+ findMissingCompanion(parsers, primaryExt, baseName, folderFiles) {
952
+ return this.getCompanionExts(parsers, primaryExt).find(compExt => !folderFiles.some(f => this.getExtension(f.name).toLowerCase() === compExt.toLowerCase() &&
953
+ f.name.toLowerCase().startsWith(baseName.toLowerCase())));
954
+ }
955
+ getBaseName(filename) {
956
+ const ext = this.getExtension(filename);
957
+ return filename.slice(0, filename.length - ext.length - 1);
958
+ }
763
959
  getExtension(filename) {
764
960
  const dot = filename.lastIndexOf('.');
765
961
  return dot >= 0 ? filename.slice(dot + 1).toLowerCase() : '';
@@ -779,6 +975,9 @@ let RouteLibraryScannerService = (() => {
779
975
  getPreviewStore() {
780
976
  return (0, store_1.usePreviewStore)();
781
977
  }
978
+ getRouteShapeStore() {
979
+ return (0, store_2.useRouteShapeStore)();
980
+ }
782
981
  getParsers() {
783
982
  return (0, parsers_1.useParsers)();
784
983
  }