@larsgw/formica 0.8.8 → 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,18 @@
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
+
1
16
  ## [0.8.8](https://github.com/identification-resources/formica/compare/v0.8.7...v0.8.8) (2025-12-25)
2
17
 
3
18
 
@@ -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',
@@ -43,7 +43,7 @@ exports.RANKS = [
43
43
  ];
44
44
  const TAXONOMIC_STATUS = {
45
45
  '>': 'incorrect',
46
- '+': 'heterotypic synonym',
46
+ '+': 'proparte synonym',
47
47
  '=': 'synonym'
48
48
  };
49
49
  const RANK_LABELS = {
@@ -113,6 +113,7 @@ const SUBGENUS_PATTERN = /^([A-Z]\S+) (?:\(([A-Z]\S+?)\))(?= |$)/;
113
113
  * $2c intergeneric hybrid: [A-Z][a-z]+_[a-z-]+ x [A-Z][a-z]+_[a-z-]+
114
114
  */
115
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+)\] /;
116
117
  function compareRanks(a, b) {
117
118
  return exports.RANKS.indexOf(a) - exports.RANKS.indexOf(b);
118
119
  }
@@ -158,15 +159,21 @@ function parseName(name, rank, parent) {
158
159
  const isSynonym = /^[+=>] /.test(name);
159
160
  if (isSynonym) {
160
161
  item.taxonomicStatus = TAXONOMIC_STATUS[name[0]];
161
- name = name.replace(/^[+=>] (\? ?)?/, '');
162
+ name = name.slice(2);
163
+ if (/^\? ?/.test(name)) {
164
+ item.dynamicProperties = '{"synonymUncertain":true}';
165
+ name = name.replace(/^\? ?/, '');
166
+ }
162
167
  rank = getSynonymRank(name, parent.taxonRank);
163
168
  }
164
169
  else {
165
170
  item.taxonomicStatus = 'accepted';
166
171
  }
167
172
  // Clusters
168
- if (/^\[(_|\d+)\] /.test(name)) {
169
- 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);
170
177
  }
171
178
  // Set verbatim identification after subsequent syntax is removed.
172
179
  item.verbatimIdentification = name.replace(/(?<=^| )x(?=$| )/g, HYBRID_SIGN).replace(/_/g, ' ');
@@ -351,5 +358,8 @@ function parseName(name, rank, parent) {
351
358
  if (item.scientificNameAuthorship) {
352
359
  item.scientificName += ` ${item.scientificNameAuthorship}`;
353
360
  }
361
+ if (isSynonym && /^auctt?\./.test(item.taxonRemarks)) {
362
+ item.taxonomicStatus = 'misapplied';
363
+ }
354
364
  return item;
355
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.8",
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 {
@@ -42,7 +42,7 @@ export const RANKS: Rank[] = [
42
42
 
43
43
  const TAXONOMIC_STATUS: Record<string, TaxonStatus> = {
44
44
  '>': 'incorrect',
45
- '+': 'heterotypic synonym',
45
+ '+': 'proparte synonym',
46
46
  '=': 'synonym'
47
47
  }
48
48
 
@@ -123,6 +123,8 @@ const SUBGENUS_PATTERN = /^([A-Z]\S+) (?:\(([A-Z]\S+?)\))(?= |$)/
123
123
  */
124
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-]+)(?= |$)/
125
125
 
126
+ const CLUSTER_PATTERN = /^\[(_|\d+)\] /
127
+
126
128
  function compareRanks (a: Rank, b: Rank): number {
127
129
  return RANKS.indexOf(a) - RANKS.indexOf(b)
128
130
  }
@@ -173,15 +175,21 @@ export function parseName (name: string, rank: Rank, parent: WorkingTaxon): Work
173
175
  const isSynonym = /^[+=>] /.test(name)
174
176
  if (isSynonym) {
175
177
  item.taxonomicStatus = TAXONOMIC_STATUS[name[0]]
176
- name = name.replace(/^[+=>] (\? ?)?/, '')
178
+ name = name.slice(2)
179
+ if (/^\? ?/.test(name)) {
180
+ item.dynamicProperties = '{"synonymUncertain":true}'
181
+ name = name.replace(/^\? ?/, '')
182
+ }
177
183
  rank = getSynonymRank(name, parent.taxonRank as Rank)
178
184
  } else {
179
185
  item.taxonomicStatus = 'accepted'
180
186
  }
181
187
 
182
188
  // Clusters
183
- if (/^\[(_|\d+)\] /.test(name)) {
184
- 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)
185
193
  }
186
194
 
187
195
  // Set verbatim identification after subsequent syntax is removed.
@@ -376,5 +384,9 @@ export function parseName (name: string, rank: Rank, parent: WorkingTaxon): Work
376
384
  item.scientificName += ` ${item.scientificNameAuthorship}`
377
385
  }
378
386
 
387
+ if (isSynonym && /^auctt?\./.test(item.taxonRemarks)) {
388
+ item.taxonomicStatus = 'misapplied'
389
+ }
390
+
379
391
  return item
380
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(() => {