@larsgw/formica 0.8.7 → 0.9.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 CHANGED
@@ -1,3 +1,27 @@
1
+ # [0.9.0](https://github.com/identification-resources/formica/compare/v0.8.8...v0.9.0) (2026-02-04)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **resources:** improve taxonomic status ([0ea8fd2](https://github.com/identification-resources/formica/commit/0ea8fd220571f5f6ff05f517069aba10148c6888))
7
+
8
+
9
+ ### Features
10
+
11
+ * **resources:** include cluster data in DwC files ([60a28ac](https://github.com/identification-resources/formica/commit/60a28ace18b0760696735ca416a2e8d014d1c928))
12
+ * **resources:** include uncertainty of synonymy in DwC if specified ([03b6734](https://github.com/identification-resources/formica/commit/03b6734851dc9c331ec88fab21e90ad3f0303c3c))
13
+
14
+
15
+
16
+ ## [0.8.8](https://github.com/identification-resources/formica/compare/v0.8.7...v0.8.8) (2025-12-25)
17
+
18
+
19
+ ### Features
20
+
21
+ * **resources:** add 'subclass' rank ([4b10405](https://github.com/identification-resources/formica/commit/4b10405b32840c1675caf7ef24bf2bbfd821a236))
22
+
23
+
24
+
1
25
  ## [0.8.7](https://github.com/identification-resources/formica/compare/v0.8.6...v0.8.7) (2025-11-01)
2
26
 
3
27
 
@@ -82,6 +82,7 @@ const DWC_FIELDS = [
82
82
  'subgenus',
83
83
  'higherClassification',
84
84
  'verbatimIdentification',
85
+ 'dynamicProperties',
85
86
  'colTaxonID',
86
87
  'gbifTaxonID',
87
88
  'colAcceptedTaxonID',
@@ -13,6 +13,7 @@ exports.RANKS = [
13
13
  'phylum',
14
14
  'subphylum',
15
15
  'class',
16
+ 'subclass',
16
17
  'infraclass',
17
18
  'superorder',
18
19
  'order',
@@ -42,7 +43,7 @@ exports.RANKS = [
42
43
  ];
43
44
  const TAXONOMIC_STATUS = {
44
45
  '>': 'incorrect',
45
- '+': 'heterotypic synonym',
46
+ '+': 'proparte synonym',
46
47
  '=': 'synonym'
47
48
  };
48
49
  const RANK_LABELS = {
@@ -112,6 +113,7 @@ const SUBGENUS_PATTERN = /^([A-Z]\S+) (?:\(([A-Z]\S+?)\))(?= |$)/;
112
113
  * $2c intergeneric hybrid: [A-Z][a-z]+_[a-z-]+ x [A-Z][a-z]+_[a-z-]+
113
114
  */
114
115
  const BINAME_PATTERN = /^(?:((?:x )?[A-Z]\S+) (?:\(([A-Z]\S+?)\) )?)?(x [a-z-]+|[a-z-][^\s.]+(?: x [a-z-]+)?|[A-Z][a-z]+_[a-z-]+ x [A-Z][a-z]+_[a-z-]+)(?= |$)/;
116
+ const CLUSTER_PATTERN = /^\[(_|\d+)\] /;
115
117
  function compareRanks(a, b) {
116
118
  return exports.RANKS.indexOf(a) - exports.RANKS.indexOf(b);
117
119
  }
@@ -157,15 +159,21 @@ function parseName(name, rank, parent) {
157
159
  const isSynonym = /^[+=>] /.test(name);
158
160
  if (isSynonym) {
159
161
  item.taxonomicStatus = TAXONOMIC_STATUS[name[0]];
160
- name = name.replace(/^[+=>] (\? ?)?/, '');
162
+ name = name.slice(2);
163
+ if (/^\? ?/.test(name)) {
164
+ item.dynamicProperties = '{"synonymUncertain":true}';
165
+ name = name.replace(/^\? ?/, '');
166
+ }
161
167
  rank = getSynonymRank(name, parent.taxonRank);
162
168
  }
163
169
  else {
164
170
  item.taxonomicStatus = 'accepted';
165
171
  }
166
172
  // Clusters
167
- if (/^\[(_|\d+)\] /.test(name)) {
168
- name = name.replace(/^\[(_|\d+)\] /, '');
173
+ if (CLUSTER_PATTERN.test(name)) {
174
+ const [match, cluster] = name.match(CLUSTER_PATTERN);
175
+ item.cluster = cluster;
176
+ name = name.slice(match.length);
169
177
  }
170
178
  // Set verbatim identification after subsequent syntax is removed.
171
179
  item.verbatimIdentification = name.replace(/(?<=^| )x(?=$| )/g, HYBRID_SIGN).replace(/_/g, ' ');
@@ -350,5 +358,8 @@ function parseName(name, rank, parent) {
350
358
  if (item.scientificNameAuthorship) {
351
359
  item.scientificName += ` ${item.scientificNameAuthorship}`;
352
360
  }
361
+ if (isSynonym && /^auctt?\./.test(item.taxonRemarks)) {
362
+ item.taxonomicStatus = 'misapplied';
363
+ }
353
364
  return item;
354
365
  }
@@ -153,6 +153,32 @@ function parseResource(resource) {
153
153
  const offsetLine = resource.offsetLine + (header + _).split('\n').length - 1;
154
154
  return [config, { content, offsetLine }];
155
155
  }
156
+ function getTaxonChildren(parent, taxa) {
157
+ const children = [];
158
+ for (const id in taxa) {
159
+ if (taxa[id].parentNameUsageID === parent) {
160
+ children.push(taxa[id]);
161
+ }
162
+ }
163
+ return children;
164
+ }
165
+ function processClusters(taxa) {
166
+ for (const id in taxa) {
167
+ const taxon = taxa[id];
168
+ if (taxon.taxonomicStatus !== 'accepted' || !taxon.cluster) {
169
+ continue;
170
+ }
171
+ const dynamicProperties = taxon.dynamicProperties ? JSON.parse(taxon.dynamicProperties) : {};
172
+ if (taxon.cluster === '_') {
173
+ dynamicProperties.identifiable = false;
174
+ }
175
+ else {
176
+ const siblings = getTaxonChildren(taxon.parentNameUsageID, taxa).filter(sibling => sibling.scientificNameID !== id);
177
+ dynamicProperties.indistinguishableFrom = siblings.filter(sibling => sibling.cluster === taxon.cluster).map(sibling => sibling.scientificNameID);
178
+ }
179
+ taxon.dynamicProperties = JSON.stringify(dynamicProperties);
180
+ }
181
+ }
156
182
  function parseResourceContent(content, resource, oldIds, offsetLine) {
157
183
  var _a, _b;
158
184
  const leafTaxonIndex = resource.metadata.levels.reduce((last, rank, i) => MAIN_RANKS.includes(rank) ? i : last, 0);
@@ -311,6 +337,7 @@ function parseResourceContent(content, resource, oldIds, offsetLine) {
311
337
  if (errors.length) {
312
338
  throw mergeParserErrors(errors);
313
339
  }
340
+ processClusters(data);
314
341
  return resource;
315
342
  }
316
343
  function splitResources(file) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@larsgw/formica",
3
- "version": "0.8.7",
3
+ "version": "0.9.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",
@@ -44,6 +44,8 @@ const DWC_FIELDS: (keyof AmendedTaxon)[] = [
44
44
  'higherClassification',
45
45
  'verbatimIdentification',
46
46
 
47
+ 'dynamicProperties',
48
+
47
49
  'colTaxonID',
48
50
  'gbifTaxonID',
49
51
  'colAcceptedTaxonID',
package/src/module.d.ts CHANGED
@@ -56,12 +56,14 @@ interface TaxonBase {
56
56
  subgenus?: string,
57
57
  higherClassification?: string,
58
58
  verbatimIdentification?: string,
59
+ dynamicProperties?: string,
59
60
  }
60
61
 
61
62
  interface WorkingTaxon extends TaxonBase {
62
63
  // Non-standard
63
64
  scientificNameOnly?: string,
64
- incorrect?: WorkingTaxon
65
+ incorrect?: WorkingTaxon,
66
+ cluster?: string
65
67
  }
66
68
 
67
69
  interface Taxon extends TaxonBase {
@@ -11,6 +11,7 @@ export const RANKS: Rank[] = [
11
11
  'phylum',
12
12
  'subphylum',
13
13
  'class',
14
+ 'subclass',
14
15
  'infraclass',
15
16
  'superorder',
16
17
  'order',
@@ -41,7 +42,7 @@ export const RANKS: Rank[] = [
41
42
 
42
43
  const TAXONOMIC_STATUS: Record<string, TaxonStatus> = {
43
44
  '>': 'incorrect',
44
- '+': 'heterotypic synonym',
45
+ '+': 'proparte synonym',
45
46
  '=': 'synonym'
46
47
  }
47
48
 
@@ -122,6 +123,8 @@ const SUBGENUS_PATTERN = /^([A-Z]\S+) (?:\(([A-Z]\S+?)\))(?= |$)/
122
123
  */
123
124
  const BINAME_PATTERN = /^(?:((?:x )?[A-Z]\S+) (?:\(([A-Z]\S+?)\) )?)?(x [a-z-]+|[a-z-][^\s.]+(?: x [a-z-]+)?|[A-Z][a-z]+_[a-z-]+ x [A-Z][a-z]+_[a-z-]+)(?= |$)/
124
125
 
126
+ const CLUSTER_PATTERN = /^\[(_|\d+)\] /
127
+
125
128
  function compareRanks (a: Rank, b: Rank): number {
126
129
  return RANKS.indexOf(a) - RANKS.indexOf(b)
127
130
  }
@@ -172,15 +175,21 @@ export function parseName (name: string, rank: Rank, parent: WorkingTaxon): Work
172
175
  const isSynonym = /^[+=>] /.test(name)
173
176
  if (isSynonym) {
174
177
  item.taxonomicStatus = TAXONOMIC_STATUS[name[0]]
175
- name = name.replace(/^[+=>] (\? ?)?/, '')
178
+ name = name.slice(2)
179
+ if (/^\? ?/.test(name)) {
180
+ item.dynamicProperties = '{"synonymUncertain":true}'
181
+ name = name.replace(/^\? ?/, '')
182
+ }
176
183
  rank = getSynonymRank(name, parent.taxonRank as Rank)
177
184
  } else {
178
185
  item.taxonomicStatus = 'accepted'
179
186
  }
180
187
 
181
188
  // Clusters
182
- if (/^\[(_|\d+)\] /.test(name)) {
183
- name = name.replace(/^\[(_|\d+)\] /, '')
189
+ if (CLUSTER_PATTERN.test(name)) {
190
+ const [match, cluster] = name.match(CLUSTER_PATTERN) as string[]
191
+ item.cluster = cluster
192
+ name = name.slice(match.length)
184
193
  }
185
194
 
186
195
  // Set verbatim identification after subsequent syntax is removed.
@@ -375,5 +384,9 @@ export function parseName (name: string, rank: Rank, parent: WorkingTaxon): Work
375
384
  item.scientificName += ` ${item.scientificNameAuthorship}`
376
385
  }
377
386
 
387
+ if (isSynonym && /^auctt?\./.test(item.taxonRemarks)) {
388
+ item.taxonomicStatus = 'misapplied'
389
+ }
390
+
378
391
  return item
379
392
  }
@@ -134,6 +134,36 @@ function parseResource (resource: FilePart): [ResourceMetadata, FilePart] {
134
134
  return [config, { content, offsetLine }]
135
135
  }
136
136
 
137
+ function getTaxonChildren (parent: TaxonId|undefined, taxa: Record<TaxonId, WorkingTaxon>): WorkingTaxon[] {
138
+ const children = []
139
+ for (const id in taxa) {
140
+ if (taxa[id].parentNameUsageID === parent) {
141
+ children.push(taxa[id])
142
+ }
143
+ }
144
+ return children
145
+ }
146
+
147
+ function processClusters (taxa: Record<TaxonId, WorkingTaxon>) {
148
+ for (const id in taxa) {
149
+ const taxon = taxa[id]
150
+ if (taxon.taxonomicStatus !== 'accepted' || !taxon.cluster) {
151
+ continue
152
+ }
153
+
154
+ const dynamicProperties = taxon.dynamicProperties ? JSON.parse(taxon.dynamicProperties) : {}
155
+
156
+ if (taxon.cluster === '_') {
157
+ dynamicProperties.identifiable = false
158
+ } else {
159
+ const siblings = getTaxonChildren(taxon.parentNameUsageID, taxa).filter(sibling => sibling.scientificNameID !== id)
160
+ dynamicProperties.indistinguishableFrom = siblings.filter(sibling => sibling.cluster === taxon.cluster).map(sibling => sibling.scientificNameID)
161
+ }
162
+
163
+ taxon.dynamicProperties = JSON.stringify(dynamicProperties)
164
+ }
165
+ }
166
+
137
167
  function parseResourceContent (content: ResourceDiff, resource: Resource, oldIds: number[], offsetLine: number): Resource {
138
168
  const leafTaxonIndex = resource.metadata.levels.reduce((last, rank, i) => MAIN_RANKS.includes(rank) ? i : last, 0)
139
169
  const data = resource.taxa as Record<TaxonId, WorkingTaxon>
@@ -307,6 +337,8 @@ function parseResourceContent (content: ResourceDiff, resource: Resource, oldIds
307
337
  throw mergeParserErrors(errors)
308
338
  }
309
339
 
340
+ processClusters(data)
341
+
310
342
  return resource
311
343
  }
312
344
 
package/test/resources.js CHANGED
@@ -166,6 +166,27 @@ Polistes Latreille, 1802
166
166
  assert.strictEqual(resource.taxa['T1:1:2'].genericName, 'Polistes')
167
167
  })
168
168
 
169
+ test('handles clusters', () => {
170
+ const [resource] = resources.parseTextFile(`---
171
+ levels: [genus, species]
172
+ ---
173
+
174
+ Eurydema Laporte, 1833
175
+ oleracea (Linnaeus, 1758)
176
+ [1] rotundicollis (Dohrn, 1860)
177
+ [1] fieberi Schummel, 1837
178
+ [2] ornata (Linnaeus, 1758)
179
+ [2] ventralis Kolenati, 1846
180
+ [_] eckerleini Josifov, 1961
181
+ `, 'T1')
182
+ assert.strictEqual(resource.taxa['T1:1:2'].dynamicProperties, undefined)
183
+ assert.strictEqual(resource.taxa['T1:1:3'].dynamicProperties, '{"indistinguishableFrom":["T1:1:4"]}')
184
+ assert.strictEqual(resource.taxa['T1:1:4'].dynamicProperties, '{"indistinguishableFrom":["T1:1:3"]}')
185
+ assert.strictEqual(resource.taxa['T1:1:5'].dynamicProperties, '{"indistinguishableFrom":["T1:1:6"]}')
186
+ assert.strictEqual(resource.taxa['T1:1:6'].dynamicProperties, '{"indistinguishableFrom":["T1:1:5"]}')
187
+ assert.strictEqual(resource.taxa['T1:1:7'].dynamicProperties, '{"identifiable":false}')
188
+ })
189
+
169
190
  suite('leaf taxa checks', () => {
170
191
  test('errors for missing leaf taxa', () => {
171
192
  assert.throws(() => {