@larsgw/formica 0.4.3 → 0.5.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/CHANGELOG.md +32 -0
- package/lib/bin/process-resources.d.ts +5 -1
- package/lib/bin/process-resources.js +143 -144
- package/lib/catalog/value.js +2 -2
- package/lib/csv.js +1 -1
- package/lib/resources/parse-text.js +80 -47
- package/package.json +1 -1
- package/src/bin/process-resources.ts +81 -60
- package/src/catalog/value.ts +2 -2
- package/src/csv.ts +1 -1
- package/src/module.d.ts +16 -4
- package/src/resources/parse-text.ts +76 -36
- package/test/resources.js +24 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,35 @@
|
|
|
1
|
+
# [0.5.0](https://github.com/identification-resources/formica/compare/v0.4.3...v0.5.0) (2023-10-24)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **catalog:** make validation more robust ([522db8a](https://github.com/identification-resources/formica/commit/522db8ad428c2d4645569a7f541fd203f5d628e8))
|
|
7
|
+
* **csv:** fix csv formatting ([064e5e5](https://github.com/identification-resources/formica/commit/064e5e57b2a642d22850501ab2a0b95879779b85))
|
|
8
|
+
* **resources:** do not count abbreviations as species ([ac0cc5a](https://github.com/identification-resources/formica/commit/ac0cc5a7cfd1f68df9b8d1d1c543a2f593839daa))
|
|
9
|
+
* **resources:** fix typos in output column names ([0d02823](https://github.com/identification-resources/formica/commit/0d0282378b21e92fbe028a53a9d121f42e791cc5))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Features
|
|
13
|
+
|
|
14
|
+
* **resources:** encode names with spaces with underscores ([9c7efd4](https://github.com/identification-resources/formica/commit/9c7efd4daf80660be98f2fec8ef9faa5d5b42d67))
|
|
15
|
+
* **resources:** include verbatimIdentification column ([4a2e920](https://github.com/identification-resources/formica/commit/4a2e9201467347e900f5690d2301a3b231ded822))
|
|
16
|
+
* **resources:** move "scope" metadata to "catalog" ([286c5b2](https://github.com/identification-resources/formica/commit/286c5b223ce41336f3ebd63c1797403aad21aef6))
|
|
17
|
+
* **resources:** update processor configuration ([64daa26](https://github.com/identification-resources/formica/commit/64daa26d70c96df24298c7b78f2b93ef0f5b2a48))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### BREAKING CHANGES
|
|
21
|
+
|
|
22
|
+
* **resources:** - "intragenericEpithet" is now "infragenericEpithet"
|
|
23
|
+
- "intraspecificEpithet" is now "infraspecificEpithet"
|
|
24
|
+
* **resources:** Adds additional column, between "higherClassification" and "colTaxonID".
|
|
25
|
+
* **resources:** - Instead of "-u, --update" use "-s modified"
|
|
26
|
+
- Instead of "--update-mapping" use "-s all"
|
|
27
|
+
* **resources:** Index will no longer contain "scope" array. "scope" field in YAML header
|
|
28
|
+
does no longer pass validation. Use the "taxon_scope" and "scope" fields
|
|
29
|
+
in "catalog" instead.
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
1
33
|
## [0.4.3](https://github.com/identification-resources/formica/compare/v0.4.2...v0.4.3) (2023-09-30)
|
|
2
34
|
|
|
3
35
|
|
|
@@ -48,20 +48,27 @@ var __generator = (this && this.__generator) || function (thisArg, body) {
|
|
|
48
48
|
}
|
|
49
49
|
};
|
|
50
50
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
51
|
+
exports.ResourceProcessorSource = void 0;
|
|
51
52
|
var fs_1 = require("fs");
|
|
52
53
|
var path = require("path");
|
|
53
54
|
var child_process_1 = require("child_process");
|
|
54
55
|
var util = require("util");
|
|
55
56
|
var index_1 = require("../index");
|
|
56
57
|
var util_1 = require("./util");
|
|
58
|
+
var ResourceProcessorSource;
|
|
59
|
+
(function (ResourceProcessorSource) {
|
|
60
|
+
ResourceProcessorSource["All"] = "all";
|
|
61
|
+
ResourceProcessorSource["Unprocessed"] = "unprocessed";
|
|
62
|
+
ResourceProcessorSource["Modified"] = "modified";
|
|
63
|
+
})(ResourceProcessorSource = exports.ResourceProcessorSource || (exports.ResourceProcessorSource = {}));
|
|
57
64
|
var DWC_FIELDS = [
|
|
58
65
|
'scientificNameID',
|
|
59
66
|
'scientificName',
|
|
60
67
|
'scientificNameAuthorship',
|
|
61
68
|
'genericName',
|
|
62
|
-
'
|
|
69
|
+
'infragenericEpithet',
|
|
63
70
|
'specificEpithet',
|
|
64
|
-
'
|
|
71
|
+
'infraspecificEpithet',
|
|
65
72
|
'taxonRank',
|
|
66
73
|
'taxonRemarks',
|
|
67
74
|
'collectionCode',
|
|
@@ -79,6 +86,7 @@ var DWC_FIELDS = [
|
|
|
79
86
|
'genus',
|
|
80
87
|
'subgenus',
|
|
81
88
|
'higherClassification',
|
|
89
|
+
'verbatimIdentification',
|
|
82
90
|
'colTaxonID',
|
|
83
91
|
'gbifTaxonID',
|
|
84
92
|
'colAcceptedTaxonID',
|
|
@@ -122,6 +130,36 @@ function runGnverifier(names) {
|
|
|
122
130
|
proc.stdin.end();
|
|
123
131
|
});
|
|
124
132
|
}
|
|
133
|
+
function listFiles(directory) {
|
|
134
|
+
return __awaiter(this, void 0, void 0, function () {
|
|
135
|
+
var input;
|
|
136
|
+
return __generator(this, function (_a) {
|
|
137
|
+
switch (_a.label) {
|
|
138
|
+
case 0: return [4 /*yield*/, fs_1.promises.readdir(directory)];
|
|
139
|
+
case 1:
|
|
140
|
+
input = _a.sent();
|
|
141
|
+
return [2 /*return*/, input.map(function (file) { return path.basename(file, '.txt'); }).sort(util_1.numericSort)];
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
function listUnprocessedFiles(directory, outputDirectory) {
|
|
147
|
+
return __awaiter(this, void 0, void 0, function () {
|
|
148
|
+
var input, output, _a;
|
|
149
|
+
return __generator(this, function (_b) {
|
|
150
|
+
switch (_b.label) {
|
|
151
|
+
case 0: return [4 /*yield*/, listFiles(directory)];
|
|
152
|
+
case 1:
|
|
153
|
+
input = _b.sent();
|
|
154
|
+
_a = Set.bind;
|
|
155
|
+
return [4 /*yield*/, fs_1.promises.readdir(outputDirectory)];
|
|
156
|
+
case 2:
|
|
157
|
+
output = new (_a.apply(Set, [void 0, _b.sent()]))();
|
|
158
|
+
return [2 /*return*/, input.filter(function (file) { return output.has(file + '-1'); })];
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
}
|
|
125
163
|
function listChangedFiles(directory) {
|
|
126
164
|
return __awaiter(this, void 0, void 0, function () {
|
|
127
165
|
var output;
|
|
@@ -132,7 +170,7 @@ function listChangedFiles(directory) {
|
|
|
132
170
|
})];
|
|
133
171
|
case 1:
|
|
134
172
|
output = _a.sent();
|
|
135
|
-
return [2 /*return*/, output.trimEnd().split('\n').sort(util_1.numericSort)];
|
|
173
|
+
return [2 /*return*/, output.trimEnd().split('\n').map(function (file) { return path.basename(file, '.txt'); }).sort(util_1.numericSort)];
|
|
136
174
|
}
|
|
137
175
|
});
|
|
138
176
|
});
|
|
@@ -162,72 +200,23 @@ var ResourceProcessor = /** @class */ (function () {
|
|
|
162
200
|
this.DIR_DWC = path.join(this.DIR_ROOT, 'dwc');
|
|
163
201
|
this.FILE_PROBLEMS = path.join(this.DIR_ROOT, 'problems.csv');
|
|
164
202
|
}
|
|
165
|
-
ResourceProcessor.prototype.run = function () {
|
|
203
|
+
ResourceProcessor.prototype.run = function (source, config) {
|
|
166
204
|
return __awaiter(this, void 0, void 0, function () {
|
|
167
|
-
var
|
|
205
|
+
var ids, _i, ids_1, id;
|
|
168
206
|
return __generator(this, function (_a) {
|
|
169
207
|
switch (_a.label) {
|
|
170
|
-
case 0: return [4 /*yield*/,
|
|
208
|
+
case 0: return [4 /*yield*/, this.listWorks(source)];
|
|
171
209
|
case 1:
|
|
172
|
-
|
|
173
|
-
return [4 /*yield*/, fs_1.promises.readdir(this.DIR_DWC)];
|
|
174
|
-
case 2:
|
|
175
|
-
output = _a.sent();
|
|
176
|
-
ids = input
|
|
177
|
-
.map(function (file) { return path.basename(file, '.txt'); })
|
|
178
|
-
.sort(function (a, b) { return parseInt(a.slice(1)) - parseInt(b.slice(1)); });
|
|
179
|
-
_loop_1 = function (id) {
|
|
180
|
-
return __generator(this, function (_b) {
|
|
181
|
-
switch (_b.label) {
|
|
182
|
-
case 0:
|
|
183
|
-
// Skip existing files
|
|
184
|
-
if (output.some(function (file) { return file.startsWith(id + '-'); })) {
|
|
185
|
-
return [2 /*return*/, "continue"];
|
|
186
|
-
}
|
|
187
|
-
return [4 /*yield*/, this_1.processWork(id)];
|
|
188
|
-
case 1:
|
|
189
|
-
_b.sent();
|
|
190
|
-
return [2 /*return*/];
|
|
191
|
-
}
|
|
192
|
-
});
|
|
193
|
-
};
|
|
194
|
-
this_1 = this;
|
|
210
|
+
ids = _a.sent();
|
|
195
211
|
_i = 0, ids_1 = ids;
|
|
196
|
-
_a.label =
|
|
197
|
-
case 3:
|
|
198
|
-
if (!(_i < ids_1.length)) return [3 /*break*/, 6];
|
|
199
|
-
id = ids_1[_i];
|
|
200
|
-
return [5 /*yield**/, _loop_1(id)];
|
|
201
|
-
case 4:
|
|
202
|
-
_a.sent();
|
|
203
|
-
_a.label = 5;
|
|
204
|
-
case 5:
|
|
205
|
-
_i++;
|
|
206
|
-
return [3 /*break*/, 3];
|
|
207
|
-
case 6: return [2 /*return*/];
|
|
208
|
-
}
|
|
209
|
-
});
|
|
210
|
-
});
|
|
211
|
-
};
|
|
212
|
-
ResourceProcessor.prototype.runUpdate = function () {
|
|
213
|
-
return __awaiter(this, void 0, void 0, function () {
|
|
214
|
-
var _i, _a, file, id;
|
|
215
|
-
return __generator(this, function (_b) {
|
|
216
|
-
switch (_b.label) {
|
|
217
|
-
case 0:
|
|
218
|
-
_i = 0;
|
|
219
|
-
return [4 /*yield*/, listChangedFiles(this.DIR_TXT)];
|
|
220
|
-
case 1:
|
|
221
|
-
_a = _b.sent();
|
|
222
|
-
_b.label = 2;
|
|
212
|
+
_a.label = 2;
|
|
223
213
|
case 2:
|
|
224
|
-
if (!(_i <
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
return [4 /*yield*/, this.processWork(id, true)];
|
|
214
|
+
if (!(_i < ids_1.length)) return [3 /*break*/, 5];
|
|
215
|
+
id = ids_1[_i];
|
|
216
|
+
return [4 /*yield*/, this.processWork(id, config)];
|
|
228
217
|
case 3:
|
|
229
|
-
|
|
230
|
-
|
|
218
|
+
_a.sent();
|
|
219
|
+
_a.label = 4;
|
|
231
220
|
case 4:
|
|
232
221
|
_i++;
|
|
233
222
|
return [3 /*break*/, 2];
|
|
@@ -236,52 +225,41 @@ var ResourceProcessor = /** @class */ (function () {
|
|
|
236
225
|
});
|
|
237
226
|
});
|
|
238
227
|
};
|
|
239
|
-
ResourceProcessor.prototype.
|
|
228
|
+
ResourceProcessor.prototype.listWorks = function (source) {
|
|
240
229
|
return __awaiter(this, void 0, void 0, function () {
|
|
241
|
-
var input, ids, _i, ids_2, id;
|
|
242
230
|
return __generator(this, function (_a) {
|
|
243
|
-
switch (
|
|
244
|
-
case
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
case 2:
|
|
253
|
-
if (!(_i < ids_2.length)) return [3 /*break*/, 5];
|
|
254
|
-
id = ids_2[_i];
|
|
255
|
-
return [4 /*yield*/, this.processWork(id, true)];
|
|
256
|
-
case 3:
|
|
257
|
-
_a.sent();
|
|
258
|
-
_a.label = 4;
|
|
259
|
-
case 4:
|
|
260
|
-
_i++;
|
|
261
|
-
return [3 /*break*/, 2];
|
|
262
|
-
case 5: return [2 /*return*/];
|
|
231
|
+
switch (source) {
|
|
232
|
+
case ResourceProcessorSource.All:
|
|
233
|
+
return [2 /*return*/, listFiles(this.DIR_TXT)];
|
|
234
|
+
case ResourceProcessorSource.Unprocessed:
|
|
235
|
+
return [2 /*return*/, listUnprocessedFiles(this.DIR_TXT, this.DIR_DWC)];
|
|
236
|
+
case ResourceProcessorSource.Modified:
|
|
237
|
+
return [2 /*return*/, listChangedFiles(this.DIR_TXT)];
|
|
238
|
+
default:
|
|
239
|
+
return [2 /*return*/, []];
|
|
263
240
|
}
|
|
241
|
+
return [2 /*return*/];
|
|
264
242
|
});
|
|
265
243
|
});
|
|
266
244
|
};
|
|
267
|
-
ResourceProcessor.prototype.processWork = function (id,
|
|
245
|
+
ResourceProcessor.prototype.processWork = function (id, config) {
|
|
268
246
|
return __awaiter(this, void 0, void 0, function () {
|
|
269
247
|
var resources;
|
|
270
248
|
var _this = this;
|
|
271
249
|
return __generator(this, function (_a) {
|
|
272
250
|
switch (_a.label) {
|
|
273
|
-
case 0: return [4 /*yield*/, this.processResources(id,
|
|
251
|
+
case 0: return [4 /*yield*/, this.processResources(id, config)];
|
|
274
252
|
case 1:
|
|
275
253
|
resources = _a.sent();
|
|
276
254
|
return [4 /*yield*/, Promise.all(resources.map(function (resource) {
|
|
277
255
|
var header = DWC_FIELDS;
|
|
278
256
|
var table = [header];
|
|
279
|
-
var
|
|
257
|
+
var _loop_1 = function (id_1) {
|
|
280
258
|
var taxon = resource.taxa[id_1];
|
|
281
259
|
table.push(header.map(function (column) { return taxon[column] || ''; }));
|
|
282
260
|
};
|
|
283
261
|
for (var id_1 in resource.taxa) {
|
|
284
|
-
|
|
262
|
+
_loop_1(id_1);
|
|
285
263
|
}
|
|
286
264
|
return fs_1.promises.writeFile(path.join(_this.DIR_DWC, "".concat(resource.file, ".csv")), index_1.csv.formatCsv(table, ',').trim());
|
|
287
265
|
}))];
|
|
@@ -292,12 +270,12 @@ var ResourceProcessor = /** @class */ (function () {
|
|
|
292
270
|
});
|
|
293
271
|
});
|
|
294
272
|
};
|
|
295
|
-
ResourceProcessor.prototype.processResources = function (id,
|
|
273
|
+
ResourceProcessor.prototype.processResources = function (id, config) {
|
|
296
274
|
return __awaiter(this, void 0, void 0, function () {
|
|
297
275
|
var resources, amendedResources, _i, resources_1, resource, results, skip, correct, choice, _a, reason;
|
|
298
276
|
return __generator(this, function (_b) {
|
|
299
277
|
switch (_b.label) {
|
|
300
|
-
case 0: return [4 /*yield*/, this.processResourceText(id,
|
|
278
|
+
case 0: return [4 /*yield*/, this.processResourceText(id, config)];
|
|
301
279
|
case 1:
|
|
302
280
|
resources = _b.sent();
|
|
303
281
|
amendedResources = [];
|
|
@@ -306,7 +284,7 @@ var ResourceProcessor = /** @class */ (function () {
|
|
|
306
284
|
case 2:
|
|
307
285
|
if (!(_i < resources_1.length)) return [3 /*break*/, 11];
|
|
308
286
|
resource = resources_1[_i];
|
|
309
|
-
return [4 /*yield*/, this.processResourceDwc(resource)];
|
|
287
|
+
return [4 /*yield*/, this.processResourceDwc(resource, config)];
|
|
310
288
|
case 3:
|
|
311
289
|
results = _b.sent();
|
|
312
290
|
return [4 /*yield*/, this.shouldBeSkipped(resource.id)];
|
|
@@ -339,7 +317,7 @@ var ResourceProcessor = /** @class */ (function () {
|
|
|
339
317
|
case 8:
|
|
340
318
|
{
|
|
341
319
|
console.log("".concat(resource.workId, ": retrying ").concat(resource.id));
|
|
342
|
-
return [2 /*return*/, this.processResources(id,
|
|
320
|
+
return [2 /*return*/, this.processResources(id, config)];
|
|
343
321
|
}
|
|
344
322
|
_b.label = 9;
|
|
345
323
|
case 9:
|
|
@@ -353,7 +331,7 @@ var ResourceProcessor = /** @class */ (function () {
|
|
|
353
331
|
});
|
|
354
332
|
});
|
|
355
333
|
};
|
|
356
|
-
ResourceProcessor.prototype.processResourceText = function (id,
|
|
334
|
+
ResourceProcessor.prototype.processResourceText = function (id, config) {
|
|
357
335
|
return __awaiter(this, void 0, void 0, function () {
|
|
358
336
|
var filePath, file, old, dwc, _i, _a, file_1, filePath_1, _b, _c, _d, _e, resources, error_1, prefix, file;
|
|
359
337
|
var _f;
|
|
@@ -367,7 +345,7 @@ var ResourceProcessor = /** @class */ (function () {
|
|
|
367
345
|
case 1:
|
|
368
346
|
file = _g.sent();
|
|
369
347
|
old = undefined;
|
|
370
|
-
if (!update) return [3 /*break*/, 8];
|
|
348
|
+
if (!config.update) return [3 /*break*/, 8];
|
|
371
349
|
dwc = [];
|
|
372
350
|
_i = 0;
|
|
373
351
|
return [4 /*yield*/, fs_1.promises.readdir(this.DIR_DWC)];
|
|
@@ -412,19 +390,43 @@ var ResourceProcessor = /** @class */ (function () {
|
|
|
412
390
|
delete require.cache[file];
|
|
413
391
|
}
|
|
414
392
|
}
|
|
415
|
-
return [2 /*return*/, this.processResourceText(id,
|
|
393
|
+
return [2 /*return*/, this.processResourceText(id, config)];
|
|
416
394
|
case 12: return [2 /*return*/];
|
|
417
395
|
}
|
|
418
396
|
});
|
|
419
397
|
});
|
|
420
398
|
};
|
|
421
|
-
ResourceProcessor.prototype.processResourceDwc = function (resource) {
|
|
399
|
+
ResourceProcessor.prototype.processResourceDwc = function (resource, config) {
|
|
422
400
|
return __awaiter(this, void 0, void 0, function () {
|
|
423
|
-
var filteredResults, taxonNames, names, id, name_1, result,
|
|
424
|
-
return __generator(this, function (
|
|
425
|
-
switch (
|
|
401
|
+
var file, _a, header_1, rows, _b, _c, _i, rows_1, row, oldTaxon, taxon, filteredResults, taxonNames, names, id, name_1, result, _d, _e, results, _f, name_2, matches, _g, matches_1, match, source, currentRank, _h, _j, loirId, taxon, _k, amendResource, groupNameMatches, groupedNameMatches, amendedResource, _l, _m, _o, _p, source, matches;
|
|
402
|
+
return __generator(this, function (_q) {
|
|
403
|
+
switch (_q.label) {
|
|
426
404
|
case 0:
|
|
427
405
|
console.log("".concat(resource.workId, ": matching ").concat(resource.id));
|
|
406
|
+
if (!!config.updateMappings) return [3 /*break*/, 3];
|
|
407
|
+
file = path.join(this.DIR_DWC, resource.file + '.csv');
|
|
408
|
+
if (!(0, fs_1.existsSync)(file)) return [3 /*break*/, 2];
|
|
409
|
+
_c = (_b = index_1.csv).parseCsv;
|
|
410
|
+
return [4 /*yield*/, fs_1.promises.readFile(file, 'utf-8')];
|
|
411
|
+
case 1:
|
|
412
|
+
_a = _c.apply(_b, [_q.sent()]), header_1 = _a[0], rows = _a.slice(1);
|
|
413
|
+
for (_i = 0, rows_1 = rows; _i < rows_1.length; _i++) {
|
|
414
|
+
row = rows_1[_i];
|
|
415
|
+
oldTaxon = row.reduce(function (taxon, value, index) {
|
|
416
|
+
taxon[header_1[index]] = value;
|
|
417
|
+
return taxon;
|
|
418
|
+
}, {});
|
|
419
|
+
taxon = resource.taxa[oldTaxon.scientificNameID];
|
|
420
|
+
if (taxon) {
|
|
421
|
+
taxon.colTaxonID = oldTaxon.colTaxonID;
|
|
422
|
+
taxon.colAcceptedTaxonID = oldTaxon.colAcceptedTaxonID;
|
|
423
|
+
taxon.gbifTaxonID = oldTaxon.gbifTaxonID;
|
|
424
|
+
taxon.gbifAcceptedTaxonID = oldTaxon.gbifAcceptedTaxonID;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
_q.label = 2;
|
|
428
|
+
case 2: return [2 /*return*/, resource];
|
|
429
|
+
case 3:
|
|
428
430
|
filteredResults = {};
|
|
429
431
|
taxonNames = {};
|
|
430
432
|
names = new Set();
|
|
@@ -438,16 +440,16 @@ var ResourceProcessor = /** @class */ (function () {
|
|
|
438
440
|
filteredResults[id] = [];
|
|
439
441
|
}
|
|
440
442
|
return [4 /*yield*/, runGnverifier(Array.from(names).join('\n'))];
|
|
441
|
-
case
|
|
442
|
-
result =
|
|
443
|
-
for (
|
|
444
|
-
results =
|
|
445
|
-
|
|
443
|
+
case 4:
|
|
444
|
+
result = _q.sent();
|
|
445
|
+
for (_d = 0, _e = result.trim().split('\n'); _d < _e.length; _d++) {
|
|
446
|
+
results = _e[_d];
|
|
447
|
+
_f = JSON.parse(results), name_2 = _f.name, matches = _f.results;
|
|
446
448
|
if (!matches) {
|
|
447
449
|
continue;
|
|
448
450
|
}
|
|
449
|
-
for (
|
|
450
|
-
match = matches_1[
|
|
451
|
+
for (_g = 0, matches_1 = matches; _g < matches_1.length; _g++) {
|
|
452
|
+
match = matches_1[_g];
|
|
451
453
|
source = match.dataSourceId;
|
|
452
454
|
currentRank = match.classificationRanks.split('|').pop();
|
|
453
455
|
if (match.scoreDetails.cardinalityScore === 0) {
|
|
@@ -458,8 +460,8 @@ var ResourceProcessor = /** @class */ (function () {
|
|
|
458
460
|
// GBIF species like "Nomada spec"
|
|
459
461
|
continue;
|
|
460
462
|
}
|
|
461
|
-
for (
|
|
462
|
-
loirId =
|
|
463
|
+
for (_h = 0, _j = taxonNames[name_2]; _h < _j.length; _h++) {
|
|
464
|
+
loirId = _j[_h];
|
|
463
465
|
taxon = resource.taxa[loirId];
|
|
464
466
|
if (source === 11 && !GBIF_RANKS.includes(taxon.taxonRank)) {
|
|
465
467
|
// Exclude GBIF matches for ranks that are not in GBIF
|
|
@@ -483,30 +485,30 @@ var ResourceProcessor = /** @class */ (function () {
|
|
|
483
485
|
}
|
|
484
486
|
}
|
|
485
487
|
return [4 /*yield*/, Promise.resolve().then(function () { return require('../index'); })];
|
|
486
|
-
case
|
|
487
|
-
|
|
488
|
+
case 5:
|
|
489
|
+
_k = (_q.sent()).taxonNames, amendResource = _k.amendResource, groupNameMatches = _k.groupNameMatches;
|
|
488
490
|
groupedNameMatches = groupNameMatches(filteredResults);
|
|
489
491
|
amendedResource = __assign(__assign({}, resource), { taxa: __assign({}, resource.taxa) });
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
for (
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
case
|
|
497
|
-
if (!(
|
|
498
|
-
|
|
499
|
-
if (!(
|
|
500
|
-
source =
|
|
492
|
+
_l = groupedNameMatches;
|
|
493
|
+
_m = [];
|
|
494
|
+
for (_o in _l)
|
|
495
|
+
_m.push(_o);
|
|
496
|
+
_p = 0;
|
|
497
|
+
_q.label = 6;
|
|
498
|
+
case 6:
|
|
499
|
+
if (!(_p < _m.length)) return [3 /*break*/, 9];
|
|
500
|
+
_o = _m[_p];
|
|
501
|
+
if (!(_o in _l)) return [3 /*break*/, 8];
|
|
502
|
+
source = _o;
|
|
501
503
|
return [4 /*yield*/, this.selectPrefixes(resource, groupedNameMatches, source)];
|
|
502
|
-
case
|
|
503
|
-
matches =
|
|
504
|
+
case 7:
|
|
505
|
+
matches = _q.sent();
|
|
504
506
|
amendResource(amendedResource, source, matches);
|
|
505
|
-
|
|
506
|
-
case
|
|
507
|
-
|
|
508
|
-
return [3 /*break*/,
|
|
509
|
-
case
|
|
507
|
+
_q.label = 8;
|
|
508
|
+
case 8:
|
|
509
|
+
_p++;
|
|
510
|
+
return [3 /*break*/, 6];
|
|
511
|
+
case 9: return [2 /*return*/, amendedResource];
|
|
510
512
|
}
|
|
511
513
|
});
|
|
512
514
|
});
|
|
@@ -635,12 +637,14 @@ var ResourceProcessor = /** @class */ (function () {
|
|
|
635
637
|
function main() {
|
|
636
638
|
var args = util.parseArgs({
|
|
637
639
|
options: {
|
|
638
|
-
|
|
639
|
-
type: '
|
|
640
|
-
short: '
|
|
640
|
+
source: {
|
|
641
|
+
type: 'string',
|
|
642
|
+
short: 's',
|
|
643
|
+
default: 'unprocessed'
|
|
641
644
|
},
|
|
642
|
-
'
|
|
643
|
-
type: 'boolean'
|
|
645
|
+
'keep-mappings': {
|
|
646
|
+
type: 'boolean',
|
|
647
|
+
short: 'k'
|
|
644
648
|
}
|
|
645
649
|
},
|
|
646
650
|
allowPositionals: true
|
|
@@ -649,17 +653,12 @@ function main() {
|
|
|
649
653
|
process.on('exit', function () {
|
|
650
654
|
process.stdout.write('\n');
|
|
651
655
|
});
|
|
652
|
-
var
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
}
|
|
659
|
-
else {
|
|
660
|
-
task = processor.run();
|
|
661
|
-
}
|
|
662
|
-
task.catch(function (error) {
|
|
656
|
+
var source = args.values.source;
|
|
657
|
+
var config = {
|
|
658
|
+
update: source !== 'unprocessed',
|
|
659
|
+
updateMappings: !args.values['keep-mappings']
|
|
660
|
+
};
|
|
661
|
+
processor.run(source, config).catch(function (error) {
|
|
663
662
|
console.error(error);
|
|
664
663
|
process.exit(1);
|
|
665
664
|
});
|
package/lib/catalog/value.js
CHANGED
|
@@ -22,10 +22,10 @@ exports.FORMATS = {
|
|
|
22
22
|
};
|
|
23
23
|
exports.CHECK = {
|
|
24
24
|
MULTILANG: function (entry) {
|
|
25
|
-
return entry.language.length > 1;
|
|
25
|
+
return Array.isArray(entry.language) && entry.language.length > 1;
|
|
26
26
|
},
|
|
27
27
|
ISBN: function (entry) {
|
|
28
|
-
if (entry.ISBN.length < 2) {
|
|
28
|
+
if (!Array.isArray(entry.ISBN) || entry.ISBN.length < 2) {
|
|
29
29
|
return false;
|
|
30
30
|
}
|
|
31
31
|
var a = entry.ISBN[0].length === 10;
|
package/lib/csv.js
CHANGED
|
@@ -25,7 +25,7 @@ function formatCsv(table, delimiter) {
|
|
|
25
25
|
.map(function (row) {
|
|
26
26
|
return row.map(function (value) {
|
|
27
27
|
if (/["\n]/.test(value) || value.includes(delimiter)) {
|
|
28
|
-
return "\"".concat(value.replace(/"
|
|
28
|
+
return "\"".concat(value.replace(/"/g, '""'), "\"");
|
|
29
29
|
}
|
|
30
30
|
else {
|
|
31
31
|
return value;
|
|
@@ -13,6 +13,7 @@ var __assign = (this && this.__assign) || function () {
|
|
|
13
13
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
14
|
exports.parseFileHeader = exports.parseFile = void 0;
|
|
15
15
|
var yaml = require("js-yaml");
|
|
16
|
+
var work_1 = require("../catalog/tables/work");
|
|
16
17
|
var diff_resource_1 = require("./diff-resource");
|
|
17
18
|
var RANKS = [
|
|
18
19
|
'class',
|
|
@@ -122,9 +123,9 @@ var NAME_PATTERN = new RegExp('^' +
|
|
|
122
123
|
* $1 genus+subgenus (+ trailing space): (?:([A-Z]\S+) (?:\(([A-Z]\S+?)\) )?)?
|
|
123
124
|
* $1.1 genus: ([A-Z]\S+)
|
|
124
125
|
* $1.2 subgenus: (?:\(([A-Z]\S+?)\) )?
|
|
125
|
-
* $2 species: ((?:x )?[a-z]
|
|
126
|
+
* $2 species: ((?:x )?[a-z][^\s.]+)
|
|
126
127
|
*/
|
|
127
|
-
var BINAME_PATTERN = /^(?:([A-Z]\S+) (?:\(([A-Z]\S+?)\) )?)?((?:x )?[a-z]
|
|
128
|
+
var BINAME_PATTERN = /^(?:([A-Z]\S+) (?:\(([A-Z]\S+?)\) )?)?((?:x )?[a-z][^\s.]+)(?= |$)/;
|
|
128
129
|
function compareRanks(a, b) {
|
|
129
130
|
return RANKS.indexOf(a) - RANKS.indexOf(b);
|
|
130
131
|
}
|
|
@@ -174,55 +175,62 @@ function parseName(name, rank, parent) {
|
|
|
174
175
|
if (/^\[(_|\d+)\] /.test(name)) {
|
|
175
176
|
name = name.replace(/^\[(_|\d+)\] /, '');
|
|
176
177
|
}
|
|
178
|
+
// Set verbatim identification after subsequent syntax is removed.
|
|
179
|
+
item.verbatimIdentification = name;
|
|
177
180
|
// Parent context is used for parsing and formatting binomial names.
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
181
|
+
// For formatting, it needs to match external databases (i.e. be correct).
|
|
182
|
+
// For parsing, it needs to match the current file. If relevant parents
|
|
183
|
+
// (i.e. genus, species) had mistakes that were corrected, the uncorrected
|
|
184
|
+
// genus and species names need to be used.
|
|
185
|
+
var parentContext = {
|
|
186
|
+
genus: parent.genus,
|
|
187
|
+
subgenus: parent.subgenus,
|
|
188
|
+
specificEpithet: parent.specificEpithet,
|
|
189
|
+
incorrect: {
|
|
190
|
+
genus: parent.incorrect && parent.incorrect.genus,
|
|
191
|
+
specificEpithet: parent.incorrect && parent.incorrect.specificEpithet
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
// Both contexts should be amended in the two cases where binomial names
|
|
195
|
+
// are fully used: (1) synonyms and (2) multinomial taxa without parents to
|
|
196
|
+
// provide parts of the name (e.g. bare species without a genus parent, or
|
|
197
|
+
// even subspecies without a species or genus parent).
|
|
185
198
|
if (isSynonym || !parentContext.genus || (compareRanks('species', rank) < 0 && !parentContext.specificEpithet)) {
|
|
186
199
|
var _a = name.match(BINAME_PATTERN) || [], genus = _a[1], subgenus = _a[2], species = _a[3];
|
|
187
200
|
if (genus) {
|
|
188
|
-
parentContext.genus = capitalize(genus);
|
|
189
|
-
if (parentContext.incorrect)
|
|
190
|
-
parentContext.incorrect.genus = capitalize(genus);
|
|
201
|
+
parentContext.genus = parentContext.incorrect.genus = capitalize(genus);
|
|
191
202
|
}
|
|
192
203
|
if (subgenus) {
|
|
193
204
|
parentContext.subgenus = capitalize(subgenus);
|
|
194
|
-
if (parentContext.incorrect)
|
|
195
|
-
parentContext.incorrect.subgenus = capitalize(subgenus);
|
|
196
205
|
}
|
|
197
206
|
else if (genus) {
|
|
198
|
-
// If a genus is given but no subgenus, remove
|
|
207
|
+
// If a genus is given but no subgenus, remove any existing subgenus
|
|
208
|
+
// from the parent context.
|
|
199
209
|
delete parentContext.subgenus;
|
|
200
|
-
if (parentContext.incorrect)
|
|
201
|
-
delete parentContext.incorrect.subgenus;
|
|
202
210
|
}
|
|
203
211
|
if (species) {
|
|
204
|
-
parentContext.specificEpithet = species;
|
|
205
|
-
if (parentContext.incorrect)
|
|
206
|
-
parentContext.incorrect.specificEpithet = species;
|
|
212
|
+
parentContext.specificEpithet = parentContext.incorrect.specificEpithet = species;
|
|
207
213
|
}
|
|
208
214
|
}
|
|
209
215
|
// In taxa of group, species or lower, the name should just contain the
|
|
210
|
-
// (
|
|
216
|
+
// (infra)specific epithet and the author information & remarks when processing
|
|
211
217
|
// further.
|
|
212
218
|
if (compareRanks('group', rank) <= 0) {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
219
|
+
// Remove genus
|
|
220
|
+
var genus = parentContext.incorrect.genus || parentContext.genus || '';
|
|
221
|
+
if (name[0] === genus[0] && name.toLowerCase().startsWith(genus.toLowerCase() + ' ')) {
|
|
222
|
+
name = name.slice(genus.length + 1);
|
|
223
|
+
}
|
|
224
|
+
// Remove subgenus
|
|
225
|
+
name = name.replace(/^\(.*?\) /, '');
|
|
226
|
+
// Infraspecific taxa
|
|
221
227
|
if (compareRanks('species', rank) < 0) {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
228
|
+
// Remove specific epithet
|
|
229
|
+
var species = parentContext.incorrect.specificEpithet || parentContext.specificEpithet || '';
|
|
230
|
+
if (name.startsWith(species + ' ')) {
|
|
231
|
+
name = name.slice(species.length + 1);
|
|
225
232
|
}
|
|
233
|
+
// Remove rank abbreviations
|
|
226
234
|
name = name.replace(/^(st|r|ab|f|var|ssp|subsp)\. /, '');
|
|
227
235
|
}
|
|
228
236
|
}
|
|
@@ -236,6 +244,11 @@ function parseName(name, rank, parent) {
|
|
|
236
244
|
if (!nameParts) {
|
|
237
245
|
throw new Error("Taxon \"".concat(name, "\" could not be parsed"));
|
|
238
246
|
}
|
|
247
|
+
// To encode old names with spaces (e.g. "Orsillus pini canariensis Lindberg, 1953")
|
|
248
|
+
// underscores are used, which are replaced here.
|
|
249
|
+
if (nameParts[1].includes('_')) {
|
|
250
|
+
nameParts[1] = nameParts[1].replace(/_/g, ' ');
|
|
251
|
+
}
|
|
239
252
|
var _ = nameParts[0], taxon = nameParts[1], _b = nameParts[2], citation = _b === void 0 ? '' : _b, notes = nameParts[3];
|
|
240
253
|
item.scientificNameAuthorship = capitalizeAuthors(citation);
|
|
241
254
|
item.taxonRemarks = notes;
|
|
@@ -284,21 +297,21 @@ function parseName(name, rank, parent) {
|
|
|
284
297
|
item.genericName = parentContext.genus;
|
|
285
298
|
item.infragenericEpithet = parentContext.subgenus;
|
|
286
299
|
item.specificEpithet = parentContext.specificEpithet;
|
|
287
|
-
item.
|
|
300
|
+
item.infraspecificEpithet = taxon.toLowerCase();
|
|
288
301
|
// If possible, names below species should have abbreviations for ranks,
|
|
289
302
|
// like "subsp."
|
|
290
303
|
var nameParts_1 = [
|
|
291
304
|
item.genericName,
|
|
292
305
|
item.specificEpithet,
|
|
293
|
-
item.
|
|
306
|
+
item.infraspecificEpithet
|
|
294
307
|
];
|
|
295
308
|
if (item.taxonRank in RANK_LABELS) {
|
|
296
309
|
nameParts_1.splice(2, 0, RANK_LABELS[item.taxonRank]);
|
|
297
310
|
}
|
|
298
311
|
item.scientificName = nameParts_1.join(' ');
|
|
299
|
-
if (item.
|
|
312
|
+
if (item.infraspecificEpithet !== taxon) {
|
|
300
313
|
console.log(item, taxon);
|
|
301
|
-
throw new Error("
|
|
314
|
+
throw new Error("Infraspecific epithet should be lowercase: \"".concat(taxon, "\""));
|
|
302
315
|
}
|
|
303
316
|
}
|
|
304
317
|
// Re-add authorship information
|
|
@@ -335,15 +348,8 @@ function parseHeader(header) {
|
|
|
335
348
|
else {
|
|
336
349
|
levels = config.levels;
|
|
337
350
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
scope = [];
|
|
341
|
-
}
|
|
342
|
-
else if (!Array.isArray(config.scope)) {
|
|
343
|
-
throw new SyntaxError('"scope" should be an array');
|
|
344
|
-
}
|
|
345
|
-
else {
|
|
346
|
-
scope = config.scope;
|
|
351
|
+
if ('scope' in config) {
|
|
352
|
+
throw new SyntaxError('"scope" data should go in "catalog"');
|
|
347
353
|
}
|
|
348
354
|
// No taxon ranks
|
|
349
355
|
if (levels.length === 0) {
|
|
@@ -354,9 +360,36 @@ function parseHeader(header) {
|
|
|
354
360
|
if (invalidTaxonRanks.length) {
|
|
355
361
|
throw new SyntaxError("\"levels\" contains invalid values: ".concat(invalidTaxonRanks.join(', ')));
|
|
356
362
|
}
|
|
357
|
-
var metadata = { levels: levels
|
|
363
|
+
var metadata = { levels: levels };
|
|
358
364
|
if ('catalog' in config && typeof config.catalog === 'object' && config.catalog !== null) {
|
|
359
|
-
|
|
365
|
+
var catalog = {};
|
|
366
|
+
for (var key in config.catalog) {
|
|
367
|
+
var value = config.catalog[key];
|
|
368
|
+
if (typeof value === 'number') {
|
|
369
|
+
catalog[key] = value.toString();
|
|
370
|
+
}
|
|
371
|
+
else if (typeof value === 'string') {
|
|
372
|
+
catalog[key] = value;
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
throw new SyntaxError("\"catalog\" should contain only strings (\"".concat(key, "\")"));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
var work = new work_1.Work(catalog);
|
|
379
|
+
var errors = work.validate().filter(function (_a) {
|
|
380
|
+
var error = _a.error;
|
|
381
|
+
return error !== 'Value(s) required but missing';
|
|
382
|
+
});
|
|
383
|
+
if (errors.length > 0) {
|
|
384
|
+
throw new SyntaxError("\"catalog\" contains errors: ".concat(errors.map(function (_a) {
|
|
385
|
+
var field = _a.field, error = _a.error;
|
|
386
|
+
return "[".concat(field, "] ").concat(error);
|
|
387
|
+
}).join('; ')));
|
|
388
|
+
}
|
|
389
|
+
metadata.catalog = {};
|
|
390
|
+
for (var key in work.fields) {
|
|
391
|
+
metadata.catalog[key] = work.get(key);
|
|
392
|
+
}
|
|
360
393
|
}
|
|
361
394
|
return metadata;
|
|
362
395
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { promises as fs } from 'fs'
|
|
3
|
+
import { promises as fs, existsSync as doesFileExist } from 'fs'
|
|
4
4
|
import * as path from 'path'
|
|
5
5
|
import { spawn } from 'child_process'
|
|
6
6
|
import * as util from 'util'
|
|
@@ -8,14 +8,20 @@ import * as util from 'util'
|
|
|
8
8
|
import { csv } from '../index'
|
|
9
9
|
import { prompt, promptForAnswers, numericSort, runCommand } from './util'
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
export enum ResourceProcessorSource {
|
|
12
|
+
All = 'all',
|
|
13
|
+
Unprocessed = 'unprocessed',
|
|
14
|
+
Modified = 'modified'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const DWC_FIELDS: (keyof AmendedTaxon)[] = [
|
|
12
18
|
'scientificNameID',
|
|
13
19
|
'scientificName',
|
|
14
20
|
'scientificNameAuthorship',
|
|
15
21
|
'genericName',
|
|
16
|
-
'
|
|
22
|
+
'infragenericEpithet',
|
|
17
23
|
'specificEpithet',
|
|
18
|
-
'
|
|
24
|
+
'infraspecificEpithet',
|
|
19
25
|
|
|
20
26
|
'taxonRank',
|
|
21
27
|
'taxonRemarks',
|
|
@@ -36,6 +42,7 @@ const DWC_FIELDS: string[] = [
|
|
|
36
42
|
'genus',
|
|
37
43
|
'subgenus',
|
|
38
44
|
'higherClassification',
|
|
45
|
+
'verbatimIdentification',
|
|
39
46
|
|
|
40
47
|
'colTaxonID',
|
|
41
48
|
'gbifTaxonID',
|
|
@@ -43,7 +50,7 @@ const DWC_FIELDS: string[] = [
|
|
|
43
50
|
'gbifAcceptedTaxonID'
|
|
44
51
|
]
|
|
45
52
|
|
|
46
|
-
const DISPLAY_FIELDS:
|
|
53
|
+
const DISPLAY_FIELDS: (keyof AmendedTaxon)[] = [
|
|
47
54
|
'scientificNameID',
|
|
48
55
|
'taxonRank',
|
|
49
56
|
'scientificName',
|
|
@@ -83,11 +90,22 @@ function runGnverifier (names: string): Promise<string> {
|
|
|
83
90
|
})
|
|
84
91
|
}
|
|
85
92
|
|
|
93
|
+
async function listFiles (directory: string): Promise<string[]> {
|
|
94
|
+
const input = await fs.readdir(directory)
|
|
95
|
+
return input.map(file => path.basename(file, '.txt')).sort(numericSort)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function listUnprocessedFiles (directory: string, outputDirectory: string): Promise<string[]> {
|
|
99
|
+
const input = await listFiles(directory)
|
|
100
|
+
const output = new Set(await fs.readdir(outputDirectory))
|
|
101
|
+
return input.filter(file => output.has(file + '-1'))
|
|
102
|
+
}
|
|
103
|
+
|
|
86
104
|
async function listChangedFiles (directory: string): Promise<string[]> {
|
|
87
105
|
const output = await runCommand('git', ['diff', '--name-only', 'HEAD', '--', directory], {
|
|
88
106
|
cwd: directory
|
|
89
107
|
})
|
|
90
|
-
return output.trimEnd().split('\n').sort(numericSort)
|
|
108
|
+
return output.trimEnd().split('\n').map(file => path.basename(file, '.txt')).sort(numericSort)
|
|
91
109
|
}
|
|
92
110
|
|
|
93
111
|
async function getOldFile (file: string): Promise<string> {
|
|
@@ -111,49 +129,32 @@ class ResourceProcessor {
|
|
|
111
129
|
this.FILE_PROBLEMS = path.join(this.DIR_ROOT, 'problems.csv')
|
|
112
130
|
}
|
|
113
131
|
|
|
114
|
-
async run (): Promise<void> {
|
|
115
|
-
const
|
|
116
|
-
const output = await fs.readdir(this.DIR_DWC)
|
|
117
|
-
|
|
118
|
-
const ids = input
|
|
119
|
-
.map(file => path.basename(file, '.txt'))
|
|
120
|
-
.sort((a, b) => parseInt(a.slice(1)) - parseInt(b.slice(1)))
|
|
121
|
-
|
|
132
|
+
async run (source: ResourceProcessorSource, config: ResourceProcessorConfig): Promise<void> {
|
|
133
|
+
const ids = await this.listWorks(source)
|
|
122
134
|
for (const id of ids) {
|
|
123
|
-
|
|
124
|
-
if (output.some(file => file.startsWith(id + '-'))) {
|
|
125
|
-
continue
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
await this.processWork(id)
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
async runUpdate (): Promise<void> {
|
|
133
|
-
for (const file of await listChangedFiles(this.DIR_TXT)) {
|
|
134
|
-
const id = path.basename(file, '.txt')
|
|
135
|
-
await this.processWork(id, true)
|
|
135
|
+
await this.processWork(id, config)
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
async
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
139
|
+
async listWorks (source: ResourceProcessorSource): Promise<string[]> {
|
|
140
|
+
switch (source) {
|
|
141
|
+
case ResourceProcessorSource.All:
|
|
142
|
+
return listFiles(this.DIR_TXT)
|
|
143
|
+
case ResourceProcessorSource.Unprocessed:
|
|
144
|
+
return listUnprocessedFiles(this.DIR_TXT, this.DIR_DWC)
|
|
145
|
+
case ResourceProcessorSource.Modified:
|
|
146
|
+
return listChangedFiles(this.DIR_TXT)
|
|
147
|
+
default:
|
|
148
|
+
return []
|
|
148
149
|
}
|
|
149
150
|
}
|
|
150
151
|
|
|
151
|
-
async processWork (id: WorkId,
|
|
152
|
-
const resources = await this.processResources(id,
|
|
152
|
+
async processWork (id: WorkId, config: ResourceProcessorConfig): Promise<void> {
|
|
153
|
+
const resources = await this.processResources(id, config)
|
|
153
154
|
|
|
154
155
|
await Promise.all(resources.map(resource => {
|
|
155
156
|
const header = DWC_FIELDS
|
|
156
|
-
const table = [header]
|
|
157
|
+
const table: string[][] = [header]
|
|
157
158
|
|
|
158
159
|
for (const id in resource.taxa) {
|
|
159
160
|
const taxon = resource.taxa[id] as unknown as Record<string, string | undefined>
|
|
@@ -164,12 +165,12 @@ class ResourceProcessor {
|
|
|
164
165
|
}))
|
|
165
166
|
}
|
|
166
167
|
|
|
167
|
-
async processResources (id: WorkId,
|
|
168
|
-
const resources = await this.processResourceText(id,
|
|
168
|
+
async processResources (id: WorkId, config: ResourceProcessorConfig): Promise<AmendedResource[]> {
|
|
169
|
+
const resources = await this.processResourceText(id, config)
|
|
169
170
|
|
|
170
171
|
const amendedResources = []
|
|
171
172
|
for (const resource of resources) {
|
|
172
|
-
const results = await this.processResourceDwc(resource)
|
|
173
|
+
const results = await this.processResourceDwc(resource, config)
|
|
173
174
|
|
|
174
175
|
const skip = await this.shouldBeSkipped(resource.id)
|
|
175
176
|
|
|
@@ -198,7 +199,7 @@ class ResourceProcessor {
|
|
|
198
199
|
case 'r':
|
|
199
200
|
case 'R': {
|
|
200
201
|
console.log(`${resource.workId}: retrying ${resource.id}`)
|
|
201
|
-
return this.processResources(id,
|
|
202
|
+
return this.processResources(id, config)
|
|
202
203
|
}
|
|
203
204
|
}
|
|
204
205
|
}
|
|
@@ -210,14 +211,14 @@ class ResourceProcessor {
|
|
|
210
211
|
return amendedResources
|
|
211
212
|
}
|
|
212
213
|
|
|
213
|
-
async processResourceText (id: WorkId,
|
|
214
|
+
async processResourceText (id: WorkId, config: ResourceProcessorConfig): Promise<Resource[]> {
|
|
214
215
|
try {
|
|
215
216
|
console.log(`${id}: generating Darwin Core`)
|
|
216
217
|
const filePath = path.join(this.DIR_TXT, id + '.txt')
|
|
217
218
|
const file = await fs.readFile(filePath, 'utf-8')
|
|
218
219
|
|
|
219
220
|
let old = undefined
|
|
220
|
-
if (update) {
|
|
221
|
+
if (config.update) {
|
|
221
222
|
const dwc = []
|
|
222
223
|
for (const file of await fs.readdir(this.DIR_DWC)) {
|
|
223
224
|
if (file.startsWith(id + '-')) {
|
|
@@ -243,13 +244,34 @@ class ResourceProcessor {
|
|
|
243
244
|
}
|
|
244
245
|
}
|
|
245
246
|
|
|
246
|
-
return this.processResourceText(id,
|
|
247
|
+
return this.processResourceText(id, config)
|
|
247
248
|
}
|
|
248
249
|
}
|
|
249
250
|
|
|
250
|
-
async processResourceDwc (resource: Resource): Promise<AmendedResource> {
|
|
251
|
+
async processResourceDwc (resource: Resource, config: ResourceProcessorConfig): Promise<AmendedResource> {
|
|
251
252
|
console.log(`${resource.workId}: matching ${resource.id}`)
|
|
252
253
|
|
|
254
|
+
if (!config.updateMappings) {
|
|
255
|
+
const file = path.join(this.DIR_DWC, resource.file + '.csv')
|
|
256
|
+
if (doesFileExist(file)) {
|
|
257
|
+
const [header, ...rows] = csv.parseCsv(await fs.readFile(file, 'utf-8'))
|
|
258
|
+
for (const row of rows) {
|
|
259
|
+
const oldTaxon = row.reduce((taxon, value, index) => {
|
|
260
|
+
taxon[header[index]] = value
|
|
261
|
+
return taxon
|
|
262
|
+
}, {} as Record<string, string>)
|
|
263
|
+
const taxon = resource.taxa[oldTaxon.scientificNameID] as AmendedTaxon
|
|
264
|
+
if (taxon) {
|
|
265
|
+
taxon.colTaxonID = oldTaxon.colTaxonID
|
|
266
|
+
taxon.colAcceptedTaxonID = oldTaxon.colAcceptedTaxonID
|
|
267
|
+
taxon.gbifTaxonID = oldTaxon.gbifTaxonID
|
|
268
|
+
taxon.gbifAcceptedTaxonID = oldTaxon.gbifAcceptedTaxonID
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return resource as AmendedResource
|
|
273
|
+
}
|
|
274
|
+
|
|
253
275
|
const filteredResults: Record<TaxonId, TaxonMatch[]> = {}
|
|
254
276
|
const taxonNames: Record<string, TaxonId[]> = {}
|
|
255
277
|
const names = new Set()
|
|
@@ -429,12 +451,14 @@ class ResourceProcessor {
|
|
|
429
451
|
function main (): void {
|
|
430
452
|
const args = util.parseArgs({
|
|
431
453
|
options: {
|
|
432
|
-
|
|
433
|
-
type: '
|
|
434
|
-
short: '
|
|
454
|
+
source: {
|
|
455
|
+
type: 'string',
|
|
456
|
+
short: 's',
|
|
457
|
+
default: 'unprocessed'
|
|
435
458
|
},
|
|
436
|
-
'
|
|
437
|
-
type: 'boolean'
|
|
459
|
+
'keep-mappings': {
|
|
460
|
+
type: 'boolean',
|
|
461
|
+
short: 'k'
|
|
438
462
|
}
|
|
439
463
|
},
|
|
440
464
|
allowPositionals: true
|
|
@@ -445,16 +469,13 @@ function main (): void {
|
|
|
445
469
|
process.stdout.write('\n')
|
|
446
470
|
})
|
|
447
471
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
task = processor.runMappingsUpdate()
|
|
453
|
-
} else {
|
|
454
|
-
task = processor.run()
|
|
472
|
+
const source = args.values.source as ResourceProcessorSource
|
|
473
|
+
const config: ResourceProcessorConfig = {
|
|
474
|
+
update: source !== 'unprocessed',
|
|
475
|
+
updateMappings: !args.values['keep-mappings']
|
|
455
476
|
}
|
|
456
477
|
|
|
457
|
-
|
|
478
|
+
processor.run(source, config).catch(error => {
|
|
458
479
|
console.error(error)
|
|
459
480
|
process.exit(1)
|
|
460
481
|
})
|
package/src/catalog/value.ts
CHANGED
|
@@ -24,11 +24,11 @@ export const FORMATS = {
|
|
|
24
24
|
|
|
25
25
|
export const CHECK = {
|
|
26
26
|
MULTILANG (entry: Record<string, Value>) {
|
|
27
|
-
return entry.language.length > 1
|
|
27
|
+
return Array.isArray(entry.language) && entry.language.length > 1
|
|
28
28
|
},
|
|
29
29
|
|
|
30
30
|
ISBN (entry: Record<string, Value>) {
|
|
31
|
-
if (entry.ISBN.length < 2) { return false }
|
|
31
|
+
if (!Array.isArray(entry.ISBN) || entry.ISBN.length < 2) { return false }
|
|
32
32
|
const a = entry.ISBN[0].length === 10
|
|
33
33
|
const b = entry.ISBN[0].length === 13
|
|
34
34
|
const c = entry.ISBN[1].length === 10
|
package/src/csv.ts
CHANGED
|
@@ -23,7 +23,7 @@ export function formatCsv (table: string[][], delimiter = ',') {
|
|
|
23
23
|
.map((row: string[]) => {
|
|
24
24
|
return row.map(value => {
|
|
25
25
|
if (/["\n]/.test(value) || value.includes(delimiter)) {
|
|
26
|
-
return `"${value.replace(/"
|
|
26
|
+
return `"${value.replace(/"/g, '""')}"`
|
|
27
27
|
} else {
|
|
28
28
|
return value
|
|
29
29
|
}
|
package/src/module.d.ts
CHANGED
|
@@ -34,7 +34,7 @@ interface WorkingTaxon {
|
|
|
34
34
|
genericName?: string,
|
|
35
35
|
infragenericEpithet?: string,
|
|
36
36
|
specificEpithet?: string,
|
|
37
|
-
|
|
37
|
+
infraspecificEpithet?: string,
|
|
38
38
|
|
|
39
39
|
taxonRank?: Rank,
|
|
40
40
|
taxonRemarks?: string,
|
|
@@ -55,6 +55,7 @@ interface WorkingTaxon {
|
|
|
55
55
|
genus?: string,
|
|
56
56
|
subgenus?: string,
|
|
57
57
|
higherClassification?: string,
|
|
58
|
+
verbatimIdentification?: string,
|
|
58
59
|
|
|
59
60
|
// Non-standard
|
|
60
61
|
scientificNameOnly?: string,
|
|
@@ -66,13 +67,13 @@ interface Taxon extends WorkingTaxon {
|
|
|
66
67
|
scientificName: string,
|
|
67
68
|
taxonRank: Rank,
|
|
68
69
|
collectionCode: ResourceId,
|
|
69
|
-
taxonomicStatus: string
|
|
70
|
+
taxonomicStatus: string,
|
|
71
|
+
verbatimIdentification: string
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
interface ResourceMetadata {
|
|
73
75
|
levels: Rank[],
|
|
74
|
-
|
|
75
|
-
catalog?: object
|
|
76
|
+
catalog?: Record<string, Value>
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
interface Resource {
|
|
@@ -123,3 +124,14 @@ interface TaxonMatch {
|
|
|
123
124
|
}
|
|
124
125
|
|
|
125
126
|
type GroupedNameMatches = Record<string, Record<string, Record<TaxonId, TaxonMatch>>>
|
|
127
|
+
|
|
128
|
+
declare enum ResourceProcessorSource {
|
|
129
|
+
All = 'all',
|
|
130
|
+
Unprocessed = 'unprocessed',
|
|
131
|
+
Modified = 'modified'
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface ResourceProcessorConfig {
|
|
135
|
+
update: boolean,
|
|
136
|
+
updateMappings: boolean
|
|
137
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as yaml from 'js-yaml'
|
|
2
|
+
import { Work } from '../catalog/tables/work'
|
|
2
3
|
import { createDiff, ResourceDiffType } from './diff-resource'
|
|
3
4
|
|
|
4
5
|
const RANKS: Rank[] = [
|
|
@@ -120,9 +121,9 @@ const NAME_PATTERN = new RegExp(
|
|
|
120
121
|
* $1 genus+subgenus (+ trailing space): (?:([A-Z]\S+) (?:\(([A-Z]\S+?)\) )?)?
|
|
121
122
|
* $1.1 genus: ([A-Z]\S+)
|
|
122
123
|
* $1.2 subgenus: (?:\(([A-Z]\S+?)\) )?
|
|
123
|
-
* $2 species: ((?:x )?[a-z]
|
|
124
|
+
* $2 species: ((?:x )?[a-z][^\s.]+)
|
|
124
125
|
*/
|
|
125
|
-
const BINAME_PATTERN = /^(?:([A-Z]\S+) (?:\(([A-Z]\S+?)\) )?)?((?:x )?[a-z]
|
|
126
|
+
const BINAME_PATTERN = /^(?:([A-Z]\S+) (?:\(([A-Z]\S+?)\) )?)?((?:x )?[a-z][^\s.]+)(?= |$)/
|
|
126
127
|
|
|
127
128
|
function compareRanks (a: Rank, b: Rank): number {
|
|
128
129
|
return RANKS.indexOf(a) - RANKS.indexOf(b)
|
|
@@ -178,49 +179,67 @@ function parseName (name: string, rank: Rank, parent: WorkingTaxon): WorkingTaxo
|
|
|
178
179
|
name = name.replace(/^\[(_|\d+)\] /, '')
|
|
179
180
|
}
|
|
180
181
|
|
|
182
|
+
// Set verbatim identification after subsequent syntax is removed.
|
|
183
|
+
item.verbatimIdentification = name
|
|
184
|
+
|
|
181
185
|
// Parent context is used for parsing and formatting binomial names.
|
|
182
|
-
|
|
183
|
-
|
|
186
|
+
// For formatting, it needs to match external databases (i.e. be correct).
|
|
187
|
+
// For parsing, it needs to match the current file. If relevant parents
|
|
188
|
+
// (i.e. genus, species) had mistakes that were corrected, the uncorrected
|
|
189
|
+
// genus and species names need to be used.
|
|
190
|
+
const parentContext = {
|
|
191
|
+
genus: parent.genus,
|
|
192
|
+
subgenus: parent.subgenus,
|
|
193
|
+
specificEpithet: parent.specificEpithet,
|
|
194
|
+
incorrect: {
|
|
195
|
+
genus: parent.incorrect && parent.incorrect.genus,
|
|
196
|
+
specificEpithet: parent.incorrect && parent.incorrect.specificEpithet
|
|
197
|
+
}
|
|
198
|
+
}
|
|
184
199
|
|
|
185
|
-
//
|
|
186
|
-
// are
|
|
187
|
-
//
|
|
200
|
+
// Both contexts should be amended in the two cases where binomial names
|
|
201
|
+
// are fully used: (1) synonyms and (2) multinomial taxa without parents to
|
|
202
|
+
// provide parts of the name (e.g. bare species without a genus parent, or
|
|
203
|
+
// even subspecies without a species or genus parent).
|
|
188
204
|
if (isSynonym || !parentContext.genus || (compareRanks('species', rank) < 0 && !parentContext.specificEpithet)) {
|
|
189
205
|
const [, genus, subgenus, species] = name.match(BINAME_PATTERN) || []
|
|
190
206
|
if (genus) {
|
|
191
|
-
parentContext.genus = capitalize(genus)
|
|
192
|
-
if (parentContext.incorrect) parentContext.incorrect.genus = capitalize(genus)
|
|
207
|
+
parentContext.genus = parentContext.incorrect.genus = capitalize(genus)
|
|
193
208
|
}
|
|
194
209
|
if (subgenus) {
|
|
195
210
|
parentContext.subgenus = capitalize(subgenus)
|
|
196
|
-
if (parentContext.incorrect) parentContext.incorrect.subgenus = capitalize(subgenus)
|
|
197
211
|
} else if (genus) {
|
|
198
|
-
// If a genus is given but no subgenus, remove
|
|
212
|
+
// If a genus is given but no subgenus, remove any existing subgenus
|
|
213
|
+
// from the parent context.
|
|
199
214
|
delete parentContext.subgenus
|
|
200
|
-
if (parentContext.incorrect) delete parentContext.incorrect.subgenus
|
|
201
215
|
}
|
|
202
216
|
if (species) {
|
|
203
|
-
parentContext.specificEpithet = species
|
|
204
|
-
if (parentContext.incorrect) parentContext.incorrect.specificEpithet = species
|
|
217
|
+
parentContext.specificEpithet = parentContext.incorrect.specificEpithet = species
|
|
205
218
|
}
|
|
206
219
|
}
|
|
207
220
|
|
|
208
221
|
// In taxa of group, species or lower, the name should just contain the
|
|
209
|
-
// (
|
|
222
|
+
// (infra)specific epithet and the author information & remarks when processing
|
|
210
223
|
// further.
|
|
211
224
|
if (compareRanks('group', rank) <= 0) {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
name = name.replace(genusPrefix, '')
|
|
225
|
+
// Remove genus
|
|
226
|
+
const genus = parentContext.incorrect.genus || parentContext.genus || ''
|
|
227
|
+
if (name[0] === genus[0] && name.toLowerCase().startsWith(genus.toLowerCase() + ' ')) {
|
|
228
|
+
name = name.slice(genus.length + 1)
|
|
217
229
|
}
|
|
218
230
|
|
|
231
|
+
// Remove subgenus
|
|
232
|
+
name = name.replace(/^\(.*?\) /, '')
|
|
233
|
+
|
|
234
|
+
// Infraspecific taxa
|
|
219
235
|
if (compareRanks('species', rank) < 0) {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
236
|
+
// Remove specific epithet
|
|
237
|
+
const species = parentContext.incorrect.specificEpithet || parentContext.specificEpithet || ''
|
|
238
|
+
if (name.startsWith(species + ' ')) {
|
|
239
|
+
name = name.slice(species.length + 1)
|
|
223
240
|
}
|
|
241
|
+
|
|
242
|
+
// Remove rank abbreviations
|
|
224
243
|
name = name.replace(/^(st|r|ab|f|var|ssp|subsp)\. /, '')
|
|
225
244
|
}
|
|
226
245
|
}
|
|
@@ -236,6 +255,13 @@ function parseName (name: string, rank: Rank, parent: WorkingTaxon): WorkingTaxo
|
|
|
236
255
|
if (!nameParts) {
|
|
237
256
|
throw new Error(`Taxon "${name}" could not be parsed`)
|
|
238
257
|
}
|
|
258
|
+
|
|
259
|
+
// To encode old names with spaces (e.g. "Orsillus pini canariensis Lindberg, 1953")
|
|
260
|
+
// underscores are used, which are replaced here.
|
|
261
|
+
if (nameParts[1].includes('_')) {
|
|
262
|
+
nameParts[1] = nameParts[1].replace(/_/g, ' ')
|
|
263
|
+
}
|
|
264
|
+
|
|
239
265
|
const [_, taxon, citation = '', notes] = nameParts
|
|
240
266
|
item.scientificNameAuthorship = capitalizeAuthors(citation)
|
|
241
267
|
item.taxonRemarks = notes
|
|
@@ -282,23 +308,23 @@ function parseName (name: string, rank: Rank, parent: WorkingTaxon): WorkingTaxo
|
|
|
282
308
|
item.genericName = parentContext.genus
|
|
283
309
|
item.infragenericEpithet = parentContext.subgenus
|
|
284
310
|
item.specificEpithet = parentContext.specificEpithet
|
|
285
|
-
item.
|
|
311
|
+
item.infraspecificEpithet = taxon.toLowerCase()
|
|
286
312
|
|
|
287
313
|
// If possible, names below species should have abbreviations for ranks,
|
|
288
314
|
// like "subsp."
|
|
289
315
|
const nameParts = [
|
|
290
316
|
item.genericName,
|
|
291
317
|
item.specificEpithet,
|
|
292
|
-
item.
|
|
318
|
+
item.infraspecificEpithet
|
|
293
319
|
]
|
|
294
320
|
if (item.taxonRank in RANK_LABELS) {
|
|
295
321
|
nameParts.splice(2, 0, RANK_LABELS[item.taxonRank])
|
|
296
322
|
}
|
|
297
323
|
item.scientificName = nameParts.join(' ')
|
|
298
324
|
|
|
299
|
-
if (item.
|
|
325
|
+
if (item.infraspecificEpithet !== taxon) {
|
|
300
326
|
console.log(item, taxon)
|
|
301
|
-
throw new Error(`
|
|
327
|
+
throw new Error(`Infraspecific epithet should be lowercase: "${taxon}"`)
|
|
302
328
|
}
|
|
303
329
|
}
|
|
304
330
|
|
|
@@ -341,13 +367,8 @@ function parseHeader (header: string): ResourceMetadata {
|
|
|
341
367
|
levels = config.levels
|
|
342
368
|
}
|
|
343
369
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
scope = []
|
|
347
|
-
} else if (!Array.isArray(config.scope)) {
|
|
348
|
-
throw new SyntaxError('"scope" should be an array')
|
|
349
|
-
} else {
|
|
350
|
-
scope = config.scope
|
|
370
|
+
if ('scope' in config) {
|
|
371
|
+
throw new SyntaxError('"scope" data should go in "catalog"')
|
|
351
372
|
}
|
|
352
373
|
|
|
353
374
|
// No taxon ranks
|
|
@@ -361,10 +382,29 @@ function parseHeader (header: string): ResourceMetadata {
|
|
|
361
382
|
throw new SyntaxError(`"levels" contains invalid values: ${invalidTaxonRanks.join(', ')}`)
|
|
362
383
|
}
|
|
363
384
|
|
|
364
|
-
const metadata: ResourceMetadata = { levels
|
|
385
|
+
const metadata: ResourceMetadata = { levels }
|
|
365
386
|
|
|
366
387
|
if ('catalog' in config && typeof config.catalog === 'object' && config.catalog !== null) {
|
|
367
|
-
|
|
388
|
+
const catalog: Record<string, string> = {}
|
|
389
|
+
for (const key in config.catalog) {
|
|
390
|
+
const value = config.catalog[key as keyof object]
|
|
391
|
+
if (typeof value === 'number') {
|
|
392
|
+
catalog[key] = (value as number).toString()
|
|
393
|
+
} else if (typeof value === 'string') {
|
|
394
|
+
catalog[key] = value
|
|
395
|
+
} else {
|
|
396
|
+
throw new SyntaxError(`"catalog" should contain only strings ("${key}")`)
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
const work = new Work(catalog)
|
|
400
|
+
const errors = work.validate().filter(({ error }) => error !== 'Value(s) required but missing')
|
|
401
|
+
if (errors.length > 0) {
|
|
402
|
+
throw new SyntaxError(`"catalog" contains errors: ${errors.map(({ field, error }) => `[${field}] ${error}`).join('; ')}`)
|
|
403
|
+
}
|
|
404
|
+
metadata.catalog = {}
|
|
405
|
+
for (const key in work.fields) {
|
|
406
|
+
metadata.catalog[key] = work.get(key) as Value
|
|
407
|
+
}
|
|
368
408
|
}
|
|
369
409
|
|
|
370
410
|
return metadata
|
package/test/resources.js
CHANGED
|
@@ -100,4 +100,28 @@ Nematus fåhraei Thomson
|
|
|
100
100
|
`, 'T1')
|
|
101
101
|
assert.strictEqual(resource.taxa['T1:1:1'].scientificName, 'Nematus fåhraei Thomson')
|
|
102
102
|
})
|
|
103
|
+
|
|
104
|
+
await t.test('parses synonyms in different genera', (t) => {
|
|
105
|
+
const [resource] = resources.parseTextFile(`---
|
|
106
|
+
levels: [species]
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
Katamenes arbustorum subsp. burlinii
|
|
110
|
+
> arbustorum subsp. burlinii
|
|
111
|
+
= Eumenes arbustorum var. burlinii
|
|
112
|
+
`, 'T1')
|
|
113
|
+
assert.strictEqual(resource.taxa['T1:1:2'].scientificName, 'Eumenes arbustorum var. burlinii')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
await t.test('parses species without generic names', (t) => {
|
|
117
|
+
const [resource] = resources.parseTextFile(`---
|
|
118
|
+
levels: [genus, subgenus, species]
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
Microdynerus Thomson, 1874
|
|
122
|
+
Alastorynerus Blüthgen, 1938
|
|
123
|
+
microdynerus (Dalla Torre, 1889)
|
|
124
|
+
`, 'T1')
|
|
125
|
+
assert.strictEqual(resource.taxa['T1:1:3'].scientificName, 'Microdynerus microdynerus (Dalla Torre, 1889)')
|
|
126
|
+
})
|
|
103
127
|
})
|