Passed
Push — master ( 58f3b1...bab9fc )
by
unknown
05:56 queued 02:54
created

Client::download()   B

Complexity

Conditions 7
Paths 16

Size

Total Lines 27
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 7.0119

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 17
c 2
b 0
f 0
dl 0
loc 27
ccs 15
cts 16
cp 0.9375
rs 8.8333
cc 7
nc 16
nop 1
crap 7.0119
1
<?php
2
3
namespace BackblazeB2;
4
5
use BackblazeB2\Exceptions\B2Exception;
6
use BackblazeB2\Exceptions\NotFoundException;
7
use BackblazeB2\Exceptions\ValidationException;
8
use BackblazeB2\Http\Client as HttpClient;
9
use Carbon\Carbon;
10
use GuzzleHttp\Exception\GuzzleException;
11
12
class Client
13
{
14
    private const B2_API_BASE_URL = 'https://api.backblazeb2.com';
15
    private const B2_API_V1 = '/b2api/v1/';
16
    protected $accountId;
17
    protected $applicationKey;
18
    protected $authToken;
19
    protected $apiUrl;
20
    protected $downloadUrl;
21
    protected $client;
22
    protected $reAuthTime;
23
    protected $authTimeoutSeconds;
24
25
    /**
26
     * Accepts the account ID, application key and an optional array of options.
27
     *
28
     * @param $accountId
29
     * @param $applicationKey
30
     * @param array $options
31
     *
32
     * @throws \Exception
33
     */
34 29
    public function __construct($accountId, $applicationKey, array $options = [])
35
    {
36 29
        $this->accountId = $accountId;
37 29
        $this->applicationKey = $applicationKey;
38
39 29
        $this->authTimeoutSeconds = 12 * 60 * 60; // 12 hour default
40 29
        if (isset($options['auth_timeout_seconds'])) {
41 1
            $this->authTimeoutSeconds = $options['auth_timeout_seconds'];
42
        }
43
44
        // set reauthorize time to force an authentication to take place
45 29
        $this->reAuthTime = Carbon::now('UTC')->subSeconds($this->authTimeoutSeconds * 2);
46
47 29
        $this->client = new HttpClient(['exceptions' => false]);
48 29
        if (isset($options['client'])) {
49 29
            $this->client = $options['client'];
50
        }
51 29
    }
52
53
    /**
54
     * Create a bucket with the given name and type.
55
     *
56
     * @param array $options
57
     *
58
     * @throws ValidationException
59
     * @throws GuzzleException     If the request fails.
60
     * @throws B2Exception         If the B2 server replies with an error.
61
     *
62
     * @return Bucket
63
     */
64 5
    public function createBucket(array $options)
65
    {
66 5
        if (!in_array($options['BucketType'], [Bucket::TYPE_PUBLIC, Bucket::TYPE_PRIVATE])) {
67 1
            throw new ValidationException(
68 1
                sprintf('Bucket type must be %s or %s', Bucket::TYPE_PRIVATE, Bucket::TYPE_PUBLIC)
69
            );
70
        }
71
72 4
        $response = $this->sendAuthorizedRequest('POST', 'b2_create_bucket', [
73 4
            'accountId'  => $this->accountId,
74 4
            'bucketName' => $options['BucketName'],
75 4
            'bucketType' => $options['BucketType'],
76
        ]);
77
78 3
        return new Bucket($response['bucketId'], $response['bucketName'], $response['bucketType']);
79
    }
80
81
    /**
82
     * Updates the type attribute of a bucket by the given ID.
83
     *
84
     * @param array $options
85
     *
86
     * @throws ValidationException
87
     * @throws GuzzleException     If the request fails.
88
     * @throws B2Exception         If the B2 server replies with an error.
89
     *
90
     * @return Bucket
91
     */
92 2
    public function updateBucket(array $options)
93
    {
94 2
        if (!in_array($options['BucketType'], [Bucket::TYPE_PUBLIC, Bucket::TYPE_PRIVATE])) {
95
            throw new ValidationException(
96
                sprintf('Bucket type must be %s or %s', Bucket::TYPE_PRIVATE, Bucket::TYPE_PUBLIC)
97
            );
98
        }
99
100 2
        if (!isset($options['BucketId']) && isset($options['BucketName'])) {
101
            $options['BucketId'] = $this->getBucketIdFromName($options['BucketName']);
102
        }
103
104 2
        $response = $this->sendAuthorizedRequest('POST', 'b2_update_bucket', [
105 2
            'accountId'  => $this->accountId,
106 2
            'bucketId'   => $options['BucketId'],
107 2
            'bucketType' => $options['BucketType'],
108
        ]);
109
110 2
        return new Bucket($response['bucketId'], $response['bucketName'], $response['bucketType']);
111
    }
112
113
    /**
114
     * Returns a list of bucket objects representing the buckets on the account.
115
     *
116
     * @throws GuzzleException If the request fails.
117
     * @throws B2Exception     If the B2 server replies with an error.
118
     *
119
     * @return array
120
     */
121 2
    public function listBuckets()
122
    {
123 2
        $buckets = [];
124
125 2
        $response = $this->sendAuthorizedRequest('POST', 'b2_list_buckets', [
126 2
            'accountId' => $this->accountId,
127
        ]);
128
129 2
        foreach ($response['buckets'] as $bucket) {
130 1
            $buckets[] = new Bucket($bucket['bucketId'], $bucket['bucketName'], $bucket['bucketType']);
131
        }
132
133 2
        return $buckets;
134
    }
135
136
    /**
137
     * Deletes the bucket identified by its ID.
138
     *
139
     * @param array $options
140
     *
141
     * @throws GuzzleException If the request fails.
142
     * @throws B2Exception     If the B2 server replies with an error.
143
     *
144
     * @return bool
145
     */
146 3
    public function deleteBucket(array $options)
147
    {
148 3
        if (!isset($options['BucketId']) && isset($options['BucketName'])) {
149
            $options['BucketId'] = $this->getBucketIdFromName($options['BucketName']);
150
        }
151
152 3
        $this->sendAuthorizedRequest('POST', 'b2_delete_bucket', [
153 3
            'accountId' => $this->accountId,
154 3
            'bucketId'  => $options['BucketId'],
155
        ]);
156
157 1
        return true;
158
    }
159
160
    /**
161
     * Uploads a file to a bucket and returns a File object.
162
     *
163
     * @param array $options
164
     *
165
     * @throws GuzzleException If the request fails.
166
     * @throws B2Exception     If the B2 server replies with an error.
167
     *
168
     * @return File
169
     */
170 3
    public function upload(array $options)
171
    {
172
        // Clean the path if it starts with /.
173 3
        if (substr($options['FileName'], 0, 1) === '/') {
174
            $options['FileName'] = ltrim($options['FileName'], '/');
175
        }
176
177 3
        if (!isset($options['BucketId']) && isset($options['BucketName'])) {
178
            $options['BucketId'] = $this->getBucketIdFromName($options['BucketName']);
179
        }
180
181
        // Retrieve the URL that we should be uploading to.
182
183 3
        $response = $this->sendAuthorizedRequest('POST', 'b2_get_upload_url', [
184 3
            'bucketId' => $options['BucketId'],
185
        ]);
186
187 3
        $uploadEndpoint = $response['uploadUrl'];
188 3
        $uploadAuthToken = $response['authorizationToken'];
189
190 3
        if (is_resource($options['Body'])) {
191
            // We need to calculate the file's hash incrementally from the stream.
192 1
            $context = hash_init('sha1');
193 1
            hash_update_stream($context, $options['Body']);
194 1
            $hash = hash_final($context);
195
196
            // Similarly, we have to use fstat to get the size of the stream.
197 1
            $size = fstat($options['Body'])['size'];
198
199
            // Rewind the stream before passing it to the HTTP client.
200 1
            rewind($options['Body']);
201
        } else {
202
            // We've been given a simple string body, it's super simple to calculate the hash and size.
203 2
            $hash = sha1($options['Body']);
204 2
            $size = strlen($options['Body']);
205
        }
206
207 3
        if (!isset($options['FileLastModified'])) {
208 2
            $options['FileLastModified'] = round(microtime(true) * 1000);
209
        }
210
211 3
        if (!isset($options['FileContentType'])) {
212 2
            $options['FileContentType'] = 'b2/x-auto';
213
        }
214
215 3
        $customHeaders = $options['Headers'] ?? [];
216 3
        $response = $this->client->guzzleRequest('POST', $uploadEndpoint, [
217 3
            'headers' => array_merge([
218 3
                'Authorization'                      => $uploadAuthToken,
219 3
                'Content-Type'                       => $options['FileContentType'],
220 3
                'Content-Length'                     => $size,
221 3
                'X-Bz-File-Name'                     => $options['FileName'],
222 3
                'X-Bz-Content-Sha1'                  => $hash,
223 3
                'X-Bz-Info-src_last_modified_millis' => $options['FileLastModified'],
224 3
            ], $customHeaders),
225 3
            'body' => $options['Body'],
226
        ]);
227
228 3
        return new File(
229 3
            $response['fileId'],
230 3
            $response['fileName'],
231 3
            $response['contentSha1'],
232 3
            $response['contentLength'],
233 3
            $response['contentType'],
234 3
            $response['fileInfo']
235
        );
236
    }
237
238
    /**
239
     * Download a file from a B2 bucket.
240
     *
241
     * @param array $options
242
     *
243
     * @return bool
244
     */
245 7
    public function download(array $options)
246
    {
247 7
        if (!isset($options['FileId']) && !isset($options['BucketName']) && isset($options['BucketId'])) {
248
            $options['BucketName'] = $this->getBucketNameFromId($options['BucketId']);
249
        }
250
251 7
        $this->authorizeAccount();
252
253 7
        $requestUrl = null;
254 7
        $customHeaders = $options['Headers'] ?? [];
255
        $requestOptions = [
256 7
            'headers' => array_merge([
257 7
                'Authorization' => $this->authToken,
258 7
            ], $customHeaders),
259 7
            'sink' => isset($options['SaveAs']) ? $options['SaveAs'] : null,
260
        ];
261
262 7
        if (isset($options['FileId'])) {
263 4
            $requestOptions['query'] = ['fileId' => $options['FileId']];
264 4
            $requestUrl = $this->downloadUrl.'/b2api/v1/b2_download_file_by_id';
265
        } else {
266 3
            $requestUrl = sprintf('%s/file/%s/%s', $this->downloadUrl, $options['BucketName'], $options['FileName']);
267
        }
268
269 7
        $response = $this->client->guzzleRequest('GET', $requestUrl, $requestOptions, false);
270
271 5
        return isset($options['SaveAs']) ? true : $response;
272
    }
273
274
    /**
275
     * Retrieve a collection of File objects representing the files stored inside a bucket.
276
     *
277
     * @param array $options
278
     *
279
     * @throws GuzzleException If the request fails.
280
     * @throws B2Exception     If the B2 server replies with an error.
281
     *
282
     * @return array
283
     */
284 2
    public function listFiles(array $options)
285
    {
286
        // if FileName is set, we only attempt to retrieve information about that single file.
287 2
        $fileName = !empty($options['FileName']) ? $options['FileName'] : null;
288
289 2
        $nextFileName = null;
290 2
        $maxFileCount = 1000;
291
292 2
        $prefix = isset($options['Prefix']) ? $options['Prefix'] : '';
293 2
        $delimiter = isset($options['Delimiter']) ? $options['Delimiter'] : null;
294
295 2
        $files = [];
296
297 2
        if (!isset($options['BucketId']) && isset($options['BucketName'])) {
298
            $options['BucketId'] = $this->getBucketIdFromName($options['BucketName']);
299
        }
300
301 2
        if ($fileName) {
302
            $nextFileName = $fileName;
303
            $maxFileCount = 1;
304
        }
305
306 2
        $this->authorizeAccount();
307
308
        // B2 returns, at most, 1000 files per "page". Loop through the pages and compile an array of File objects.
309 2
        while (true) {
310 2
            $response = $this->sendAuthorizedRequest('POST', 'b2_list_file_names', [
311 2
                'bucketId'      => $options['BucketId'],
312 2
                'startFileName' => $nextFileName,
313 2
                'maxFileCount'  => $maxFileCount,
314 2
                'prefix'        => $prefix,
315 2
                'delimiter'     => $delimiter,
316
            ]);
317
318 2
            foreach ($response['files'] as $file) {
319
                // if we have a file name set, only retrieve information if the file name matches
320 1
                if (!$fileName || ($fileName === $file['fileName'])) {
321 1
                    $files[] = new File($file['fileId'], $file['fileName'], null, $file['size']);
322
                }
323
            }
324
325 2
            if ($fileName || $response['nextFileName'] === null) {
326
                // We've got all the files - break out of loop.
327 2
                break;
328
            }
329
330 1
            $nextFileName = $response['nextFileName'];
331
        }
332
333 2
        return $files;
334
    }
335
336
    /**
337
     * Test whether a file exists in B2 for the given bucket.
338
     *
339
     * @param array $options
340
     *
341
     * @return bool
342
     */
343
    public function fileExists(array $options)
344
    {
345
        $files = $this->listFiles($options);
346
347
        return !empty($files);
348
    }
349
350
    /**
351
     * Returns a single File object representing a file stored on B2.
352
     *
353
     * @param array $options
354
     *
355
     * @throws GuzzleException
356
     * @throws NotFoundException If no file id was provided and BucketName + FileName does not resolve to a file, a NotFoundException is thrown.
357
     * @throws GuzzleException   If the request fails.
358
     * @throws B2Exception       If the B2 server replies with an error.
359
     *
360
     * @return File
361
     */
362 4
    public function getFile(array $options)
363
    {
364 4
        if (!isset($options['FileId']) && isset($options['BucketName']) && isset($options['FileName'])) {
365
            $options['FileId'] = $this->getFileIdFromBucketAndFileName($options['BucketName'], $options['FileName']);
366
367
            if (!$options['FileId']) {
368
                throw new NotFoundException();
369
            }
370
        }
371
372 4
        $response = $this->sendAuthorizedRequest('POST', 'b2_get_file_info', [
373 4
            'fileId' => $options['FileId'],
374
        ]);
375
376 3
        return new File(
377 3
            $response['fileId'],
378 3
            $response['fileName'],
379 3
            $response['contentSha1'],
380 3
            $response['contentLength'],
381 3
            $response['contentType'],
382 3
            $response['fileInfo'],
383 3
            $response['bucketId'],
384 3
            $response['action'],
385 3
            $response['uploadTimestamp']
386
        );
387
    }
388
389
    /**
390
     * Deletes the file identified by ID from Backblaze B2.
391
     *
392
     * @param array $options
393
     *
394
     * @throws GuzzleException
395
     * @throws NotFoundException
396
     * @throws GuzzleException   If the request fails.
397
     * @throws B2Exception       If the B2 server replies with an error.
398
     *
399
     * @return bool
400
     */
401 3
    public function deleteFile(array $options)
402
    {
403 3
        if (!isset($options['FileName'])) {
404 2
            $file = $this->getFile($options);
405
406 2
            $options['FileName'] = $file->getName();
407
        }
408
409 3
        if (!isset($options['FileId']) && isset($options['BucketName']) && isset($options['FileName'])) {
410
            $file = $this->getFile($options);
411
412
            $options['FileId'] = $file->getId();
413
        }
414
415 3
        $this->sendAuthorizedRequest('POST', 'b2_delete_file_version', [
416 3
            'fileName' => $options['FileName'],
417 3
            'fileId'   => $options['FileId'],
418
        ]);
419
420 2
        return true;
421
    }
422
423
    /**
424
     * Authorize the B2 account in order to get an auth token and API/download URLs.
425
     */
426 28
    protected function authorizeAccount()
427
    {
428 28
        if (Carbon::now('UTC')->timestamp < $this->reAuthTime->timestamp) {
429 4
            return;
430
        }
431
432 28
        $response = $this->client->guzzleRequest('GET', self::B2_API_BASE_URL.self::B2_API_V1.'/b2_authorize_account', [
433 28
            'auth' => [$this->accountId, $this->applicationKey],
434
        ]);
435
436 28
        $this->authToken = $response['authorizationToken'];
437 28
        $this->apiUrl = $response['apiUrl'].self::B2_API_V1;
438 28
        $this->downloadUrl = $response['downloadUrl'];
439 28
        $this->reAuthTime = Carbon::now('UTC');
440 28
        $this->reAuthTime->addSeconds($this->authTimeoutSeconds);
441 28
    }
442
443
    /**
444
     * Maps the provided bucket name to the appropriate bucket ID.
445
     *
446
     * @param $name
447
     *
448
     * @return mixed
449
     */
450
    protected function getBucketIdFromName($name)
451
    {
452
        $buckets = $this->listBuckets();
453
454
        foreach ($buckets as $bucket) {
455
            if ($bucket->getName() === $name) {
456
                return $bucket->getId();
457
            }
458
        }
459
    }
460
461
    /**
462
     * Maps the provided bucket ID to the appropriate bucket name.
463
     *
464
     * @param $id
465
     *
466
     * @return mixed
467
     */
468
    protected function getBucketNameFromId($id)
469
    {
470
        $buckets = $this->listBuckets();
471
472
        foreach ($buckets as $bucket) {
473
            if ($bucket->getId() === $id) {
474
                return $bucket->getName();
475
            }
476
        }
477
    }
478
479
    /**
480
     * @param $bucketName
481
     * @param $fileName
482
     *
483
     * @return mixed
484
     */
485
    protected function getFileIdFromBucketAndFileName($bucketName, $fileName)
486
    {
487
        $files = $this->listFiles([
488
            'BucketName' => $bucketName,
489
            'FileName'   => $fileName,
490
        ]);
491
492
        foreach ($files as $file) {
493
            if ($file->getName() === $fileName) {
494
                return $file->getId();
495
            }
496
        }
497
    }
498
499
    /**
500
     * Uploads a large file using b2 large file procedure.
501
     *
502
     * @param array $options
503
     *
504
     * @return File
505
     */
506
    public function uploadLargeFile(array $options)
507
    {
508
        if (substr($options['FileName'], 0, 1) === '/') {
509
            $options['FileName'] = ltrim($options['FileName'], '/');
510
        }
511
512
        //if last char of path is not a "/" then add a "/"
513
        if (substr($options['FilePath'], -1) != '/') {
514
            $options['FilePath'] = $options['FilePath'].'/';
515
        }
516
517
        if (!isset($options['BucketId']) && isset($options['BucketName'])) {
518
            $options['BucketId'] = $this->getBucketIdFromName($options['BucketName']);
519
        }
520
521
        if (!isset($options['FileContentType'])) {
522
            $options['FileContentType'] = 'b2/x-auto';
523
        }
524
525
        $this->authorizeAccount();
526
527
        // 1) b2_start_large_file, (returns fileId)
0 ignored issues
show
Unused Code Comprehensibility introduced by
37% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
528
        $start = $this->startLargeFile($options['FileName'], $options['FileContentType'], $options['BucketId']);
529
530
        // 2) b2_get_upload_part_url for each thread uploading (takes fileId)
531
        $url = $this->getUploadPartUrl($start['fileId']);
532
533
        // 3) b2_upload_part for each part of the file
534
        $parts = $this->uploadParts($options['FilePath'].$options['FileName'], $url['uploadUrl'], $url['authorizationToken'], $options);
535
536
        $sha1s = [];
537
538
        foreach ($parts as $part) {
539
            $sha1s[] = $part['contentSha1'];
540
        }
541
542
        // 4) b2_finish_large_file.
543
        return $this->finishLargeFile($start['fileId'], $sha1s);
544
    }
545
546
    /**
547
     * Starts the large file upload process.
548
     *
549
     * @param $fileName
550
     * @param $contentType
551
     * @param $bucketId
552
     *
553
     * @throws GuzzleException If the request fails.
554
     * @throws B2Exception     If the B2 server replies with an error.
555
     *
556
     * @return mixed
557
     */
558
    protected function startLargeFile($fileName, $contentType, $bucketId)
559
    {
560
        return $this->sendAuthorizedRequest('POST', 'b2_start_large_file', [
561
            'fileName'      => $fileName,
562
            'contentType'   => $contentType,
563
            'bucketId'      => $bucketId,
564
        ]);
565
    }
566
567
    /**
568
     * Gets the url for the next large file part upload.
569
     *
570
     * @param $fileId
571
     *
572
     * @throws GuzzleException If the request fails.
573
     * @throws B2Exception     If the B2 server replies with an error.
574
     *
575
     * @return mixed
576
     */
577
    protected function getUploadPartUrl($fileId)
578
    {
579
        return $this->sendAuthorizedRequest('POST', 'b2_get_upload_part_url', [
580
            'fileId' => $fileId,
581
        ]);
582
    }
583
584
    /**
585
     * Uploads the file as "parts" of 100MB each.
586
     *
587
     * @param $filePath
588
     * @param $uploadUrl
589
     * @param $largeFileAuthToken
590
     * @param $options
591
     *
592
     * @return array
593
     */
594
    protected function uploadParts($filePath, $uploadUrl, $largeFileAuthToken, $options = [])
595
    {
596
        $return = [];
597
598
        $minimum_part_size = 100 * (1000 * 1000);
599
600
        $local_file_size = filesize($filePath);
601
        $total_bytes_sent = 0;
602
        $bytes_sent_for_part = $minimum_part_size;
603
        $sha1_of_parts = [];
604
        $part_no = 1;
605
        $file_handle = fopen($filePath, 'r');
606
607
        while ($total_bytes_sent < $local_file_size) {
608
609
            // Determine the number of bytes to send based on the minimum part size
610
            if (($local_file_size - $total_bytes_sent) < $minimum_part_size) {
611
                $bytes_sent_for_part = ($local_file_size - $total_bytes_sent);
612
            }
613
614
            // Get a sha1 of the part we are going to send
615
            fseek($file_handle, $total_bytes_sent);
616
            $data_part = fread($file_handle, $bytes_sent_for_part);
617
            array_push($sha1_of_parts, sha1($data_part));
618
            fseek($file_handle, $total_bytes_sent);
619
620
            $customHeaders = $options['Headers'] ?? [];
621
            $response = $this->client->guzzleRequest('POST', $uploadUrl, [
622
                'headers' => array_merge([
623
                    'Authorization'                      => $largeFileAuthToken,
624
                    'Content-Length'                     => $bytes_sent_for_part,
625
                    'X-Bz-Part-Number'                   => $part_no,
626
                    'X-Bz-Content-Sha1'                  => $sha1_of_parts[$part_no - 1],
627
                ], $customHeaders),
628
                'body' => $data_part,
629
            ]);
630
631
            $return[] = $response;
632
633
            // Prepare for the next iteration of the loop
634
            $part_no++;
635
            $total_bytes_sent = $bytes_sent_for_part + $total_bytes_sent;
636
        }
637
638
        fclose($file_handle);
639
640
        return $return;
641
    }
642
643
    /**
644
     * Finishes the large file upload procedure.
645
     *
646
     * @param       $fileId
647
     * @param array $sha1s
648
     *
649
     * @throws GuzzleException If the request fails.
650
     * @throws B2Exception     If the B2 server replies with an error.
651
     *
652
     * @return File
653
     */
654
    protected function finishLargeFile($fileId, array $sha1s)
655
    {
656
        $response = $this->sendAuthorizedRequest('POST', 'b2_finish_large_file', [
657
            'fileId'        => $fileId,
658
            'partSha1Array' => $sha1s,
659
        ]);
660
661
        return new File(
662
            $response['fileId'],
663
            $response['fileName'],
664
            $response['contentSha1'],
665
            $response['contentLength'],
666
            $response['contentType'],
667
            $response['fileInfo'],
668
            $response['bucketId'],
669
            $response['action'],
670
            $response['uploadTimestamp']
671
        );
672
    }
673
674
    /**
675
     * Sends a authorized request to b2 API.
676
     *
677
     * @param string $method
678
     * @param string $route
679
     * @param array  $json
680
     *
681
     * @throws GuzzleException If the request fails.
682
     * @throws B2Exception     If the B2 server replies with an error.
683
     *
684
     * @return mixed
685
     */
686 21
    protected function sendAuthorizedRequest($method, $route, $json = [])
687
    {
688 21
        $this->authorizeAccount();
689
690 21
        return $this->client->guzzleRequest($method, $this->apiUrl.$route, [
691
            'headers' => [
692 21
                'Authorization' => $this->authToken,
693
            ],
694 21
            'json' => $json,
695
        ]);
696
    }
697
}
698