@environment-safe/file 0.2.0 → 0.3.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.
- package/.husky/pre-commit +2 -2
- package/README.md +1 -1
- package/package.json +3 -2
- package/src/buffer.mjs +55 -9
- package/src/filesystem.mjs +20 -14
- package/src/index.mjs +63 -14
- package/test/aso.png +0 -0
- package/test/test.mjs +57 -10
package/.husky/pre-commit
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env sh
|
|
2
2
|
. "$(dirname -- "$0")/_/husky.sh"
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
npm run lint
|
|
5
5
|
npm run path-test
|
|
6
6
|
npm run headless-browser-path-test
|
|
7
|
-
|
|
7
|
+
npm run import-test
|
|
8
8
|
npm run headless-browser-test
|
|
9
9
|
#npm run build-commonjs
|
|
10
10
|
#npm run require-test
|
package/README.md
CHANGED
|
@@ -67,7 +67,7 @@ Roadmap
|
|
|
67
67
|
-------
|
|
68
68
|
|
|
69
69
|
- [x] - test existing suite in mac node
|
|
70
|
-
- [
|
|
70
|
+
- [x] - test existing suite in in chrome + server
|
|
71
71
|
- [ ] - test existing suite in in chrome + file
|
|
72
72
|
- [ ] - test existing suite in windows node
|
|
73
73
|
- [ ] - test existing suite in linux node
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@environment-safe/file",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.mjs",
|
|
6
6
|
"module": "src/index.mjs",
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"shims": {
|
|
66
66
|
"chai": "node_modules/chai/chai.js",
|
|
67
67
|
"@environment-safe/runtime-context": "node_modules/@environment-safe/runtime-context/./src/index.mjs",
|
|
68
|
+
"@environment-safe/file": "./src/index.mjs",
|
|
68
69
|
"browser-or-node": "node_modules/browser-or-node/src/index.js"
|
|
69
70
|
}
|
|
70
71
|
},
|
|
@@ -76,7 +77,7 @@
|
|
|
76
77
|
"@environment-safe/chai": "^0.1.0",
|
|
77
78
|
"@environment-safe/commonjs-builder": "^0.0.3",
|
|
78
79
|
"@environment-safe/jsdoc-builder": "^0.0.2",
|
|
79
|
-
"@open-automaton/moka": "^0.
|
|
80
|
+
"@open-automaton/moka": "^0.5.0",
|
|
80
81
|
"babel-plugin-search-and-replace": "^1.1.1",
|
|
81
82
|
"babel-plugin-transform-import-meta": "^2.2.0",
|
|
82
83
|
"chai": "^4.3.7",
|
package/src/buffer.mjs
CHANGED
|
@@ -26,7 +26,7 @@ const base64ToArrayBuffer = (base64)=>{
|
|
|
26
26
|
bytes[i] = binaryString.charCodeAt(i);
|
|
27
27
|
}
|
|
28
28
|
return bytes.buffer;
|
|
29
|
-
}
|
|
29
|
+
};
|
|
30
30
|
|
|
31
31
|
if(isBrowser || isJsDom){
|
|
32
32
|
InternalBuffer = function(value, type){
|
|
@@ -36,6 +36,16 @@ if(isBrowser || isJsDom){
|
|
|
36
36
|
};
|
|
37
37
|
const enc = new TextEncoder();
|
|
38
38
|
const dec = new TextDecoder(); //'utf-8'
|
|
39
|
+
InternalBuffer.fromDataURI = async (url)=>{
|
|
40
|
+
if(typeof url === 'string' && url.startsWith('data:')){
|
|
41
|
+
const result = await fetch(url);
|
|
42
|
+
const buffer = await result.arrayBuffer();
|
|
43
|
+
return buffer;
|
|
44
|
+
}else{
|
|
45
|
+
throw new Error('not a data uri');
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
InternalBuffer.fromDataURL = InternalBuffer.fromDataURI;
|
|
39
49
|
InternalBuffer.from = (ob)=>{
|
|
40
50
|
const type = Array.isArray(ob)?'array':(typeof ob);
|
|
41
51
|
if(InternalBuffer.is(ob)){
|
|
@@ -46,7 +56,6 @@ if(isBrowser || isJsDom){
|
|
|
46
56
|
case 'array':
|
|
47
57
|
case 'string':
|
|
48
58
|
return enc.encode(ob).buffer;
|
|
49
|
-
break;
|
|
50
59
|
case '':
|
|
51
60
|
}
|
|
52
61
|
};
|
|
@@ -66,17 +75,30 @@ if(isBrowser || isJsDom){
|
|
|
66
75
|
result = toBinString(Uint8Array.from(buffer));
|
|
67
76
|
break;
|
|
68
77
|
case 'base64':
|
|
69
|
-
|
|
78
|
+
var binary = '';
|
|
79
|
+
var bytes = new Uint8Array( buffer );
|
|
80
|
+
var len = bytes.byteLength;
|
|
81
|
+
for (var i = 0; i < len; i++) {
|
|
82
|
+
binary += String.fromCharCode( bytes[ i ] );
|
|
83
|
+
}
|
|
84
|
+
result = btoa( binary );
|
|
70
85
|
break;
|
|
71
86
|
}
|
|
72
87
|
return result;
|
|
73
88
|
};
|
|
74
89
|
InternalBuffer.toString = (type, buffer)=>{
|
|
75
90
|
let result = null;
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
91
|
+
switch(type){
|
|
92
|
+
case 'hex':
|
|
93
|
+
result = Array.prototype.map.call(
|
|
94
|
+
new Uint8Array(buffer),
|
|
95
|
+
x => ('00' + x.toString(16)).slice(-2)
|
|
96
|
+
).join('').match(/[a-fA-F0-9]{2}/g).join('');
|
|
97
|
+
break;
|
|
98
|
+
case 'base64':
|
|
99
|
+
result = InternalBuffer.to(type, buffer);
|
|
100
|
+
break;
|
|
101
|
+
default: result = InternalBuffer.to('string', buffer);
|
|
80
102
|
}
|
|
81
103
|
return result;
|
|
82
104
|
};
|
|
@@ -87,11 +109,12 @@ if(isBrowser || isJsDom){
|
|
|
87
109
|
result[lcv] = fill;
|
|
88
110
|
}
|
|
89
111
|
}
|
|
112
|
+
return result;
|
|
90
113
|
//todo: convert encoding to byte offset
|
|
91
114
|
};
|
|
92
115
|
}else{
|
|
93
116
|
InternalBuffer = function(value, type){
|
|
94
|
-
return new Buffer(value, type)
|
|
117
|
+
return new Buffer(value, type);
|
|
95
118
|
};
|
|
96
119
|
InternalBuffer.from = (ob='')=>{
|
|
97
120
|
return Buffer.from(ob);
|
|
@@ -99,6 +122,16 @@ if(isBrowser || isJsDom){
|
|
|
99
122
|
InternalBuffer.is = (buffer)=>{
|
|
100
123
|
return Buffer.isBuffer(buffer);
|
|
101
124
|
};
|
|
125
|
+
InternalBuffer.fromDataURI = async (url)=>{
|
|
126
|
+
if(typeof url === 'string' && url.startsWith('data:')){
|
|
127
|
+
const result = await fetch(url);
|
|
128
|
+
const buffer = await result.arrayBuffer();
|
|
129
|
+
return buffer;
|
|
130
|
+
}else{
|
|
131
|
+
throw new Error('not a data uri');
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
InternalBuffer.fromDataURL = InternalBuffer.fromDataURI;
|
|
102
135
|
//InternalBuffer = Buffer;
|
|
103
136
|
InternalBuffer.to = (type, buffer)=>{
|
|
104
137
|
switch(type){
|
|
@@ -109,7 +142,7 @@ if(isBrowser || isJsDom){
|
|
|
109
142
|
case 'string':
|
|
110
143
|
return buffer.toString();
|
|
111
144
|
case 'base64':
|
|
112
|
-
return
|
|
145
|
+
return buffer.toString('base64');
|
|
113
146
|
case '':
|
|
114
147
|
|
|
115
148
|
}
|
|
@@ -119,5 +152,18 @@ if(isBrowser || isJsDom){
|
|
|
119
152
|
return InternalBuffer.to('string', buffer);
|
|
120
153
|
};
|
|
121
154
|
}
|
|
155
|
+
InternalBuffer.stringify = (buffer, indent=' ', lineIndent='', lineSize=80)=>{
|
|
156
|
+
let pos = 0;
|
|
157
|
+
return InternalBuffer.toString('hex', buffer).split('').map((value, index)=>{
|
|
158
|
+
const chars = (pos%2 === 0 && pos !== 0)?indent+value:value;
|
|
159
|
+
if(pos + chars.length > lineSize){
|
|
160
|
+
const add = lineIndent+value;
|
|
161
|
+
pos = add.length;
|
|
162
|
+
return '\n'+add;
|
|
163
|
+
}
|
|
164
|
+
pos += chars.length;
|
|
165
|
+
return chars;
|
|
166
|
+
}).join('');
|
|
167
|
+
};
|
|
122
168
|
|
|
123
169
|
export const FileBuffer = InternalBuffer;
|
package/src/filesystem.mjs
CHANGED
|
@@ -16,6 +16,7 @@ const ensureRequire = ()=> (!internalRequire) && (internalRequire = mod.createRe
|
|
|
16
16
|
// and we now have 4 an obvious indicator that we went wrong? (win, unix, file, known dirs)
|
|
17
17
|
|
|
18
18
|
import { FileBuffer } from './buffer.mjs';
|
|
19
|
+
import { File } from './index.mjs';
|
|
19
20
|
import * as fs from 'fs';
|
|
20
21
|
import {
|
|
21
22
|
isClient // is running a client
|
|
@@ -331,7 +332,9 @@ export const serverFile = {
|
|
|
331
332
|
const url = parsed.toUrl('native');
|
|
332
333
|
return await new Promise((resolve, reject)=>{
|
|
333
334
|
fs.readFile(url, (err, buffer)=>{
|
|
334
|
-
if(err)
|
|
335
|
+
if(err){
|
|
336
|
+
reject(new Error(`File not found('${path}')`));
|
|
337
|
+
}
|
|
335
338
|
resolve(buffer);
|
|
336
339
|
});
|
|
337
340
|
});
|
|
@@ -342,15 +345,17 @@ export const serverFile = {
|
|
|
342
345
|
return await new Promise((resolve, reject)=>{
|
|
343
346
|
fs.writeFile(url, buffer, (err)=>{
|
|
344
347
|
if(err) return reject(err);
|
|
345
|
-
if(globalThis.handleWrite)
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
348
|
+
if(globalThis.handleWrite){
|
|
349
|
+
globalThis.handleWrite({
|
|
350
|
+
path,
|
|
351
|
+
url,
|
|
352
|
+
buffer,
|
|
353
|
+
text: ()=>{
|
|
354
|
+
return FileBuffer.toString('string', buffer);
|
|
355
|
+
},
|
|
356
|
+
arrayBuffer: ()=> buffer,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
354
359
|
resolve();
|
|
355
360
|
});
|
|
356
361
|
});
|
|
@@ -431,18 +436,19 @@ export const remote = {
|
|
|
431
436
|
},
|
|
432
437
|
read: async (path, options={})=>{
|
|
433
438
|
const response = await fetch(path);
|
|
439
|
+
if(response.status === 404) throw new Error(`File not found('${path}')`);
|
|
434
440
|
return await response.arrayBuffer();
|
|
435
441
|
},
|
|
436
442
|
write: async (path, buffer, options={})=>{
|
|
437
443
|
return await new Promise((resolve, reject)=>{
|
|
438
444
|
try{
|
|
439
445
|
var element = document.createElement('a');
|
|
440
|
-
const type = options.mimeType ||
|
|
441
|
-
const format = options.format ||
|
|
446
|
+
const type = options.mimeType || File.deriveMIMEType(buffer);
|
|
447
|
+
const format = options.format || File.deriveFormat(type);
|
|
442
448
|
const representation = (
|
|
443
449
|
type === 'text/plain'?
|
|
444
|
-
|
|
445
|
-
|
|
450
|
+
FileBuffer.toString('text', buffer):
|
|
451
|
+
FileBuffer.toString('base64', buffer)
|
|
446
452
|
);
|
|
447
453
|
const dataURI = `data:${type};${format},${representation}`;
|
|
448
454
|
element.setAttribute('href', dataURI);
|
package/src/index.mjs
CHANGED
|
@@ -131,33 +131,50 @@ const internalCache = {};
|
|
|
131
131
|
|
|
132
132
|
const mimeTypes = [
|
|
133
133
|
{
|
|
134
|
-
check: [0x89, 0x50, 0x4e, 0x47
|
|
135
|
-
mime: 'image/png'
|
|
134
|
+
check: [0x89, 0x50, 0x4e, 0x47],
|
|
135
|
+
mime: 'image/png',
|
|
136
|
+
types: ['png']
|
|
136
137
|
},
|
|
137
138
|
{
|
|
138
139
|
check: [0xff, 0xd8, 0xff],
|
|
139
|
-
mime: 'image/jpeg'
|
|
140
|
+
mime: 'image/jpeg',
|
|
141
|
+
types: ['jpg', 'jpeg']
|
|
140
142
|
},
|
|
141
143
|
{
|
|
142
144
|
check: [0x47, 0x49, 0x46, 0x38],
|
|
143
|
-
mime: 'image/gif'
|
|
145
|
+
mime: 'image/gif',
|
|
146
|
+
types: ['gif']
|
|
144
147
|
}
|
|
145
148
|
];
|
|
149
|
+
const textMimeTypes = [
|
|
150
|
+
'text/plain'
|
|
151
|
+
];
|
|
146
152
|
|
|
147
153
|
const checkOne = (headers)=>{
|
|
148
|
-
return (buffers, options = { offset: 0 }) =>
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
154
|
+
return (buffers, options = { offset: 0 }) =>{
|
|
155
|
+
const array = new Uint8Array(buffers);
|
|
156
|
+
return headers.reduce((agg, value, index)=>{
|
|
157
|
+
return agg && value === array[options.offset + index];
|
|
158
|
+
}, true);
|
|
159
|
+
};
|
|
152
160
|
};
|
|
153
161
|
|
|
154
162
|
const checks = {};
|
|
155
163
|
|
|
164
|
+
/*const mimeFromType = (path)=>{
|
|
165
|
+
const fileType = path.split('.').pop().toLowerCase();
|
|
166
|
+
const result = mimeTypes.reduce((agg, type)=>{
|
|
167
|
+
return agg || (type.types.indexOf(fileType) !== -1 && fileType)
|
|
168
|
+
}, false);
|
|
169
|
+
return result;
|
|
170
|
+
};*/
|
|
171
|
+
|
|
156
172
|
const mimeFromBuffer = (buffer)=>{
|
|
157
|
-
|
|
173
|
+
const result = mimeTypes.reduce((agg, type)=>{
|
|
158
174
|
if(!checks[type.check]) checks[type.check] = checkOne(type.check);
|
|
159
|
-
return agg || (checks[type.check](buffer) && type.mime)
|
|
175
|
+
return agg || (checks[type.check](buffer) && type.mime);
|
|
160
176
|
}, false);
|
|
177
|
+
return result;
|
|
161
178
|
};
|
|
162
179
|
|
|
163
180
|
export class File{
|
|
@@ -170,7 +187,11 @@ export class File{
|
|
|
170
187
|
this.options = options;
|
|
171
188
|
//one of: desktop, documents, downloads, music, pictures, videos
|
|
172
189
|
this.directory = options.directory || '.';
|
|
173
|
-
|
|
190
|
+
if(location.startsWith('data:')){
|
|
191
|
+
this.dataURI = location;
|
|
192
|
+
}else{
|
|
193
|
+
this.path = location;
|
|
194
|
+
}
|
|
174
195
|
this.setBuffer(FileBuffer.from(''));
|
|
175
196
|
}
|
|
176
197
|
|
|
@@ -188,8 +209,14 @@ export class File{
|
|
|
188
209
|
}
|
|
189
210
|
|
|
190
211
|
async load(){
|
|
191
|
-
|
|
192
|
-
|
|
212
|
+
if(this.path){
|
|
213
|
+
const input = await read(this.path, this.options);
|
|
214
|
+
this.setBuffer(input);
|
|
215
|
+
return this;
|
|
216
|
+
}
|
|
217
|
+
if(this.dataURI){
|
|
218
|
+
this.setBuffer(await FileBuffer.fromDataURI(this.dataURI));
|
|
219
|
+
}
|
|
193
220
|
}
|
|
194
221
|
|
|
195
222
|
body(value){
|
|
@@ -207,13 +234,21 @@ export class File{
|
|
|
207
234
|
return this.derivedMIMEType;
|
|
208
235
|
}
|
|
209
236
|
|
|
237
|
+
isText(){
|
|
238
|
+
return textMimeTypes.indexOf(this.mimeType()) !== -1;
|
|
239
|
+
}
|
|
240
|
+
|
|
210
241
|
format(){
|
|
211
242
|
if(!this.derivedMIMEType) this.mimeType();
|
|
212
243
|
return File.deriveFormat(this.derivedMIMEType);
|
|
213
244
|
}
|
|
214
245
|
|
|
215
246
|
toDataURL(){
|
|
216
|
-
|
|
247
|
+
if(this.isText()){
|
|
248
|
+
return `data:${this.mimeType()};${this.format()},${FileBuffer.toString('string', this.body())}`;
|
|
249
|
+
}else{
|
|
250
|
+
return `data:${this.mimeType()};${this.format()},${FileBuffer.toString('base64', this.body())}`;
|
|
251
|
+
}
|
|
217
252
|
}
|
|
218
253
|
|
|
219
254
|
async info(){
|
|
@@ -246,6 +281,20 @@ export class File{
|
|
|
246
281
|
return await exists(path, directory);
|
|
247
282
|
}
|
|
248
283
|
|
|
284
|
+
static async similarity(fileA, fileB, strict){
|
|
285
|
+
//*
|
|
286
|
+
if(strict && fileA.buffer.byteLength !== fileB.buffer.byteLength){
|
|
287
|
+
throw new Error(`File lengths do not match! (${fileA.buffer.byteLength} != ${fileB.buffer.byteLength})`);
|
|
288
|
+
} //*/
|
|
289
|
+
const size = fileA.buffer.byteLength;
|
|
290
|
+
let total = 0;
|
|
291
|
+
let lcv=0;
|
|
292
|
+
for(lcv=0; lcv<size; lcv++){
|
|
293
|
+
if(fileA[lcv] === fileB[lcv]) total++;
|
|
294
|
+
}
|
|
295
|
+
return total/size;
|
|
296
|
+
}
|
|
297
|
+
|
|
249
298
|
static async list(path, options){
|
|
250
299
|
return await list(path, options);
|
|
251
300
|
}
|
package/test/aso.png
ADDED
|
Binary file
|
package/test/test.mjs
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/* global describe:false */
|
|
2
|
-
import { isServer } from '@environment-safe/runtime-context';
|
|
3
2
|
import { chai } from '@environment-safe/chai';
|
|
4
|
-
import { it, configure, interactive } from '@open-automaton/moka';
|
|
3
|
+
import { it, configure, interactive, isInteractive } from '@open-automaton/moka';
|
|
5
4
|
import { File, Path, Download } from '../src/index.mjs';
|
|
6
5
|
const should = chai.should();
|
|
7
6
|
|
|
@@ -18,9 +17,6 @@ describe('@environment-safe/file', ()=>{
|
|
|
18
17
|
const fileRelativeSaveName = Path.join(Path.location('downloads'), 'index.mjs');
|
|
19
18
|
const missingFileRelativeName = Path.join('..', 'src', 'unknown.mjs');
|
|
20
19
|
const download = new Download();
|
|
21
|
-
if(isServer){
|
|
22
|
-
|
|
23
|
-
}
|
|
24
20
|
configure({
|
|
25
21
|
dialog : async (context, actions)=> await actions.confirm(), // OK everything,
|
|
26
22
|
wantsInput : async (context, actions)=> await actions.click('#mocha'), // click everything
|
|
@@ -93,11 +89,15 @@ describe('@environment-safe/file', ()=>{
|
|
|
93
89
|
await file.load();
|
|
94
90
|
file.path = fileRelativeSaveName; //change path
|
|
95
91
|
file.body('foo!'); //replace source
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
92
|
+
if(!isInteractive){
|
|
93
|
+
// if the test is interactive, automated file hooks won't work
|
|
94
|
+
// so test without a download confirm
|
|
95
|
+
const anticipatedDownload = download.expect();
|
|
96
|
+
await file.save();
|
|
97
|
+
const result = await anticipatedDownload;
|
|
98
|
+
const downloadedText = await result.text();
|
|
99
|
+
downloadedText.should.equal('foo!');
|
|
100
|
+
}
|
|
101
101
|
});
|
|
102
102
|
|
|
103
103
|
it('loads an implicit, relative URL', async function(){
|
|
@@ -116,6 +116,53 @@ describe('@environment-safe/file', ()=>{
|
|
|
116
116
|
await file.load();
|
|
117
117
|
file.body().cast('string').length.should.be.above(1);
|
|
118
118
|
});
|
|
119
|
+
|
|
120
|
+
it('loads itself as data when text', async function(){
|
|
121
|
+
const file = new File(Path.join(
|
|
122
|
+
'../node_modules/@environment-safe/chai',
|
|
123
|
+
'README.md'
|
|
124
|
+
));
|
|
125
|
+
await file.load();
|
|
126
|
+
const uri = file.toDataURL();
|
|
127
|
+
const copy = new File(uri);
|
|
128
|
+
await copy.load();
|
|
129
|
+
const similarity = await File.similarity(file, copy);
|
|
130
|
+
should.exist(similarity);
|
|
131
|
+
similarity.should.equal(1);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('loads itself as data when binary', async function(){
|
|
135
|
+
const file = new File('./aso.png');
|
|
136
|
+
await file.load();
|
|
137
|
+
const uri = file.toDataURL();
|
|
138
|
+
const copy = new File(uri);
|
|
139
|
+
await copy.load();
|
|
140
|
+
const similarity = await File.similarity(file, copy);
|
|
141
|
+
should.exist(similarity);
|
|
142
|
+
similarity.should.equal(1);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('can duplicate by copying buffer', async function(){
|
|
146
|
+
const file = new File('./aso.png');
|
|
147
|
+
await file.load();
|
|
148
|
+
const copy = new File();
|
|
149
|
+
copy.body(file.buffer);
|
|
150
|
+
const similarity = await File.similarity(file, copy, true);
|
|
151
|
+
should.exist(similarity);
|
|
152
|
+
similarity.should.equal(1);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('fails to load a non-existent file', async function(){
|
|
156
|
+
|
|
157
|
+
try{
|
|
158
|
+
const file = new File('./woo.png');
|
|
159
|
+
await file.load();
|
|
160
|
+
}catch(ex){
|
|
161
|
+
ex.message.should.equal('File not found(\'./woo.png\')');
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
should.not.exist(true, 'file did not error on load');
|
|
165
|
+
});
|
|
119
166
|
});
|
|
120
167
|
});
|
|
121
168
|
|