@larsgw/formica 0.4.2 → 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.
@@ -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-z0-9-]+)
126
+ * $2 species: ((?:x )?[a-z][^\s.]+)
126
127
  */
127
- var BINAME_PATTERN = /^(?:([A-Z]\S+) (?:\(([A-Z]\S+?)\) )?)?((?:x )?[a-z0-9-]+)(?= |$)/;
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
- var parentContext = __assign({}, parent);
179
- if (parent.incorrect) {
180
- parentContext.incorrect = __assign({}, parent.incorrect);
181
- }
182
- // The parent context should be amended in the two cases where binomial names
183
- // are truly accepted: synonyms and species (and below) without parents (resp.
184
- // genera and genera and species) to provide parts of the name.
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 it from the parent context
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
- // (inter)specific epithet and the author information & remarks when processing
216
+ // (infra)specific epithet and the author information & remarks when processing
211
217
  // further.
212
218
  if (compareRanks('group', rank) <= 0) {
213
- var parseContext = parentContext.incorrect || parentContext;
214
- if (!parseContext.genus) {
215
- parseContext.genus = name.split(' ', 1)[0];
216
- }
217
- var genusPrefix = new RegExp("^".concat(parentContext.genus, " (\\(.*?\\) )?"), 'i');
218
- if (name[0] === parentContext.genus[0]) {
219
- name = name.replace(genusPrefix, '');
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
- var speciesPrefix = parseContext.specificEpithet + ' ';
223
- if (name.startsWith(speciesPrefix)) {
224
- name = name.slice(speciesPrefix.length);
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.intraspecificEpithet = taxon.toLowerCase();
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.intraspecificEpithet
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.intraspecificEpithet !== taxon) {
312
+ if (item.infraspecificEpithet !== taxon) {
300
313
  console.log(item, taxon);
301
- throw new Error("Intraspecific epithet should be lowercase: \"".concat(taxon, "\""));
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
- var scope;
339
- if (!('scope' in config)) {
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, scope: scope };
363
+ var metadata = { levels: levels };
358
364
  if ('catalog' in config && typeof config.catalog === 'object' && config.catalog !== null) {
359
- metadata.catalog = config.catalog;
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
  {
2
2
  "name": "@larsgw/formica",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "SDK and tools for data from the Library of Identification Resources",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -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
- const DWC_FIELDS: string[] = [
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
- 'intragenericEpithet',
22
+ 'infragenericEpithet',
17
23
  'specificEpithet',
18
- 'intraspecificEpithet',
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: string[] = [
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 input = await fs.readdir(this.DIR_TXT)
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
- // Skip existing files
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 runMappingsUpdate (): Promise<void> {
140
- const input = await fs.readdir(this.DIR_TXT)
141
-
142
- const ids = input
143
- .map(file => path.basename(file, '.txt'))
144
- .sort((a, b) => parseInt(a.slice(1)) - parseInt(b.slice(1)))
145
-
146
- for (const id of ids) {
147
- await this.processWork(id, true)
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, update?: boolean): Promise<void> {
152
- const resources = await this.processResources(id, update)
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, update?: boolean): Promise<AmendedResource[]> {
168
- const resources = await this.processResourceText(id, update)
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, update)
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, update?: boolean): Promise<Resource[]> {
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, update)
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
- update: {
433
- type: 'boolean',
434
- short: 'u'
454
+ source: {
455
+ type: 'string',
456
+ short: 's',
457
+ default: 'unprocessed'
435
458
  },
436
- 'update-mappings': {
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
- let task
449
- if (args.values.update) {
450
- task = processor.runUpdate()
451
- } else if (args.values['update-mappings']) {
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
- task.catch(error => {
478
+ processor.run(source, config).catch(error => {
458
479
  console.error(error)
459
480
  process.exit(1)
460
481
  })
@@ -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
- intraspecificEpithet?: string,
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
- scope: string[],
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
+ }