@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 +15 -0
- package/lib/bin/process-resources.js +1 -0
- package/lib/resources/parse-name.js +14 -4
- package/lib/resources/parse-text.js +27 -0
- package/package.json +1 -1
- package/src/bin/process-resources.ts +2 -0
- package/src/module.d.ts +3 -1
- package/src/resources/parse-name.ts +16 -4
- package/src/resources/parse-text.ts +32 -0
- package/test/resources.js +21 -0
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
|
|
|
@@ -43,7 +43,7 @@ exports.RANKS = [
|
|
|
43
43
|
];
|
|
44
44
|
const TAXONOMIC_STATUS = {
|
|
45
45
|
'>': 'incorrect',
|
|
46
|
-
'+': '
|
|
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.
|
|
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 (
|
|
169
|
-
|
|
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
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
|
-
'+': '
|
|
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.
|
|
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 (
|
|
184
|
-
|
|
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(() => {
|