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.
@@ -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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "imerss-bioinfo",
3
3
  "description": "Working data of the IMERSS biodiversity informatics working group, together with algorithms",
4
- "version": "1.18.0",
4
+ "version": "1.19.0",
5
5
  "author": "Antranig Basman",
6
6
  "scripts": {
7
7
  "lint": "fluid-lint-all",
@@ -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
- const parsedArgs = minimist(process.argv.slice(2), {boolean: [...Object.keys(strategies), "noRank"]});
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
- const swapsFile = parsedArgs.swaps || fluid.module.resolvePath("%imerss-bioinfo/data/b-team/taxon-swaps-2025.csv");
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
- const noRank = parsedArgs.noRank;
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
- const inputFile = parsedArgs._[0] || fluid.module.resolvePath("%imerss-bioinfo/data/b-team/plant-pollinators-Carril-normalised.csv");
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
- const inputTrunk = hortis.inputFileToTrunk(inputFile);
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 outputFile = parsedArgs.o || inputTrunk + "-assigned.csv";
156
- const outputTaxaFile = parsedArgs.taxa || inputTrunk + "-assigned-taxa.csv";
220
+ const driverOutFile = parsedArgs.driverOut || inputTrunk + "-driver.json5";
157
221
 
158
- const mismatchFile = parsedArgs.mismatches || inputTrunk + "-mismatches.csv";
222
+ const strategy = driver.strategy;
223
+ const noRank = driver.noRank;
159
224
 
160
225
  const swapsReader = hortis.csvReaderWithMap({
161
- inputFile: swapsFile,
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: 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 if (strategy === "reintegrated") {
331
- await hortis.applyName(row, i, row.Phylum, row.Rank, invertedSwaps, taxa, unmappedTaxa, strategyBigRec);
332
- } else if (strategy.startsWith("DwC")) {
333
- await hortis.applyName(row, i, row.phylum, row.taxonRank, invertedSwaps, taxa, unmappedTaxa, strategyBigRec);
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(outputFile);
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(outputTaxaFile, Object.keys(taxaRows[0]), taxaRows, fluid.promise());
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(mismatchFile, Object.keys(unmappedRows[0]), unmappedRows, fluid.promise());
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
- that.scrollTop = scrollEl.scrollTop;
201
- that.viewHeight = Math.max(scrollEl.clientHeight - theadHeight, 0);
202
- if (rowHeight > 0 && Math.abs(rowHeight - that.rowHeight) > 0.01) {
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("./readCSVWithmap.js");
11
- require("./readCSVWithoutMap.js");
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: 180,
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 firstLevel = await that.inMemorySource.get(query);
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
- await that.inMemorySource.set(query, cached);
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
- let live;
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
- await that.inMemorySource.set(query, hortis.writeNoValue(toWrite));
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
- const source = hortis.iNat.obsSource({
194
- headers: {
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
- setTimeout(function () {
238
- hortis.makeObsRequest(directModel);
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 (data) {
255
- await hortis.applyResponse(data);
259
+ promise.then(async function (record) {
260
+ await hortis.applyResponse(record.doc);
256
261
  }, function (error) {
257
- console.log("Got ERROUR: ", error);
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