imerss-bioinfo 1.18.0 → 1.19.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/docs/css/data-table.css +4 -0
- package/package.json +1 -1
- package/src/assignBNames.js +95 -31
- package/src/client/css/data-table.css +4 -0
- package/src/client/sfc/components/hortis-dataTable.vue +46 -6
- package/src/dataProcessing/sqlite.js +4 -0
- package/src/dataProcessing/writeCSV.js +2 -2
- package/src/iNaturalist/obsAPI.js +92 -0
- package/src/iNaturalist/taxonAPI.js +52 -15
- package/src/inatObs.js +21 -15
package/docs/css/data-table.css
CHANGED
|
@@ -61,6 +61,10 @@
|
|
|
61
61
|
min-height: 0;
|
|
62
62
|
overflow-x: scroll;
|
|
63
63
|
overflow-y: auto;
|
|
64
|
+
/* Only the rows near those in view are rendered, between spacers. The browser's scroll anchoring would otherwise
|
|
65
|
+
move the scrolled position each time a new block of rows replaces the old, so that scrolling runs away from the
|
|
66
|
+
pointer */
|
|
67
|
+
overflow-anchor: none;
|
|
64
68
|
}
|
|
65
69
|
|
|
66
70
|
.hortis-dataTable-scroll table {
|
package/package.json
CHANGED
package/src/assignBNames.js
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
const fluid = require("infusion");
|
|
6
6
|
const minimist = require("minimist");
|
|
7
|
+
const JSON5 = require("json5");
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const path = require("path");
|
|
7
10
|
|
|
8
11
|
fluid.require("%imerss-bioinfo");
|
|
9
12
|
|
|
@@ -142,23 +145,85 @@ const strategies = {
|
|
|
142
145
|
}
|
|
143
146
|
};
|
|
144
147
|
|
|
145
|
-
|
|
148
|
+
// Strategy fields which may be overridden from the command line or a driver file
|
|
149
|
+
const overridableFields = ["phylum", "phylumField", "rank", "rankField", "rawName", "iNatName"];
|
|
146
150
|
|
|
147
|
-
|
|
151
|
+
// Paths held in a driver file, which are stored relative to the driver file's own directory
|
|
152
|
+
const driverPaths = ["input", "swaps", "output", "taxa", "mismatches"];
|
|
148
153
|
|
|
149
|
-
|
|
154
|
+
/**
|
|
155
|
+
* Load a driver file, resolving any paths it holds against the driver file's directory.
|
|
156
|
+
* @param {String} driverFile - Path to a JSON5 driver file
|
|
157
|
+
* @return {Object} The driver record with its paths made absolute
|
|
158
|
+
*/
|
|
159
|
+
hortis.readDriver = function (driverFile) {
|
|
160
|
+
const driver = JSON5.parse(fs.readFileSync(driverFile, "utf8"));
|
|
161
|
+
const driverDir = path.dirname(path.resolve(driverFile));
|
|
162
|
+
driverPaths.forEach(function (key) {
|
|
163
|
+
if (driver[key] !== undefined) {
|
|
164
|
+
driver[key] = path.resolve(driverDir, fluid.module.resolvePath(driver[key]));
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
return driver;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Write a driver file which will reproduce this run, with paths relative to the driver file's directory.
|
|
172
|
+
* @param {String} driverFile - Path at which to write the JSON5 driver file
|
|
173
|
+
* @param {Object} driver - The fully resolved driver record for this run
|
|
174
|
+
*/
|
|
175
|
+
hortis.writeDriver = function (driverFile, driver) {
|
|
176
|
+
const driverDir = path.dirname(path.resolve(driverFile));
|
|
177
|
+
const togo = fluid.copy(driver);
|
|
178
|
+
driverPaths.forEach(function (key) {
|
|
179
|
+
if (togo[key] !== undefined) {
|
|
180
|
+
togo[key] = path.relative(driverDir, path.resolve(fluid.module.resolvePath(togo[key]))).replace(/\\/g, "/");
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
fs.writeFileSync(driverFile, JSON5.stringify(togo, null, 4) + "\n");
|
|
184
|
+
console.log("Written driver file " + driverFile);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const parsedArgs = minimist(process.argv.slice(2), {boolean: [...Object.keys(strategies), "noRank"], string: overridableFields});
|
|
188
|
+
|
|
189
|
+
const fileDriver = parsedArgs.driver ? hortis.readDriver(parsedArgs.driver) : {};
|
|
190
|
+
|
|
191
|
+
const argStrategy = parsedArgs.strategy || Object.keys(strategies).find(strategy => parsedArgs[strategy]);
|
|
192
|
+
|
|
193
|
+
const argOverrides = {};
|
|
194
|
+
overridableFields.forEach(function (key) {
|
|
195
|
+
if (parsedArgs[key] !== undefined) {
|
|
196
|
+
argOverrides[key] = parsedArgs[key];
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Values supplied on the command line take priority over those in any driver file
|
|
201
|
+
const driver = {
|
|
202
|
+
strategy: argStrategy || fileDriver.strategy,
|
|
203
|
+
input: parsedArgs._[0] || fileDriver.input || fluid.module.resolvePath("%imerss-bioinfo/data/b-team/plant-pollinators-Carril-normalised.csv"),
|
|
204
|
+
swaps: parsedArgs.swaps || fileDriver.swaps || fluid.module.resolvePath("%imerss-bioinfo/data/b-team/taxon-swaps-2025.csv"),
|
|
205
|
+
noRank: parsedArgs.noRank || fileDriver.noRank || false,
|
|
206
|
+
// Default to DwC column names for phylum and rank - other layouts supply these via e.g. --phylumField, --rank
|
|
207
|
+
overrides: {phylumField: "phylum", rankField: "taxonRank", ...fileDriver.overrides, ...argOverrides}
|
|
208
|
+
};
|
|
150
209
|
|
|
151
|
-
|
|
210
|
+
if (!driver.strategy || !strategies[driver.strategy]) {
|
|
211
|
+
fluid.fail("Please supply strategy argument via --strategy=<name> or one of ", Object.keys(strategies).map(key => "--" + key).join(", "));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const inputTrunk = hortis.inputFileToTrunk(driver.input);
|
|
152
215
|
|
|
153
|
-
|
|
216
|
+
driver.output = parsedArgs.o || fileDriver.output || inputTrunk + "-assigned.csv";
|
|
217
|
+
driver.taxa = parsedArgs.taxa || fileDriver.taxa || inputTrunk + "-assigned-taxa.csv";
|
|
218
|
+
driver.mismatches = parsedArgs.mismatches || fileDriver.mismatches || inputTrunk + "-mismatches.csv";
|
|
154
219
|
|
|
155
|
-
const
|
|
156
|
-
const outputTaxaFile = parsedArgs.taxa || inputTrunk + "-assigned-taxa.csv";
|
|
220
|
+
const driverOutFile = parsedArgs.driverOut || inputTrunk + "-driver.json5";
|
|
157
221
|
|
|
158
|
-
const
|
|
222
|
+
const strategy = driver.strategy;
|
|
223
|
+
const noRank = driver.noRank;
|
|
159
224
|
|
|
160
225
|
const swapsReader = hortis.csvReaderWithMap({
|
|
161
|
-
inputFile:
|
|
226
|
+
inputFile: driver.swaps,
|
|
162
227
|
mapColumns: {
|
|
163
228
|
"preferred": "Preferred name",
|
|
164
229
|
"supplied": "Supplied name",
|
|
@@ -170,22 +235,25 @@ const source = hortis.iNatTaxonSource({
|
|
|
170
235
|
// disableNameCache: true
|
|
171
236
|
});
|
|
172
237
|
|
|
173
|
-
|
|
174
|
-
const strategy = Object.keys(strategies).find(strategy => parsedArgs[strategy]);
|
|
175
|
-
|
|
176
|
-
if (!strategy) {
|
|
177
|
-
fluid.fail("Please supply strategy argument via one of ", Object.keys(strategies).map(key => "--" + key).join(", "));
|
|
178
|
-
}
|
|
179
|
-
|
|
180
238
|
console.log("Applying strategy ", strategy);
|
|
181
|
-
const strategyBigRec = strategies[strategy];
|
|
182
|
-
|
|
239
|
+
const strategyBigRec = strategy === "bees" ? strategies.bees : {...strategies[strategy], ...driver.overrides};
|
|
183
240
|
|
|
184
241
|
const reader = hortis.csvReaderWithoutMap({
|
|
185
|
-
inputFile:
|
|
242
|
+
inputFile: driver.input,
|
|
186
243
|
csvOptions: strategyBigRec.csvOptions
|
|
187
244
|
});
|
|
188
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Determine a value such as phylum or rank for a row, taking it from the configured field if present,
|
|
248
|
+
* falling back to any configured default.
|
|
249
|
+
* @param {Object} row - The row being processed
|
|
250
|
+
* @param {String} [field] - The name of the field holding the value
|
|
251
|
+
* @param {String} [defaultValue] - Value to use if the field is absent or empty
|
|
252
|
+
* @return {String} The resolved value, or undefined
|
|
253
|
+
*/
|
|
254
|
+
hortis.rowValue = function (row, field, defaultValue) {
|
|
255
|
+
return (field && row[field]) || defaultValue;
|
|
256
|
+
};
|
|
189
257
|
|
|
190
258
|
// Added in Epifamily so we can include Bees [Epifamily Anthophila]
|
|
191
259
|
const storeRanks = ["stateofmatter", "kingdom", "phylum", "subphylum", "class", "order", "epifamily", "family", "genus"];
|
|
@@ -327,23 +395,19 @@ Promise.all([reader.completionPromise, swapsReader.completionPromise, source.eve
|
|
|
327
395
|
if (strategy === "bees") {
|
|
328
396
|
await hortis.applyName(row, i, "Tracheophyta", null, invertedSwaps, taxa, unmappedTaxa, strategyBigRec.plant);
|
|
329
397
|
await hortis.applyName(row, i, "Arthropoda", null, invertedSwaps, taxa, unmappedTaxa, strategyBigRec.pollinator);
|
|
330
|
-
} else
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
} else if (strategy === "iNat") {
|
|
335
|
-
await hortis.applyName(row, i, row.phylum, row.taxon_rank, invertedSwaps, taxa, unmappedTaxa, strategyBigRec);
|
|
336
|
-
} else if (strategy === "Saanich") {
|
|
337
|
-
await hortis.applyName(row, i, "Tracheophyta", "species", invertedSwaps, taxa, unmappedTaxa, strategyBigRec);
|
|
398
|
+
} else {
|
|
399
|
+
const s = strategyBigRec;
|
|
400
|
+
await hortis.applyName(row, i, hortis.rowValue(row, s.phylumField, s.phylum), hortis.rowValue(row, s.rankField, s.rank),
|
|
401
|
+
invertedSwaps, taxa, unmappedTaxa, s);
|
|
338
402
|
}
|
|
339
403
|
|
|
340
404
|
mapped.push(row);
|
|
341
405
|
}
|
|
342
|
-
const resolveOut = fluid.module.resolvePath(
|
|
406
|
+
const resolveOut = fluid.module.resolvePath(driver.output);
|
|
343
407
|
await hortis.writeCSV(resolveOut, Object.keys(mapped[0]), mapped, fluid.promise());
|
|
344
408
|
|
|
345
409
|
const taxaRows = hortis.flattenTaxa(taxa);
|
|
346
|
-
await hortis.writeCSV(
|
|
410
|
+
await hortis.writeCSV(driver.taxa, Object.keys(taxaRows[0]), taxaRows, fluid.promise());
|
|
347
411
|
|
|
348
412
|
const unmapped = Object.keys(unmappedTaxa);
|
|
349
413
|
if (unmapped.length > 0) {
|
|
@@ -353,7 +417,7 @@ Promise.all([reader.completionPromise, swapsReader.completionPromise, source.eve
|
|
|
353
417
|
console.log(key + ", original name " + scientificName);
|
|
354
418
|
return {taxonName: key, originalName: scientificName};
|
|
355
419
|
});
|
|
356
|
-
await hortis.writeCSV(
|
|
420
|
+
await hortis.writeCSV(driver.mismatches, Object.keys(unmappedRows[0]), unmappedRows, fluid.promise());
|
|
357
421
|
}
|
|
358
|
-
|
|
422
|
+
hortis.writeDriver(driverOutFile, driver);
|
|
359
423
|
});
|
|
@@ -61,6 +61,10 @@
|
|
|
61
61
|
min-height: 0;
|
|
62
62
|
overflow-x: scroll;
|
|
63
63
|
overflow-y: auto;
|
|
64
|
+
/* Only the rows near those in view are rendered, between spacers. The browser's scroll anchoring would otherwise
|
|
65
|
+
move the scrolled position each time a new block of rows replaces the old, so that scrolling runs away from the
|
|
66
|
+
pointer */
|
|
67
|
+
overflow-anchor: none;
|
|
64
68
|
}
|
|
65
69
|
|
|
66
70
|
.hortis-dataTable-scroll table {
|
|
@@ -30,6 +30,10 @@ fluid.def("hortis.dataTable", {
|
|
|
30
30
|
scrollTop: 0,
|
|
31
31
|
viewHeight: 0,
|
|
32
32
|
rowHeight: 26.5,
|
|
33
|
+
// The least change in the measured height of a row for which `rowHeight` is updated. Rows differ in height by a
|
|
34
|
+
// fraction of a pixel, by their content and the display's scaling, and since every row not rendered is reckoned at
|
|
35
|
+
// `rowHeight`, following each such difference would change the height of a long table by thousands of pixels
|
|
36
|
+
rowHeightTolerance: 0.5,
|
|
33
37
|
resolvedColumns: "$compute:hortis.dataTable.resolveColumns({self}.rows, {self}.columns)",
|
|
34
38
|
displayRows: "$compute:hortis.dataTable.sortRows({self}.rows, {self}.sortColumn, {self}.sortDirection)",
|
|
35
39
|
window: "$compute:hortis.dataTable.window({self}.displayRows.length, {self}.scrollTop, {self}.viewHeight, {self}.rowHeight, {self}.minBlockSize)",
|
|
@@ -178,10 +182,28 @@ hortis.dataTable.pageInfo = function (window) {
|
|
|
178
182
|
return window.total > 0 ? `${window.first + 1}–${window.last} of ${window.total}` : "No data";
|
|
179
183
|
};
|
|
180
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Measure the height of the rows rendered in a table's scrolled element, as their mean, since they may differ by a
|
|
187
|
+
* fraction of a pixel.
|
|
188
|
+
* @param {Element} scrollEl - The table's scrolled element
|
|
189
|
+
* @return {Number} The mean height of the rendered rows, or 0 if none is rendered
|
|
190
|
+
*/
|
|
191
|
+
hortis.dataTable.measureRowHeight = function (scrollEl) {
|
|
192
|
+
const rows = scrollEl.querySelectorAll("tbody tr:not(.hortis-dataTable-spacer)");
|
|
193
|
+
const first = rows[0], last = rows[rows.length - 1];
|
|
194
|
+
return rows.length > 0 ? (last.getBoundingClientRect().bottom - first.getBoundingClientRect().top) / rows.length : 0;
|
|
195
|
+
};
|
|
196
|
+
|
|
181
197
|
/**
|
|
182
198
|
* Track the scrolled position and the height of the view of a table's scrolled element, writing them into the table
|
|
183
199
|
* on each animation frame in which they change, and the height of its head into the element's CSS variable
|
|
184
200
|
* --data-table-head-height.
|
|
201
|
+
* The table's `rowHeight`, by which the heights of the spacers standing for the rows not rendered are reckoned, is
|
|
202
|
+
* corrected only when the rows' measured height departs from it by more than `rowHeightTolerance`, and never whilst
|
|
203
|
+
* the pointer is pressed within the scrolled element. A change of `rowHeight` changes the height of the whole table,
|
|
204
|
+
* and whilst the scrollbar's thumb is dragged, the browser holds the thumb at the pointer by moving the scrolled
|
|
205
|
+
* position in proportion - which rendered other rows, measured afresh, in a loop which ran the table to its end. Once
|
|
206
|
+
* `rowHeight` is corrected, the scrolled position is moved so that the same row stays at the top of the view.
|
|
185
207
|
* @param {hortis.dataTable} that - The table
|
|
186
208
|
* @param {Element} container - The table's rendered container
|
|
187
209
|
* @param {Window} window - The window holding the table
|
|
@@ -190,32 +212,50 @@ hortis.dataTable.pageInfo = function (window) {
|
|
|
190
212
|
hortis.dataTable.track = function (that, container, window) {
|
|
191
213
|
const scrollEl = container.querySelector(".hortis-dataTable-scroll");
|
|
192
214
|
let frame = null;
|
|
215
|
+
// Whether the pointer is pressed within the scrolled element, e.g. dragging its scrollbar's thumb
|
|
216
|
+
let pressed = false;
|
|
193
217
|
const update = () => {
|
|
194
218
|
frame = null;
|
|
195
219
|
const theadHeight = scrollEl.querySelector("thead")?.getBoundingClientRect().height || 0;
|
|
196
|
-
const row = scrollEl.querySelector("tbody tr:not(.hortis-dataTable-spacer)");
|
|
197
|
-
const rowHeight = row?.getBoundingClientRect().height;
|
|
198
220
|
// Confines the vertical scrollbar's track to the rows, beneath the sticky head - see data-table.css
|
|
199
221
|
scrollEl.style.setProperty("--data-table-head-height", theadHeight + "px");
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
222
|
+
const rowHeight = hortis.dataTable.measureRowHeight(scrollEl);
|
|
223
|
+
if (!pressed && rowHeight > 0 && Math.abs(rowHeight - that.rowHeight) > that.rowHeightTolerance) {
|
|
224
|
+
const topRow = scrollEl.scrollTop / that.rowHeight;
|
|
203
225
|
that.rowHeight = rowHeight;
|
|
226
|
+
scrollEl.scrollTop = topRow * rowHeight;
|
|
204
227
|
}
|
|
228
|
+
that.scrollTop = scrollEl.scrollTop;
|
|
229
|
+
that.viewHeight = Math.max(scrollEl.clientHeight - theadHeight, 0);
|
|
205
230
|
};
|
|
206
231
|
const schedule = () => {
|
|
207
232
|
if (!frame) {
|
|
208
233
|
frame = window.requestAnimationFrame(update);
|
|
209
234
|
}
|
|
210
235
|
};
|
|
236
|
+
const press = () => {
|
|
237
|
+
pressed = true;
|
|
238
|
+
};
|
|
239
|
+
const release = () => {
|
|
240
|
+
if (pressed) {
|
|
241
|
+
pressed = false;
|
|
242
|
+
schedule();
|
|
243
|
+
}
|
|
244
|
+
};
|
|
211
245
|
scrollEl.addEventListener("scroll", schedule);
|
|
246
|
+
scrollEl.addEventListener("pointerdown", press);
|
|
247
|
+
window.addEventListener("pointerup", release);
|
|
248
|
+
window.addEventListener("pointercancel", release);
|
|
212
249
|
const observer = new window.ResizeObserver(schedule);
|
|
213
250
|
observer.observe(scrollEl);
|
|
214
|
-
return {scrollEl, schedule, observer, window, cancel: () => frame && window.cancelAnimationFrame(frame)};
|
|
251
|
+
return {scrollEl, schedule, press, release, observer, window, cancel: () => frame && window.cancelAnimationFrame(frame)};
|
|
215
252
|
};
|
|
216
253
|
|
|
217
254
|
hortis.dataTable.untrack = function (record) {
|
|
218
255
|
record.scrollEl.removeEventListener("scroll", record.schedule);
|
|
256
|
+
record.scrollEl.removeEventListener("pointerdown", record.press);
|
|
257
|
+
record.window.removeEventListener("pointerup", record.release);
|
|
258
|
+
record.window.removeEventListener("pointercancel", record.release);
|
|
219
259
|
record.observer.disconnect();
|
|
220
260
|
record.cancel();
|
|
221
261
|
};
|
|
@@ -8,6 +8,8 @@ const hortis = fluid.registerNamespace("hortis");
|
|
|
8
8
|
const sqlite = require("sqlite");
|
|
9
9
|
const sqlite3 = require("sqlite3");
|
|
10
10
|
const pako = require("pako");
|
|
11
|
+
const fs = require("fs");
|
|
12
|
+
const nodePath = require("path");
|
|
11
13
|
|
|
12
14
|
fluid.registerNamespace("hortis.codecs.zlib");
|
|
13
15
|
|
|
@@ -60,6 +62,8 @@ hortis.sqliteDB.openDB = async function (dbFile) {
|
|
|
60
62
|
const path = fluid.module.resolvePath(dbFile);
|
|
61
63
|
try {
|
|
62
64
|
fluid.log("Opening SQLite database at ", path);
|
|
65
|
+
// Ensure the database's directory exists, since SQLite will create the file but not its parents
|
|
66
|
+
fs.mkdirSync(nodePath.dirname(path), {recursive: true});
|
|
63
67
|
return sqlite.open({
|
|
64
68
|
filename: path,
|
|
65
69
|
driver: sqlite3.Database
|
|
@@ -7,8 +7,8 @@ const csvWriterModule = require("csv-writer");
|
|
|
7
7
|
|
|
8
8
|
const hortis = fluid.registerNamespace("hortis");
|
|
9
9
|
|
|
10
|
-
require("./
|
|
11
|
-
require("./
|
|
10
|
+
require("./readCSVwithMap.js");
|
|
11
|
+
require("./readCSVwithoutMap.js");
|
|
12
12
|
require("../utils/utils.js");
|
|
13
13
|
|
|
14
14
|
hortis.writeCSV = function (fileName, headersOrMap, rows, completionPromise) {
|
|
@@ -155,3 +155,95 @@ fluid.defaults("hortis.iNatObsAPI.byIdDBSource", {
|
|
|
155
155
|
query: "SELECT id from iNatObsId"
|
|
156
156
|
}
|
|
157
157
|
});
|
|
158
|
+
|
|
159
|
+
// A cached source for pages of the observations search API, so that an interrupted run of inatObs.js can be resumed
|
|
160
|
+
// rapidly. The cache is short-lived since observations change much more rapidly than taxa.
|
|
161
|
+
// Accepts the same directModel as hortis.iNat.obsSource, e.g. {per_page, id_above}, and resolves to a record
|
|
162
|
+
// {key, fetched_at, doc} where doc is the API response.
|
|
163
|
+
// Expected to be instantiated as a child of hortis.iNatTaxonSource in order to share its database and JWT.
|
|
164
|
+
fluid.defaults("hortis.cachediNatObsPage", {
|
|
165
|
+
gradeNames: "hortis.cachedApiSource",
|
|
166
|
+
refreshInDays: 3,
|
|
167
|
+
// Pages are large and each is requested only once per run, so holding them in memory would exhaust the heap
|
|
168
|
+
useInMemoryCache: false,
|
|
169
|
+
// paramMap: Object - search parameters for the observations query, merged with those in hortis.iNat.obsSource
|
|
170
|
+
components: {
|
|
171
|
+
apiSource: {
|
|
172
|
+
type: "hortis.iNat.obsSource",
|
|
173
|
+
options: {
|
|
174
|
+
gradeNames: "hortis.withINatRateLimit",
|
|
175
|
+
paramMap: "{cachediNatObsPage}.options.paramMap"
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
dbSource: {
|
|
179
|
+
type: "hortis.iNatObsAPI.pageDBSource",
|
|
180
|
+
options: {
|
|
181
|
+
invokers: {
|
|
182
|
+
encodeQueryKey: "hortis.cachediNatObsPage.encodeQueryKey({arguments}.0, {cachediNatObsPage}.apiSource)"
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
invokers: {
|
|
188
|
+
upgradeLiveDocument: "hortis.cachediNatObsPage.upgradeLiveDocument({arguments}.0, {arguments}.1, {arguments}.2, {that}.apiSource)"
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
/** Compute the database key for an observations page query - this is the full resolved API URL
|
|
193
|
+
* @param {Object} query - The directModel for the query, or a record already holding its `key`
|
|
194
|
+
* @param {Component} apiSource - The hortis.iNat.obsSource issuing the requests
|
|
195
|
+
* @return {Object} The query, supplemented with member `key`
|
|
196
|
+
*/
|
|
197
|
+
hortis.cachediNatObsPage.encodeQueryKey = function (query, apiSource) {
|
|
198
|
+
let key = query.key;
|
|
199
|
+
if (!key) {
|
|
200
|
+
key = apiSource.resolveUrl(apiSource.options.url, apiSource.options.termMap, query);
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
...query,
|
|
204
|
+
key
|
|
205
|
+
};
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/** Convert a live observations page response into the record form stored in the database
|
|
209
|
+
* @param {Object} query - The directModel for the query
|
|
210
|
+
* @param {Object} live - The response from the observations API
|
|
211
|
+
* @param {Function} writer - Function accepting (query, record) which writes the record to the caches
|
|
212
|
+
* @param {Component} apiSource - The hortis.iNat.obsSource issuing the requests
|
|
213
|
+
* @return {Promise<Object>} A promise for the record {key, fetched_at, doc}
|
|
214
|
+
*/
|
|
215
|
+
hortis.cachediNatObsPage.upgradeLiveDocument = async function (query, live, writer, apiSource) {
|
|
216
|
+
const toWrite = {
|
|
217
|
+
key: hortis.cachediNatObsPage.encodeQueryKey(query, apiSource).key,
|
|
218
|
+
fetched_at: new Date().toISOString(),
|
|
219
|
+
doc: live
|
|
220
|
+
};
|
|
221
|
+
// Don't cache the final empty page, so that a rerun will always pick up observations added since
|
|
222
|
+
if (live && live.results && live.results.length > 0) {
|
|
223
|
+
await writer(query, toWrite);
|
|
224
|
+
}
|
|
225
|
+
return toWrite;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
fluid.defaults("hortis.iNatObsAPI.pageDBSource", {
|
|
229
|
+
gradeNames: ["hortis.listableSqliteSource"],
|
|
230
|
+
createString: "CREATE TABLE IF NOT EXISTS iNatObsPage (key TEXT PRIMARY KEY, fetched_at TEXT, doc BLOB)",
|
|
231
|
+
columnCodecs: {
|
|
232
|
+
doc: "zlib"
|
|
233
|
+
},
|
|
234
|
+
readQuery: {
|
|
235
|
+
query: "SELECT key, fetched_at, doc from iNatObsPage WHERE key = ?",
|
|
236
|
+
args: ["%key"]
|
|
237
|
+
},
|
|
238
|
+
writeQuery: {
|
|
239
|
+
query: "INSERT OR REPLACE INTO iNatObsPage (key, fetched_at, doc) VALUES ($key, $fetched_at, $doc)",
|
|
240
|
+
args: {
|
|
241
|
+
$key: "%key",
|
|
242
|
+
$fetched_at: "%fetched_at",
|
|
243
|
+
$doc: "%doc"
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
listQuery: {
|
|
247
|
+
query: "SELECT key from iNatObsPage"
|
|
248
|
+
}
|
|
249
|
+
});
|
|
@@ -158,8 +158,14 @@ hortis.DAY_IN_MS = 24 * 60 * 60 * 1000;
|
|
|
158
158
|
|
|
159
159
|
fluid.defaults("hortis.cachedApiSource", {
|
|
160
160
|
gradeNames: ["fluid.dataSource", "fluid.dataSource.noencoding"],
|
|
161
|
-
refreshInDays:
|
|
161
|
+
refreshInDays: 365,
|
|
162
162
|
disableCache: false,
|
|
163
|
+
// Set to false for sources whose documents are large and never requested twice in a run, e.g. observation pages
|
|
164
|
+
useInMemoryCache: true,
|
|
165
|
+
retry: {
|
|
166
|
+
attempts: 6,
|
|
167
|
+
initialDelay: 5000 // doubled after each failed attempt
|
|
168
|
+
},
|
|
163
169
|
components: {
|
|
164
170
|
inMemorySource: {
|
|
165
171
|
type: "fluid.inMemoryCachedSource"
|
|
@@ -179,6 +185,40 @@ fluid.defaults("hortis.cachedApiSource", {
|
|
|
179
185
|
}
|
|
180
186
|
});
|
|
181
187
|
|
|
188
|
+
/** Issue a get against a dataSource, retrying with exponential backoff on failure (e.g. transient DNS or network
|
|
189
|
+
* errors such as EAI_AGAIN or ECONNRESET)
|
|
190
|
+
* @param {fluid.dataSource} source - The dataSource to read from
|
|
191
|
+
* @param {Object} query - The directModel for the request
|
|
192
|
+
* @param {Object} retry - Retry policy, holding members `attempts` and `initialDelay` (in ms)
|
|
193
|
+
* @return {Promise<any>} A promise for the dataSource's response, rejecting with the final error if all attempts fail
|
|
194
|
+
*/
|
|
195
|
+
hortis.getWithRetry = async function (source, query, retry) {
|
|
196
|
+
const attempts = retry?.attempts || 1;
|
|
197
|
+
let delay = retry?.initialDelay || 5000;
|
|
198
|
+
let result, lastError;
|
|
199
|
+
let succeeded = false;
|
|
200
|
+
for (let attempt = 1; attempt <= attempts && !succeeded; ++attempt) {
|
|
201
|
+
try {
|
|
202
|
+
result = await source.get(query);
|
|
203
|
+
succeeded = true;
|
|
204
|
+
} catch (e) {
|
|
205
|
+
lastError = e;
|
|
206
|
+
if (attempt < attempts) {
|
|
207
|
+
console.log("Got API error on attempt " + attempt + " of " + attempts + ", retrying in " + delay + "ms: ", e);
|
|
208
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
209
|
+
delay *= 2;
|
|
210
|
+
} else {
|
|
211
|
+
console.log("Got API error on final attempt " + attempt + ", giving up: ", e);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (succeeded) {
|
|
216
|
+
return result;
|
|
217
|
+
} else {
|
|
218
|
+
throw lastError;
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
182
222
|
hortis.readNoValue = function (value) {
|
|
183
223
|
return value === fluid.NO_VALUE ? undefined : value;
|
|
184
224
|
};
|
|
@@ -189,7 +229,8 @@ hortis.writeNoValue = function (value) {
|
|
|
189
229
|
|
|
190
230
|
hortis.cachedApiSource.read = async function (that, payload, options) {
|
|
191
231
|
const query = options.directModel;
|
|
192
|
-
const
|
|
232
|
+
const useInMemory = that.options.useInMemoryCache;
|
|
233
|
+
const firstLevel = useInMemory ? await that.inMemorySource.get(query) : undefined;
|
|
193
234
|
if (firstLevel) {
|
|
194
235
|
return hortis.readNoValue(firstLevel);
|
|
195
236
|
} else {
|
|
@@ -197,27 +238,23 @@ hortis.cachedApiSource.read = async function (that, payload, options) {
|
|
|
197
238
|
if (cached && !that.options.disableCache) {
|
|
198
239
|
const staleness = Date.now() - Date.parse(cached.fetched_at);
|
|
199
240
|
if (staleness < that.options.refreshInDays * hortis.DAY_IN_MS) {
|
|
200
|
-
|
|
241
|
+
if (useInMemory) {
|
|
242
|
+
await that.inMemorySource.set(query, cached);
|
|
243
|
+
}
|
|
201
244
|
return cached;
|
|
202
245
|
} else {
|
|
203
246
|
console.log("Staleness is " + moment.duration(staleness).humanize() + " so fetching live document");
|
|
204
247
|
}
|
|
205
248
|
}
|
|
206
|
-
|
|
207
|
-
try {
|
|
208
|
-
live = await that.apiSource.get(query);
|
|
209
|
-
} catch (e) {
|
|
210
|
-
console.log("Got API error ", e);
|
|
211
|
-
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
212
|
-
debugger;
|
|
213
|
-
live = await that.apiSource.get(query);
|
|
214
|
-
}
|
|
249
|
+
const live = await hortis.getWithRetry(that.apiSource, query, that.options.retry);
|
|
215
250
|
const writer = async function (query, toWrite) {
|
|
216
251
|
if (toWrite.doc) { // Only write to db if we got live
|
|
217
252
|
await that.dbSource.set(query, toWrite);
|
|
218
253
|
}
|
|
219
|
-
// Always write to top-level cache
|
|
220
|
-
|
|
254
|
+
// Always write to top-level cache if enabled
|
|
255
|
+
if (useInMemory) {
|
|
256
|
+
await that.inMemorySource.set(query, hortis.writeNoValue(toWrite));
|
|
257
|
+
}
|
|
221
258
|
};
|
|
222
259
|
const toWrite = await that.upgradeLiveDocument(query, live, writer);
|
|
223
260
|
|
|
@@ -340,7 +377,7 @@ hortis.genusMatch = function (a, b) {
|
|
|
340
377
|
|
|
341
378
|
// Return everything before the whitespace
|
|
342
379
|
return j > 0 ? a.slice(0, j - 1) : "";
|
|
343
|
-
}
|
|
380
|
+
};
|
|
344
381
|
|
|
345
382
|
// Highest iNat rank is 100 for Life - we want the most specific taxon of those selected
|
|
346
383
|
hortis.scoreNameMatch = async function (result, query, byIdSource) {
|
package/src/inatObs.js
CHANGED
|
@@ -190,17 +190,23 @@ const projectArgs = hortis.iNatProjects[parsedArgs._[0] || "GalianoData"];
|
|
|
190
190
|
|
|
191
191
|
const jwt = hortis.readJSONSync("jwt.json", "reading JWT token file");
|
|
192
192
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
Authorization: "Bearer " + jwt.api_token
|
|
196
|
-
},
|
|
197
|
-
paramMap: projectArgs.paramMap
|
|
198
|
-
});
|
|
199
|
-
|
|
193
|
+
// Observation pages are fetched via a short-lived cache held in the same database as taxa, so that an interrupted run
|
|
194
|
+
// can be resumed quickly. Pass --disableObsCache to force all pages to be fetched live.
|
|
200
195
|
const iNatTaxonSource = hortis.iNatTaxonSource({
|
|
201
|
-
jwt: jwt
|
|
196
|
+
jwt: jwt,
|
|
197
|
+
components: {
|
|
198
|
+
obsPages: {
|
|
199
|
+
type: "hortis.cachediNatObsPage",
|
|
200
|
+
options: {
|
|
201
|
+
paramMap: projectArgs.paramMap,
|
|
202
|
+
refreshInDays: parsedArgs.disableObsCache ? 0 : 3
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
202
206
|
});
|
|
203
207
|
|
|
208
|
+
const source = iNatTaxonSource.obsPages;
|
|
209
|
+
|
|
204
210
|
const fileVars = {
|
|
205
211
|
Date: new Date().toISOString().substring(0, 10).replaceAll("-", "_")
|
|
206
212
|
};
|
|
@@ -234,9 +240,8 @@ hortis.applyResponse = async function (data) {
|
|
|
234
240
|
const lastId = fluid.peek(rows).id;
|
|
235
241
|
console.log("got last id " + lastId);
|
|
236
242
|
directModel.id_above = lastId;
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
}, 1000);
|
|
243
|
+
// Live requests are rate-limited by hortis.iNatAPILimiter, cached pages are served immediately
|
|
244
|
+
hortis.makeObsRequest(directModel);
|
|
240
245
|
} else {
|
|
241
246
|
hortis.writeObs("obsoutput.csv", rows).then(function () {
|
|
242
247
|
const fileTarget = fluid.stringTemplate(projectArgs.outputFile, fileVars);
|
|
@@ -249,12 +254,13 @@ hortis.applyResponse = async function (data) {
|
|
|
249
254
|
|
|
250
255
|
hortis.makeObsRequest = function (directModel) {
|
|
251
256
|
console.log("Making request ", directModel);
|
|
252
|
-
const promise = source.get(directModel);
|
|
257
|
+
const promise = source.get({...directModel});
|
|
253
258
|
|
|
254
|
-
promise.then(async function (
|
|
255
|
-
await hortis.applyResponse(
|
|
259
|
+
promise.then(async function (record) {
|
|
260
|
+
await hortis.applyResponse(record.doc);
|
|
256
261
|
}, function (error) {
|
|
257
|
-
console.log("
|
|
262
|
+
console.log("Failed to fetch observations after retries, rerun to resume from cache: ", error);
|
|
263
|
+
process.exitCode = 1;
|
|
258
264
|
});
|
|
259
265
|
};
|
|
260
266
|
|