@ca-plant-list/ca-plant-list 0.4.14 → 0.4.16

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.
@@ -0,0 +1,435 @@
1
+ import path from "node:path";
2
+ import { CSV } from "../csv.js";
3
+ import { Files } from "../files.js";
4
+
5
+ const HTML_FILE_NAME = "rpi.html";
6
+ const URL_RPI_LIST =
7
+ "https://rareplants.cnps.org/Search/result?frm=T&life=tree:herb:shrub:vine:leaf:stem";
8
+
9
+ class RPI {
10
+ /** @type {Object<string,Object<string,string>>} */
11
+ static #rpiData = {};
12
+
13
+ /**
14
+ * @param {string} toolsDataDir
15
+ * @param {Taxa} taxa
16
+ * @param {Config} config
17
+ * @param {import("../exceptions.js").Exceptions} exceptions
18
+ * @param {ErrorLog} errorLog
19
+ */
20
+ static async analyze(toolsDataDir, taxa, config, exceptions, errorLog) {
21
+ /**
22
+ * @param {string} name
23
+ * @param {string} label
24
+ * @param {string|undefined} rpiRank
25
+ * @param {string|undefined} taxonRank
26
+ */
27
+ function checkStatusMatch(name, label, rpiRank, taxonRank) {
28
+ if (rpiRank !== taxonRank) {
29
+ errorLog.log(
30
+ name,
31
+ label +
32
+ " rank in taxa.csv is different than rank in " +
33
+ fileName,
34
+ String(taxonRank),
35
+ String(rpiRank),
36
+ );
37
+ }
38
+ }
39
+
40
+ const toolsDataPath = toolsDataDir + "/rpi";
41
+ const fileName = "rpi.csv";
42
+ const filePath = toolsDataPath + "/" + fileName;
43
+
44
+ // Create data directory if it's not there.
45
+ Files.mkdir(toolsDataPath);
46
+
47
+ // Download the data file if it doesn't exist.
48
+ if (!Files.exists(filePath)) {
49
+ console.log("retrieving " + filePath);
50
+
51
+ // To download results in a spreadsheet, first need to retrieve the search results HTML, which sets the
52
+ // ASP.NET_SessionId session cookie, then retrieve the CSV, which retrieves the query from the session.
53
+ const headers = await Files.fetch(
54
+ URL_RPI_LIST,
55
+ toolsDataPath + "/" + HTML_FILE_NAME,
56
+ );
57
+
58
+ const options = { headers: { cookie: headers.get("set-cookie") } };
59
+ await Files.fetch(
60
+ "https://rareplants.cnps.org/PlantExport/SearchResults",
61
+ filePath,
62
+ options,
63
+ );
64
+ }
65
+
66
+ const countyCodes = config.getCountyCodes();
67
+ const ignoreGlobalRank = config.getConfigValue("rpi", "ignoreglobal");
68
+ const ignoreCNDDBRank = config.getConfigValue("rpi", "ignorecnddb");
69
+
70
+ const csv = CSV.readFile(path.join(toolsDataPath, fileName));
71
+ for (const row of csv) {
72
+ const rpiName = row["ScientificName"].replace(" ssp.", " subsp.");
73
+ const translatedName = exceptions.getValue(
74
+ rpiName,
75
+ "rpi",
76
+ "translation",
77
+ );
78
+ this.#rpiData[rpiName] = row;
79
+ const name = translatedName ? translatedName : rpiName;
80
+ const rank = row["CRPR"];
81
+ const rawCESA = row["CESA"];
82
+ const rawFESA = row["FESA"];
83
+ const cesa = rawCESA === "None" ? undefined : rawCESA;
84
+ const fesa = rawFESA === "None" ? undefined : rawFESA;
85
+ const cnddb = row["SRank"];
86
+ const globalRank = row["GRank"];
87
+ const taxon = taxa.getTaxon(name);
88
+
89
+ const shouldBeInGeo = this.#shouldBeHere(row, countyCodes);
90
+
91
+ if (shouldBeInGeo) {
92
+ if (!taxon) {
93
+ if (cesa && countyCodes.length > 0) {
94
+ errorLog.log(
95
+ name,
96
+ "is CESA listed but not found in taxa.csv",
97
+ cesa,
98
+ );
99
+ }
100
+ if (this.#hasExceptions(name, exceptions, "notingeo")) {
101
+ continue;
102
+ }
103
+ if (
104
+ this.#hasExceptions(name, exceptions, "extirpated") &&
105
+ (rank === "1A" || rank === "2A")
106
+ ) {
107
+ // It is state ranked, but extirpated statewide, so we are not tracking it.
108
+ continue;
109
+ }
110
+ if (countyCodes.length > 0) {
111
+ errorLog.log(
112
+ name,
113
+ "in RPI but not found in taxa.csv",
114
+ rank,
115
+ );
116
+ }
117
+ continue;
118
+ }
119
+ } else {
120
+ if (!taxon) {
121
+ // Not in taxa.csv, and also not in RPI for local counties, so ignore it.
122
+ continue;
123
+ }
124
+ if (
125
+ taxon.isNative() &&
126
+ !this.#hasExceptions(name, exceptions, "ingeo")
127
+ ) {
128
+ // If it is a local native in taxa.csv, warn.
129
+ errorLog.log(
130
+ name,
131
+ "in taxa.csv but not in RPI for local counties",
132
+ rank,
133
+ );
134
+ }
135
+ }
136
+
137
+ if (rank !== taxon.getRPIRankAndThreat()) {
138
+ if (taxon.isNative()) {
139
+ errorLog.log(
140
+ name,
141
+ "rank in taxa.csv is different than rank in " +
142
+ fileName,
143
+ taxon.getRPIRankAndThreat(),
144
+ rank,
145
+ );
146
+ }
147
+ }
148
+ checkStatusMatch(name, "CESA", cesa, taxon.getCESA());
149
+ checkStatusMatch(name, "FESA", fesa, taxon.getFESA());
150
+ if (!ignoreCNDDBRank) {
151
+ checkStatusMatch(name, "CNDDB", cnddb, taxon.getCNDDBRank());
152
+ }
153
+ if (!ignoreGlobalRank) {
154
+ checkStatusMatch(
155
+ name,
156
+ "Global",
157
+ globalRank,
158
+ taxon.getGlobalRank(),
159
+ );
160
+ }
161
+
162
+ if (
163
+ !taxon.isCANative() &&
164
+ !this.#hasExceptions(name, exceptions, "non-native")
165
+ ) {
166
+ errorLog.log(name, "is in RPI but not native in taxa.csv");
167
+ }
168
+ }
169
+
170
+ // Check all taxa to make sure they are consistent with RPI.
171
+ for (const taxon of taxa.getTaxonList()) {
172
+ const name = taxon.getName();
173
+ if (taxon.getRPIRankAndThreat()) {
174
+ const translatedName = exceptions.getValue(
175
+ name,
176
+ "rpi",
177
+ "translation-to-rpi",
178
+ );
179
+ // Make sure it is in RPI data.
180
+ if (!this.#rpiData[translatedName ? translatedName : name]) {
181
+ errorLog.log(
182
+ name,
183
+ "has CRPR in taxa.csv but is not in " + fileName,
184
+ );
185
+ }
186
+ } else {
187
+ // If it is not in RPI, it shouldn't have any of the other ranks.
188
+ if (
189
+ taxon.getCESA() ||
190
+ taxon.getFESA() ||
191
+ taxon.getCNDDBRank() ||
192
+ taxon.getGlobalRank()
193
+ ) {
194
+ errorLog.log(name, "has no CRPR but has other ranks");
195
+ }
196
+ }
197
+ }
198
+
199
+ this.#checkExceptions(taxa, config, exceptions, errorLog);
200
+
201
+ this.#scrape(toolsDataDir, taxa, exceptions, errorLog);
202
+ }
203
+
204
+ /**
205
+ *
206
+ * @param {Taxa} taxa
207
+ * @param {Config} config
208
+ * @param {import("../exceptions.js").Exceptions} exceptions
209
+ * @param {ErrorLog} errorLog
210
+ */
211
+ static #checkExceptions(taxa, config, exceptions, errorLog) {
212
+ const countyCodes = config.getCountyCodes();
213
+
214
+ // Check the RPI exceptions and make sure they still apply.
215
+ for (const [name, v] of exceptions.getExceptions()) {
216
+ const rpiExceptions = v.rpi;
217
+ if (!rpiExceptions) {
218
+ continue;
219
+ }
220
+
221
+ const translatedName = exceptions.getValue(
222
+ name,
223
+ "rpi",
224
+ "translation",
225
+ );
226
+
227
+ const taxon = taxa.getTaxon(translatedName ? translatedName : name);
228
+
229
+ // Make sure it is actually in RPI data.
230
+ const rpiData = this.#rpiData[name];
231
+ if (!rpiData) {
232
+ // Ignore it if there is a "translation-to-rpi" entry.
233
+ if (!rpiExceptions["translation-to-rpi"]) {
234
+ errorLog.log(
235
+ name,
236
+ "has rpi exception but is not in rpi.csv",
237
+ );
238
+ }
239
+ }
240
+
241
+ for (const [k, v] of Object.entries(rpiExceptions)) {
242
+ switch (k) {
243
+ case "extirpated": {
244
+ // Make sure the taxon is not in our list.
245
+ if (taxon) {
246
+ errorLog.log(
247
+ name,
248
+ "has rpi extirpated exception but is in taxa.csv",
249
+ );
250
+ }
251
+ // Make sure it has extirpated RPI status.
252
+ const rank = rpiData["CRPR"];
253
+ if (rank !== "1A" && rank !== "2A") {
254
+ errorLog.log(
255
+ name,
256
+ "has rpi extirpated exception rank is not 1A or 2A",
257
+ );
258
+ }
259
+ break;
260
+ }
261
+ case "ingeo":
262
+ // Make sure the taxon is in our list.
263
+ if (!taxon) {
264
+ errorLog.log(
265
+ name,
266
+ "has rpi ingeo exception but is not in taxa.csv",
267
+ );
268
+ }
269
+ // Make sure it is no listed in local area in RPI.
270
+ if (this.#shouldBeHere(rpiData, countyCodes)) {
271
+ errorLog.log(
272
+ name,
273
+ "has rpi ingeo exception but is listed in local counties in rpi.csv",
274
+ );
275
+ }
276
+ break;
277
+ case "non-native":
278
+ // Make sure the taxon is in our list.
279
+ if (!taxon) {
280
+ errorLog.log(
281
+ name,
282
+ "has rpi non-native exception but is not in taxa.csv",
283
+ );
284
+ continue;
285
+ }
286
+ // Make sure it is non-native in our list.
287
+ if (taxon.isCANative()) {
288
+ errorLog.log(
289
+ name,
290
+ "has rpi non-native exception but is native in local list",
291
+ );
292
+ }
293
+ break;
294
+ case "notingeo":
295
+ // Make sure the taxon is not in our list.
296
+ if (taxon) {
297
+ errorLog.log(
298
+ name,
299
+ "has rpi notingeo exception but is in taxa.csv",
300
+ );
301
+ }
302
+ // Make sure it is listed in local area in RPI.
303
+ if (!this.#shouldBeHere(rpiData, countyCodes)) {
304
+ errorLog.log(
305
+ name,
306
+ "has rpi notingeo exception but is not listed in local counties in rpi.csv",
307
+ );
308
+ }
309
+ break;
310
+ case "translation": {
311
+ // Make sure the translated name is in our list.
312
+ if (!taxon) {
313
+ errorLog.log(
314
+ name,
315
+ "has rpi translation exception, but target not found",
316
+ );
317
+ }
318
+ // Make sure there is a matching translation exception.
319
+ const translatedName = exceptions.getValue(
320
+ v,
321
+ "rpi",
322
+ "translation-to-rpi",
323
+ );
324
+ if (translatedName !== name) {
325
+ errorLog.log(
326
+ name,
327
+ "has rpi translation exception, but no reverse translation",
328
+ );
329
+ }
330
+ break;
331
+ }
332
+ case "translation-to-rpi": {
333
+ // Make sure there is a matching translation exception.
334
+ const translatedName = exceptions.getValue(
335
+ v,
336
+ "rpi",
337
+ "translation",
338
+ );
339
+ if (translatedName !== name) {
340
+ errorLog.log(
341
+ name,
342
+ "has rpi translation-to-rpi exception, but no reverse translation",
343
+ );
344
+ }
345
+ break;
346
+ }
347
+ default:
348
+ errorLog.log(name, "unrecognized rpi exception", k);
349
+ }
350
+ }
351
+ }
352
+ }
353
+
354
+ /**
355
+ * @param {string} name
356
+ * @param {import("../exceptions.js").Exceptions} exceptions
357
+ * @param {...string} args
358
+ */
359
+ static #hasExceptions(name, exceptions, ...args) {
360
+ for (const arg of args) {
361
+ if (exceptions.hasException(name, "rpi", arg)) {
362
+ return true;
363
+ }
364
+ }
365
+ return false;
366
+ }
367
+
368
+ /**
369
+ * @param {string} toolsDataDir
370
+ * @param {Taxa} taxa
371
+ * @param {import("../exceptions.js").Exceptions} exceptions
372
+ * @param {ErrorLog} errorLog
373
+ */
374
+ static async #scrape(toolsDataDir, taxa, exceptions, errorLog) {
375
+ const toolsDataPath = toolsDataDir + "/rpi";
376
+ const fileName = HTML_FILE_NAME;
377
+ const filePath = toolsDataPath + "/" + fileName;
378
+
379
+ const html = Files.read(filePath);
380
+ const re = /href="\/Plants\/Details\/(\d+)".*?>(.*?)<\/a>/gs;
381
+ const matches = [...html.matchAll(re)];
382
+ /** @type {Object<string,string>} */
383
+ const rpiIDs = {};
384
+ for (const match of matches) {
385
+ const id = match[1];
386
+ const name = match[2]
387
+ .replaceAll(/<\/?em>/g, "")
388
+ .trim()
389
+ .replace(" ssp.", " subsp.");
390
+ rpiIDs[name] = id;
391
+ }
392
+
393
+ for (const taxon of taxa.getTaxonList()) {
394
+ if (!taxon.getRPIRankAndThreat()) {
395
+ continue;
396
+ }
397
+ const name = taxon.getName();
398
+ const translatedName =
399
+ exceptions.getValue(name, "rpi", "translation-to-rpi", name) ??
400
+ name;
401
+ const id = rpiIDs[translatedName];
402
+ if (!id) {
403
+ errorLog.log(name, "not found in RPI HTML", translatedName);
404
+ }
405
+ if (id !== taxon.getRPIID()) {
406
+ errorLog.log(
407
+ name,
408
+ "RPI ID in " + fileName + " does not match site data",
409
+ id,
410
+ taxon.getRPIID(),
411
+ );
412
+ }
413
+ }
414
+ }
415
+
416
+ /**
417
+ * @param {Object<string,string>} row
418
+ * @param {string[]} countyCodes
419
+ */
420
+ static #shouldBeHere(row, countyCodes) {
421
+ if (countyCodes.length === 0) {
422
+ return true;
423
+ }
424
+
425
+ const rpiCounties = row["Counties"];
426
+ for (const countyCode of countyCodes) {
427
+ if (rpiCounties.includes(countyCode)) {
428
+ return true;
429
+ }
430
+ }
431
+ return false;
432
+ }
433
+ }
434
+
435
+ export { RPI };
@@ -55,10 +55,10 @@ export async function getTaxonPhotos(taxaToUpdate) {
55
55
  /** @type {InatPhotoInfo[]} */
56
56
  const taxonPhotos = [];
57
57
  for (const taxonPhoto of iNatTaxonPhotos) {
58
- const ext = taxonPhoto.photo.medium_url.split(".").at(-1);
59
- if (!ext) {
60
- continue;
61
- }
58
+ const url = taxonPhoto.photo.medium_url || taxonPhoto.photo.url;
59
+ if (!url) continue;
60
+ const ext = url.split(".").at(-1);
61
+ if (!ext) continue;
62
62
  /** @type {InatPhotoInfo} */
63
63
  const obj = {
64
64
  id: taxonPhoto.photo.id.toString(),
@@ -4,7 +4,6 @@ import { GenericPage } from "../genericpage.js";
4
4
  import { ExternalSites } from "../externalsites.js";
5
5
  import { HTML } from "../html.js";
6
6
  import { HTMLTaxon } from "../htmltaxon.js";
7
- import { Config } from "../config.js";
8
7
 
9
8
  class PageTaxon extends GenericPage {
10
9
  #config;
@@ -188,14 +187,9 @@ class PageTaxon extends GenericPage {
188
187
  );
189
188
  html += "</div>";
190
189
 
191
- const footerTextPath =
192
- Config.getPackageDir() +
193
- "/data/text/" +
194
- this.getBaseFileName() +
195
- ".footer.md";
196
- html += HTMLTaxon.getMarkdownSection(footerTextPath);
190
+ html += HTMLTaxon.getFooterHTML(this.#taxon);
197
191
 
198
- const photos = this.#taxon.getPhotos();
192
+ const photos = this.#taxon.getPhotos().slice( 0, 5 );
199
193
  if (photos.length > 0) {
200
194
  let photosHtml = "";
201
195
  for (const photo of photos) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ca-plant-list/ca-plant-list",
3
- "version": "0.4.14",
3
+ "version": "0.4.16",
4
4
  "description": "Tools to create Jekyll files for a website listing plants in an area of California.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -9,15 +9,30 @@
9
9
  },
10
10
  "homepage": "https://github.com/ca-plants/ca-plant-list",
11
11
  "type": "module",
12
+ "files": [
13
+ "data",
14
+ "ebook",
15
+ "jekyll",
16
+ "lib",
17
+ "scripts",
18
+ "types"
19
+ ],
12
20
  "exports": {
13
21
  ".": "./lib/index.js"
14
22
  },
15
23
  "types": "./lib/index.d.ts",
24
+ "scripts": {
25
+ "check": "npm run eslint && npm run tsc",
26
+ "eslint": "npx eslint",
27
+ "prettier": "npx prettier -l .",
28
+ "tsc": "npx tsc"
29
+ },
16
30
  "bin": {
17
31
  "ca-plant-list": "scripts/build-site.js",
18
32
  "ca-plant-book": "scripts/build-ebook.js",
19
33
  "cpl-photos": "scripts/cpl-photos.js",
20
- "cpl-tools": "scripts/cpl-tools.js"
34
+ "cpl-tools": "scripts/cpl-tools.js",
35
+ "inatobsphotos": "scripts/inatobsphotos.js"
21
36
  },
22
37
  "dependencies": {
23
38
  "archiver": "^5.3.1",
@@ -32,14 +47,15 @@
32
47
  "unzipper": "^0.10.11"
33
48
  },
34
49
  "devDependencies": {
50
+ "@htmltools/scrape": "^0.1.0",
35
51
  "@types/archiver": "^6.0.2",
36
52
  "@types/cli-progress": "^3.11.6",
37
53
  "@types/markdown-it": "^14.1.2",
38
- "@types/node": "^22.7.8",
54
+ "@types/node": "^22.10.2",
39
55
  "@types/unzipper": "^0.10.9",
40
- "eslint": "^9.13.0",
56
+ "eslint": "^9.17.0",
41
57
  "exceljs": "^4.4.0",
42
- "prettier": "^3.3.3",
43
- "typescript": "^5.6.3"
58
+ "prettier": "^3.4.2",
59
+ "typescript": "^5.7.2"
44
60
  }
45
61
  }
@@ -141,6 +141,7 @@ function readPhotos() {
141
141
  const taxonPhotos = new Map();
142
142
 
143
143
  /** @type {InatCsvPhoto[]} */
144
+ // @ts-ignore
144
145
  const csvPhotos = CSV.readFile(photosFileName);
145
146
  for (const csvPhoto of csvPhotos) {
146
147
  const taxonName = csvPhoto.name;
@@ -1,14 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import * as path from "node:path";
4
3
  import { Option } from "commander";
5
- import { Taxa } from "../lib/taxa.js";
6
4
  import { Program } from "../lib/program.js";
7
5
  import { Calflora } from "../lib/tools/calflora.js";
8
6
  import { Exceptions } from "../lib/exceptions.js";
9
7
  import { ErrorLog } from "../lib/errorlog.js";
10
8
  import { Calscape } from "../lib/tools/calscape.js";
11
9
  import { INat } from "../lib/tools/inat.js";
10
+ import { JepsonEFlora } from "../lib/tools/jepsoneflora.js";
11
+ import { RPI } from "../lib/tools/rpi.js";
12
+ import { Config } from "../lib/config.js";
13
+ import { Taxa } from "../lib/taxa.js";
12
14
 
13
15
  const TOOLS = {
14
16
  CALFLORA: "calflora",
@@ -29,7 +31,6 @@ const ALL_TOOLS = [
29
31
  TOOLS.TEXT,
30
32
  ];
31
33
 
32
- const OPT_LOADER = "loader";
33
34
  const OPT_TOOL = "tool";
34
35
 
35
36
  const TOOLS_DATA_DIR = "./external_data";
@@ -48,8 +49,8 @@ async function build(program, options) {
48
49
  }
49
50
 
50
51
  const exceptions = new Exceptions(options.datadir);
51
- // const config = new Config(options.datadir);
52
- const taxa = await getTaxa(options);
52
+ const config = new Config(options.datadir);
53
+ const taxa = await Taxa.loadTaxa(options);
53
54
 
54
55
  const errorLog = new ErrorLog(options.outputdir + "/log.tsv", true);
55
56
  for (const tool of tools) {
@@ -84,26 +85,26 @@ async function build(program, options) {
84
85
  );
85
86
  break;
86
87
  case TOOLS.JEPSON_EFLORA: {
87
- // const eflora = new JepsonEFlora(
88
- // TOOLS_DATA_DIR,
89
- // taxa,
90
- // errorLog,
91
- // options.efLognotes,
92
- // );
93
- // await eflora.analyze(exceptions);
88
+ const eflora = new JepsonEFlora(
89
+ TOOLS_DATA_DIR,
90
+ taxa,
91
+ errorLog,
92
+ options.efLognotes,
93
+ );
94
+ await eflora.analyze(exceptions);
94
95
  break;
95
96
  }
96
97
  case TOOLS.JEPSON_FAM:
97
98
  // await JepsonFamilies.build(TOOLS_DATA_DIR, options.outputdir);
98
99
  break;
99
100
  case TOOLS.RPI:
100
- // await RPI.analyze(
101
- // TOOLS_DATA_DIR,
102
- // taxa,
103
- // config,
104
- // exceptions,
105
- // errorLog,
106
- // );
101
+ await RPI.analyze(
102
+ TOOLS_DATA_DIR,
103
+ taxa,
104
+ config,
105
+ exceptions,
106
+ errorLog,
107
+ );
107
108
  break;
108
109
  case TOOLS.TEXT:
109
110
  // SupplementalText.analyze(taxa, errorLog);
@@ -117,29 +118,6 @@ async function build(program, options) {
117
118
  errorLog.write();
118
119
  }
119
120
 
120
- /**
121
- * @param {import("commander").OptionValues} options
122
- */
123
- async function getTaxa(options) {
124
- const errorLog = new ErrorLog(options.outputdir + "/errors.tsv", true);
125
-
126
- const loader = options[OPT_LOADER];
127
- let taxa;
128
- if (loader) {
129
- const taxaLoaderClass = await import("file:" + path.resolve(loader));
130
- taxa = await taxaLoaderClass.TaxaLoader.loadTaxa(options, errorLog);
131
- } else {
132
- taxa = new Taxa(
133
- Program.getIncludeList(options.datadir),
134
- errorLog,
135
- options.showFlowerErrors,
136
- );
137
- }
138
-
139
- errorLog.write();
140
- return taxa;
141
- }
142
-
143
121
  const program = Program.getProgram();
144
122
  program.addOption(
145
123
  new Option(
@@ -156,16 +134,12 @@ program.option(
156
134
  "--ef-lognotes",
157
135
  "When running the jepson-eflora tool, include eFlora notes, invalid names, etc. in the log file.",
158
136
  );
159
- program.option(
160
- "--loader <path>",
161
- "The path (relative to the current directory) of the JavaScript file containing the TaxaLoader class. If not provided, the default TaxaLoader will be used.",
162
- );
163
137
  program.option("--update", "Update taxa.csv to remove errors if possible.");
164
138
  program.addHelpText(
165
139
  "after",
166
140
  `
167
141
  Tools:
168
- 'all' runs the 'calflora', 'inat', 'jepson-eflora', 'rpi', and 'text' tools.
142
+ 'all' runs the 'calflora', '${TOOLS.CALSCAPE}', 'inat', 'jepson-eflora', 'rpi', and 'text' tools.
169
143
  '${TOOLS.CALFLORA}' retrieves data from Calflora and compares with local data.
170
144
  '${TOOLS.CALSCAPE}' retrieves data from Calscape and compares with local data.
171
145
  '${TOOLS.INAT}' retrieves data from iNaturalist and compares with local data.
@@ -0,0 +1,3 @@
1
+ #!/bin/bash
2
+
3
+ npm publish --access public