filepizza-client 0.1.0

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.
@@ -0,0 +1,687 @@
1
+ // src/filepizza-downloader.ts
2
+ import Peer from 'peerjs';
3
+ import { EventEmitter } from './event-emitter';
4
+ import { DownloadHelper } from './download-helper';
5
+ import { ConnectionStatus, MessageType } from './types';
6
+ /**
7
+ * FilePizza Downloader - connects to FilePizza uploads
8
+ */
9
+ export class FilePizzaDownloader extends EventEmitter {
10
+ /**
11
+ * Create a new FilePizza downloader
12
+ * @param options Configuration options
13
+ */
14
+ constructor(options = {}) {
15
+ super();
16
+ this.filesInfo = [];
17
+ this.currentFileIndex = 0;
18
+ this.currentFileBytesReceived = 0;
19
+ this.totalBytesReceived = 0;
20
+ this.totalBytes = 0;
21
+ this.status = ConnectionStatus.Pending;
22
+ this.fileStreams = new Map();
23
+ this.isPasswordRequired = false;
24
+ this.isPasswordInvalid = false;
25
+ this.completedFiles = [];
26
+ this.filePizzaServerUrl = options.filePizzaServerUrl || 'http://localhost:8081';
27
+ }
28
+ /**
29
+ * Initialize the downloader
30
+ */
31
+ async initialize() {
32
+ if (this.peer) {
33
+ return;
34
+ }
35
+ // Get ICE servers
36
+ await this.getIceServers();
37
+ // Initialize PeerJS
38
+ this.peer = new Peer({
39
+ config: {
40
+ iceServers: this.iceServers || [{ urls: 'stun:stun.l.google.com:19302' }],
41
+ },
42
+ debug: 2,
43
+ });
44
+ // Wait for peer to be ready
45
+ if (!this.peer.id) {
46
+ await new Promise((resolve) => {
47
+ const onOpen = () => {
48
+ this.peer?.off('open', onOpen);
49
+ resolve();
50
+ };
51
+ this.peer?.on('open', onOpen);
52
+ });
53
+ }
54
+ }
55
+ /**
56
+ * Connect to an uploader using a FilePizza URL or slug
57
+ */
58
+ async connect(urlOrSlug) {
59
+ // Extract slug from URL if needed
60
+ const slug = this.extractSlug(urlOrSlug);
61
+ try {
62
+ // Look up the uploader's peer ID
63
+ const uploaderPeerID = await this.lookupUploaderPeerID(slug);
64
+ // Now connect directly to the uploader
65
+ return this.connectToPeer(uploaderPeerID);
66
+ }
67
+ catch (error) {
68
+ this.errorMessage = `Failed to connect: ${error instanceof Error ? error.message : String(error)}`;
69
+ this.emit('error', this.errorMessage);
70
+ return false;
71
+ }
72
+ }
73
+ /**
74
+ * Submit password for protected download
75
+ */
76
+ submitPassword(password) {
77
+ if (!this.connection || this.status !== ConnectionStatus.Authenticating) {
78
+ throw new Error('Not in authentication state');
79
+ }
80
+ this.connection.send({
81
+ type: MessageType.UsePassword,
82
+ password,
83
+ });
84
+ }
85
+ /**
86
+ * Start downloading the files
87
+ */
88
+ async startDownload() {
89
+ // This is just a stub - actual implementation will depend on how you want to save files
90
+ // The real FilePizza implementation uses the StreamSaver library
91
+ if (!this.connection) {
92
+ throw new Error('Not connected');
93
+ }
94
+ if (this.filesInfo.length === 0) {
95
+ throw new Error('No files available');
96
+ }
97
+ if (this.status !== ConnectionStatus.Ready) {
98
+ throw new Error(`Cannot start download in current state: ${this.status}`);
99
+ }
100
+ this.status = ConnectionStatus.Downloading;
101
+ this.currentFileIndex = 0;
102
+ this.currentFileBytesReceived = 0;
103
+ this.totalBytesReceived = 0;
104
+ // Initialize file streams
105
+ this.initializeFileStreams();
106
+ // Request the first file
107
+ this.requestNextFile();
108
+ }
109
+ /**
110
+ * Pause the download
111
+ */
112
+ pauseDownload() {
113
+ if (!this.connection || this.status !== ConnectionStatus.Downloading) {
114
+ return;
115
+ }
116
+ this.connection.send({ type: MessageType.Pause });
117
+ this.status = ConnectionStatus.Paused;
118
+ this.emit('paused');
119
+ }
120
+ /**
121
+ * Resume the download
122
+ */
123
+ resumeDownload() {
124
+ if (!this.connection || this.status !== ConnectionStatus.Paused) {
125
+ return;
126
+ }
127
+ const currentFile = this.filesInfo[this.currentFileIndex];
128
+ this.connection.send({
129
+ type: MessageType.Resume,
130
+ fileName: currentFile.fileName,
131
+ offset: this.currentFileBytesReceived,
132
+ });
133
+ this.status = ConnectionStatus.Downloading;
134
+ this.emit('resumed');
135
+ }
136
+ /**
137
+ * Cancel the download
138
+ */
139
+ /**
140
+ * Cancel the download
141
+ */
142
+ cancelDownload() {
143
+ // Close all file streams
144
+ for (const fileStreamData of this.fileStreams.values()) {
145
+ // Check if the stream is already closed before closing it
146
+ if (fileStreamData.stream.locked) {
147
+ try {
148
+ fileStreamData.close();
149
+ }
150
+ catch (error) {
151
+ console.warn('Error closing stream:', error);
152
+ }
153
+ }
154
+ }
155
+ this.fileStreams.clear();
156
+ // Close the connection
157
+ if (this.connection) {
158
+ if (this.connection.open) {
159
+ this.connection.close();
160
+ }
161
+ this.connection = undefined;
162
+ }
163
+ this.status = ConnectionStatus.Closed;
164
+ this.emit('cancelled');
165
+ }
166
+ /**
167
+ * Get file information
168
+ */
169
+ getFileInfo() {
170
+ return this.filesInfo;
171
+ }
172
+ /**
173
+ * Get download status
174
+ */
175
+ getStatus() {
176
+ return {
177
+ status: this.status,
178
+ isPasswordRequired: this.isPasswordRequired,
179
+ isPasswordInvalid: this.isPasswordInvalid,
180
+ errorMessage: this.errorMessage,
181
+ };
182
+ }
183
+ /**
184
+ * Get progress information
185
+ */
186
+ getProgress() {
187
+ return {
188
+ fileIndex: this.currentFileIndex,
189
+ fileName: this.filesInfo[this.currentFileIndex]?.fileName || '',
190
+ totalFiles: this.filesInfo.length,
191
+ currentFileProgress: this.currentFileBytesReceived /
192
+ (this.filesInfo[this.currentFileIndex]?.size || 1),
193
+ overallProgress: this.totalBytesReceived / (this.totalBytes || 1),
194
+ bytesTransferred: this.totalBytesReceived,
195
+ totalBytes: this.totalBytes,
196
+ };
197
+ }
198
+ /**
199
+ * Extract slug from URL or use directly
200
+ */
201
+ extractSlug(urlOrSlug) {
202
+ // Check if it's a URL
203
+ if (urlOrSlug.startsWith('http')) {
204
+ const url = new URL(urlOrSlug);
205
+ const pathParts = url.pathname.split('/').filter(Boolean);
206
+ // Extract the download slug
207
+ if (pathParts[0] === 'download' && pathParts.length > 1) {
208
+ return pathParts.slice(1).join('/');
209
+ }
210
+ else {
211
+ throw new Error('Invalid FilePizza URL');
212
+ }
213
+ }
214
+ // If it's not a URL, assume it's already a slug
215
+ return urlOrSlug;
216
+ }
217
+ /**
218
+ * Extract uploader peer IDs from the FilePizza server when multiple uploaders are present
219
+ */
220
+ async extractUploaderPeerIDs(slug) {
221
+ try {
222
+ const response = await fetch(`${this.filePizzaServerUrl}/download/${slug}`);
223
+ if (!response.ok) {
224
+ throw new Error(`FilePizza server returned ${response.status}`);
225
+ }
226
+ const html = await response.text();
227
+ if (!html || html.trim() === '') {
228
+ throw new Error('Received empty response from server');
229
+ }
230
+ // Match either "uploaderPeerID" (for single uploader) or "primaryUploaderID" and "additionalUploaders" (for multiple)
231
+ const primaryMatch = html.match(/\\"primaryUploaderID\\":\\"([^\\]+)\\"/);
232
+ const singleMatch = html.match(/\\"uploaderPeerID\\":\\"([^\\]+)\\"/);
233
+ if (primaryMatch && primaryMatch[1]) {
234
+ // Handle multiple uploader case
235
+ const uploaderIDs = [primaryMatch[1]];
236
+ // Look for additional uploaders
237
+ const additionalMatch = html.match(/\\"additionalUploaders\\":\[([^\]]+)\]/);
238
+ if (additionalMatch && additionalMatch[1]) {
239
+ // Parse additional uploader IDs from JSON string
240
+ const additionalIDs = additionalMatch[1].split(',')
241
+ .map(id => id.trim().replace(/\\"|"/g, ''))
242
+ .filter(id => id.length > 0);
243
+ uploaderIDs.push(...additionalIDs);
244
+ }
245
+ return uploaderIDs;
246
+ }
247
+ else if (singleMatch && singleMatch[1]) {
248
+ // Handle single uploader case
249
+ return [singleMatch[1]];
250
+ }
251
+ throw new Error('Could not find uploader peer ID');
252
+ }
253
+ catch (error) {
254
+ console.error('Error extracting uploader peer IDs:', error);
255
+ throw new Error('Failed to look up uploader information');
256
+ }
257
+ }
258
+ /**
259
+ * Look up the uploader's peer ID from the FilePizza server
260
+ */
261
+ async lookupUploaderPeerID(slug) {
262
+ try {
263
+ const uploaderIDs = await this.extractUploaderPeerIDs(slug);
264
+ if (uploaderIDs.length === 0) {
265
+ throw new Error('No uploader peer IDs found');
266
+ }
267
+ // Return the first uploader ID by default
268
+ return uploaderIDs[0];
269
+ }
270
+ catch (error) {
271
+ console.error('Error looking up uploader peer ID:', error);
272
+ throw new Error('Failed to look up uploader information');
273
+ }
274
+ }
275
+ /**
276
+ * Connect to a specific uploader by ID
277
+ */
278
+ async getAvailableUploaders(urlOrSlug) {
279
+ const slug = this.extractSlug(urlOrSlug);
280
+ return this.extractUploaderPeerIDs(slug);
281
+ }
282
+ /**
283
+ * Connect to a specific uploader by ID
284
+ */
285
+ async connectToUploader(uploaderId) {
286
+ try {
287
+ return this.connectToPeer(uploaderId);
288
+ }
289
+ catch (error) {
290
+ this.errorMessage = `Failed to connect to uploader: ${error instanceof Error ? error.message : String(error)}`;
291
+ this.emit('error', this.errorMessage);
292
+ return false;
293
+ }
294
+ }
295
+ /**
296
+ * Get ICE servers from the FilePizza server
297
+ */
298
+ async getIceServers() {
299
+ try {
300
+ const response = await fetch(`${this.filePizzaServerUrl}/api/ice`, {
301
+ method: 'POST',
302
+ });
303
+ if (!response.ok) {
304
+ throw new Error(`Failed to get ICE servers: ${response.status}`);
305
+ }
306
+ const data = await response.json();
307
+ this.iceServers = data.iceServers;
308
+ return data.iceServers;
309
+ }
310
+ catch (error) {
311
+ console.error('Error getting ICE servers:', error);
312
+ return [{ urls: 'stun:stun.l.google.com:19302' }];
313
+ }
314
+ }
315
+ /**
316
+ * Connect directly to a peer
317
+ */
318
+ async connectToPeer(peerId) {
319
+ // Make sure we're initialized
320
+ await this.initialize();
321
+ if (!this.peer) {
322
+ throw new Error('Peer not initialized');
323
+ }
324
+ try {
325
+ // Close existing connection if any
326
+ if (this.connection) {
327
+ this.connection.close();
328
+ }
329
+ // Connect to the uploader
330
+ this.connection = this.peer.connect(peerId, { reliable: true });
331
+ this.status = ConnectionStatus.Pending;
332
+ // Set up connection event handlers
333
+ return new Promise((resolve) => {
334
+ if (!this.connection) {
335
+ resolve(false);
336
+ return;
337
+ }
338
+ this.connection.on('open', () => {
339
+ this.status = ConnectionStatus.Ready;
340
+ // Send request for file info
341
+ if (this.connection) {
342
+ this.connection.send({
343
+ type: MessageType.RequestInfo,
344
+ browserName: this.getBrowserName(),
345
+ browserVersion: this.getBrowserVersion(),
346
+ osName: this.getOSName(),
347
+ osVersion: this.getOSVersion(),
348
+ mobileVendor: this.getMobileVendor(),
349
+ mobileModel: this.getMobileModel(),
350
+ });
351
+ }
352
+ this.emit('connected');
353
+ resolve(true);
354
+ });
355
+ this.connection.on('data', this.handleData.bind(this));
356
+ this.connection.on('close', () => {
357
+ this.status = ConnectionStatus.Closed;
358
+ this.emit('disconnected');
359
+ });
360
+ this.connection.on('error', (error) => {
361
+ this.errorMessage = `Connection error: ${error.message}`;
362
+ this.status = ConnectionStatus.Error;
363
+ this.emit('error', this.errorMessage);
364
+ });
365
+ });
366
+ }
367
+ catch (error) {
368
+ this.errorMessage = `Failed to connect: ${error instanceof Error ? error.message : String(error)}`;
369
+ this.emit('error', this.errorMessage);
370
+ return false;
371
+ }
372
+ }
373
+ /**
374
+ * Handle incoming data from uploader
375
+ */
376
+ handleData(data) {
377
+ try {
378
+ // WebRTC messages follow a specific format with a type field
379
+ const message = data;
380
+ switch (message.type) {
381
+ case MessageType.PasswordRequired:
382
+ this.handlePasswordRequired(message);
383
+ break;
384
+ case MessageType.Info:
385
+ this.handleInfo(message);
386
+ break;
387
+ case MessageType.Chunk:
388
+ this.handleChunk(message);
389
+ break;
390
+ case MessageType.Error:
391
+ this.handleError(message);
392
+ break;
393
+ case MessageType.Report:
394
+ this.handleReport();
395
+ break;
396
+ }
397
+ }
398
+ catch (error) {
399
+ console.error('Error handling message:', error);
400
+ this.errorMessage = `Error processing data: ${error instanceof Error ? error.message : String(error)}`;
401
+ this.emit('error', this.errorMessage);
402
+ }
403
+ }
404
+ /**
405
+ * Handle password required message
406
+ */
407
+ handlePasswordRequired(message) {
408
+ this.isPasswordRequired = true;
409
+ this.status = ConnectionStatus.Authenticating;
410
+ if (message.errorMessage) {
411
+ this.errorMessage = message.errorMessage;
412
+ this.isPasswordInvalid = true;
413
+ this.emit('passwordInvalid', message.errorMessage);
414
+ }
415
+ else {
416
+ this.emit('passwordRequired');
417
+ }
418
+ }
419
+ /**
420
+ * Handle file info message
421
+ */
422
+ handleInfo(message) {
423
+ this.filesInfo = message.files;
424
+ this.totalBytes = this.filesInfo.reduce((sum, file) => sum + file.size, 0);
425
+ this.isPasswordRequired = false;
426
+ this.isPasswordInvalid = false;
427
+ this.status = ConnectionStatus.Ready;
428
+ this.emit('info', this.filesInfo);
429
+ }
430
+ /**
431
+ * Handle error message
432
+ */
433
+ handleError(message) {
434
+ this.errorMessage = message.error;
435
+ this.status = ConnectionStatus.Error;
436
+ this.emit('error', this.errorMessage);
437
+ }
438
+ /**
439
+ * Handle report message (channel reported for violation)
440
+ */
441
+ handleReport() {
442
+ this.emit('reported');
443
+ // Redirect to reported page if in browser
444
+ if (typeof window !== 'undefined') {
445
+ window.location.href = `${this.filePizzaServerUrl}/reported`;
446
+ }
447
+ }
448
+ /**
449
+ * Handle chunk message
450
+ */
451
+ handleChunk(message) {
452
+ const { fileName, bytes, final } = message;
453
+ const fileStream = this.fileStreams.get(fileName);
454
+ if (!fileStream) {
455
+ console.error(`No stream found for file: ${fileName}`);
456
+ return;
457
+ }
458
+ // Convert bytes to Uint8Array if needed
459
+ const data = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
460
+ // Update progress
461
+ this.currentFileBytesReceived += data.byteLength;
462
+ this.totalBytesReceived += data.byteLength;
463
+ // Add to file stream
464
+ fileStream.enqueue(data);
465
+ // Emit progress
466
+ this.emit('progress', this.getProgress());
467
+ // Handle file completion
468
+ if (final) {
469
+ // Close this file's stream
470
+ fileStream.close();
471
+ // Store the completed file
472
+ this.storeCompletedFile(fileName);
473
+ // Move to next file if available
474
+ this.currentFileIndex++;
475
+ this.currentFileBytesReceived = 0;
476
+ if (this.currentFileIndex < this.filesInfo.length) {
477
+ this.requestNextFile();
478
+ }
479
+ else {
480
+ this.status = ConnectionStatus.Done;
481
+ this.emit('complete', this.completedFiles);
482
+ }
483
+ }
484
+ }
485
+ /**
486
+ * Initialize streams for all files
487
+ */
488
+ initializeFileStreams() {
489
+ this.fileStreams.clear();
490
+ for (const fileInfo of this.filesInfo) {
491
+ let enqueue = null;
492
+ let close = null;
493
+ const stream = new ReadableStream({
494
+ start(controller) {
495
+ enqueue = (chunk) => controller.enqueue(chunk);
496
+ close = () => controller.close();
497
+ },
498
+ });
499
+ if (!enqueue || !close) {
500
+ throw new Error('Failed to initialize stream controllers');
501
+ }
502
+ this.fileStreams.set(fileInfo.fileName, {
503
+ stream,
504
+ enqueue,
505
+ close,
506
+ });
507
+ }
508
+ }
509
+ /**
510
+ * Store a completed file
511
+ */
512
+ async storeCompletedFile(fileName) {
513
+ const fileStream = this.fileStreams.get(fileName);
514
+ const fileInfo = this.filesInfo.find(info => info.fileName === fileName);
515
+ if (!fileStream || !fileInfo) {
516
+ console.error(`No stream or file info found for file: ${fileName}`);
517
+ return;
518
+ }
519
+ try {
520
+ // Clone the stream since we're going to use it
521
+ const [streamToRead, streamToStore] = fileStream.stream.tee();
522
+ // Convert stream to Uint8Array
523
+ const fileData = await DownloadHelper.streamToUint8Array(streamToRead);
524
+ // Store the completed file
525
+ const completedFile = {
526
+ ...fileInfo,
527
+ data: fileData,
528
+ };
529
+ this.completedFiles.push(completedFile);
530
+ // Emit fileComplete event
531
+ this.emit('fileComplete', completedFile);
532
+ }
533
+ catch (error) {
534
+ console.error(`Error storing file ${fileName}:`, error);
535
+ this.emit('error', `Failed to store file: ${error.message}`);
536
+ }
537
+ }
538
+ /**
539
+ * Download a completed file
540
+ */
541
+ async downloadFile(fileName) {
542
+ const completedFile = this.completedFiles.find(file => file.fileName === fileName);
543
+ if (!completedFile) {
544
+ throw new Error(`File not found: ${fileName}`);
545
+ }
546
+ try {
547
+ await DownloadHelper.downloadFile(fileName, completedFile.data);
548
+ }
549
+ catch (error) {
550
+ console.error(`Error downloading file ${fileName}:`, error);
551
+ throw new Error(`Failed to download file: ${error.message}`);
552
+ }
553
+ }
554
+ /**
555
+ * Get completed files
556
+ */
557
+ getCompletedFiles() {
558
+ return [...this.completedFiles];
559
+ }
560
+ /**
561
+ * Download all completed files
562
+ */
563
+ async downloadAllFiles() {
564
+ for (const file of this.completedFiles) {
565
+ try {
566
+ await this.downloadFile(file.fileName);
567
+ }
568
+ catch (error) {
569
+ console.error(`Error downloading file ${file.fileName}:`, error);
570
+ // Continue with other files even if one fails
571
+ }
572
+ }
573
+ }
574
+ /**
575
+ * Request the next file
576
+ */
577
+ requestNextFile() {
578
+ if (!this.connection || this.currentFileIndex >= this.filesInfo.length) {
579
+ return;
580
+ }
581
+ const nextFile = this.filesInfo[this.currentFileIndex];
582
+ this.connection.send({
583
+ type: MessageType.Start,
584
+ fileName: nextFile.fileName,
585
+ offset: 0,
586
+ });
587
+ }
588
+ // Browser detection helper methods
589
+ getBrowserName() {
590
+ if (typeof navigator === 'undefined')
591
+ return 'unknown';
592
+ const ua = navigator.userAgent;
593
+ if (ua.includes('Firefox'))
594
+ return 'Firefox';
595
+ if (ua.includes('Chrome'))
596
+ return 'Chrome';
597
+ if (ua.includes('Safari'))
598
+ return 'Safari';
599
+ if (ua.includes('Edge'))
600
+ return 'Edge';
601
+ if (ua.includes('MSIE') || ua.includes('Trident/'))
602
+ return 'IE';
603
+ return 'unknown';
604
+ }
605
+ getBrowserVersion() {
606
+ if (typeof navigator === 'undefined')
607
+ return 'unknown';
608
+ const ua = navigator.userAgent;
609
+ let match;
610
+ if ((match = ua.match(/(Firefox|Chrome|Safari|Edge|MSIE)\/(\d+\.\d+)/))) {
611
+ return match[2];
612
+ }
613
+ if ((match = ua.match(/rv:(\d+\.\d+)/))) {
614
+ return match[1];
615
+ }
616
+ return 'unknown';
617
+ }
618
+ getOSName() {
619
+ if (typeof navigator === 'undefined')
620
+ return 'unknown';
621
+ const ua = navigator.userAgent;
622
+ if (ua.includes('Windows'))
623
+ return 'Windows';
624
+ if (ua.includes('Mac OS X'))
625
+ return 'macOS';
626
+ if (ua.includes('Linux'))
627
+ return 'Linux';
628
+ if (ua.includes('Android'))
629
+ return 'Android';
630
+ if (ua.includes('iOS'))
631
+ return 'iOS';
632
+ return 'unknown';
633
+ }
634
+ getOSVersion() {
635
+ if (typeof navigator === 'undefined')
636
+ return 'unknown';
637
+ const ua = navigator.userAgent;
638
+ let match;
639
+ if ((match = ua.match(/Windows NT (\d+\.\d+)/))) {
640
+ return match[1];
641
+ }
642
+ if ((match = ua.match(/Mac OS X (\d+[._]\d+)/))) {
643
+ return match[1].replace('_', '.');
644
+ }
645
+ if ((match = ua.match(/Android (\d+\.\d+)/))) {
646
+ return match[1];
647
+ }
648
+ if ((match = ua.match(/iPhone OS (\d+_\d+)/))) {
649
+ return match[1].replace('_', '.');
650
+ }
651
+ return 'unknown';
652
+ }
653
+ getMobileVendor() {
654
+ if (typeof navigator === 'undefined')
655
+ return '';
656
+ const ua = navigator.userAgent;
657
+ if (ua.includes('iPhone') || ua.includes('iPad'))
658
+ return 'Apple';
659
+ if (ua.includes('Samsung'))
660
+ return 'Samsung';
661
+ if (ua.includes('Pixel'))
662
+ return 'Google';
663
+ if (ua.includes('Huawei'))
664
+ return 'Huawei';
665
+ return '';
666
+ }
667
+ getMobileModel() {
668
+ if (typeof navigator === 'undefined')
669
+ return '';
670
+ const ua = navigator.userAgent;
671
+ let match;
672
+ if ((match = ua.match(/iPhone(\d+),(\d+)/))) {
673
+ return `iPhone ${match[1]}`;
674
+ }
675
+ if ((match = ua.match(/iPad(\d+),(\d+)/))) {
676
+ return `iPad ${match[1]}`;
677
+ }
678
+ if ((match = ua.match(/SM-\w+/))) {
679
+ return match[0];
680
+ }
681
+ if ((match = ua.match(/Pixel \d+/))) {
682
+ return match[0];
683
+ }
684
+ return '';
685
+ }
686
+ }
687
+ //# sourceMappingURL=filepizza-downloader.js.map