@ca-plant-list/ca-plant-list 0.4.4 → 0.4.6

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/data/taxa.csv CHANGED
@@ -114,6 +114,7 @@ Antirrhinum kelloggii,,N,13568,401,165675
114
114
  Antirrhinum majus,snapdragon,X,13557,404,48969
115
115
  Antirrhinum thompsonii,Sierra snapdragon,N,108978,14286,168306,,pink,4,8
116
116
  Antirrhinum vexillocalyculatum subsp. vexillocalyculatum,,N,88873,10499,840919,annual,purple,6,8
117
+ Aphanes occidentalis,western lady's mantle,N,13608,,1452907,annual,,3,5
117
118
  Aphyllon californicum subsp. jepsonii,,N,103325,13438,802459
118
119
  Aphyllon epigalium subsp. epigalium,,N,103327,13527,809377
119
120
  Aphyllon fasciculatum,clustered broom-rape,N,100018,13441,802543
@@ -0,0 +1,13 @@
1
+ export default [
2
+ {
3
+ rules: {
4
+ indent: ["error", 4, {
5
+ SwitchCase: 1,
6
+ }],
7
+
8
+ "linebreak-style": ["error", "unix"],
9
+ quotes: ["error", "double"],
10
+ semi: ["error", "always"],
11
+ },
12
+ }
13
+ ];
package/jekyll/index.md CHANGED
@@ -1,3 +1,6 @@
1
1
  ---
2
+ layout: html
2
3
  title: HOME PAGE
3
4
  ---
5
+
6
+ This is the home page.
@@ -167,7 +167,7 @@ class PlantBook extends EBook {
167
167
  function getRequiredConfigValue(config, name) {
168
168
  const value = config.getConfigValue("ebook", name);
169
169
  if (value === undefined) {
170
- throw new Error();
170
+ throw new Error(`Failed to find ebook config for ${name}`);
171
171
  }
172
172
  return value;
173
173
  }
@@ -0,0 +1,43 @@
1
+ import {
2
+ CC0,
3
+ CC_BY,
4
+ CC_BY_NC,
5
+ COPYRIGHT,
6
+ Photo,
7
+ } from "./photo.js";
8
+
9
+ class InatPhoto extends Photo {
10
+ /** @type {number} */
11
+ inatPhotoId;
12
+ /** @type {string} */
13
+ ext;
14
+
15
+ /**
16
+ * @param {number} id
17
+ * @param {string} ext
18
+ * @param {InatLicenseCode} licenseCode
19
+ * @param {string} attrName
20
+ */
21
+ constructor( id, ext, licenseCode, attrName ) {
22
+ /** @type {typeof COPYRIGHT | typeof CC_BY | typeof CC_BY_NC | typeof CC0} */
23
+ let rights = COPYRIGHT;
24
+ if ( licenseCode === "cc0" ) rights = CC0;
25
+ else if ( licenseCode === "cc-by" ) rights = CC_BY;
26
+ else if ( licenseCode === "cc-by-nc" ) rights = CC_BY_NC;
27
+ super( null, attrName, rights );
28
+ this.inatPhotoId = id;
29
+ this.ext = ext;
30
+ }
31
+
32
+ getUrl() {
33
+ return `https://inaturalist-open-data.s3.amazonaws.com/photos/${this.inatPhotoId}/medium.${this.ext}`;
34
+ }
35
+
36
+ getSourceUrl() {
37
+ return `https://www.inaturalist.org/photos/${this.inatPhotoId}`;
38
+ }
39
+ }
40
+
41
+ export {
42
+ InatPhoto
43
+ };
package/lib/photo.js ADDED
@@ -0,0 +1,44 @@
1
+ const CC0 = "CC0";
2
+ const CC_BY = "CC BY";
3
+ const CC_BY_NC = "CC BY-NC";
4
+ const COPYRIGHT = "C";
5
+
6
+ class Photo {
7
+ /** @type {string?} */
8
+ #url;
9
+ /** @type {string?} */
10
+ rightsHolder;
11
+ /** @type {null | typeof COPYRIGHT | typeof CC_BY | typeof CC_BY_NC | typeof CC0} */
12
+ rights;
13
+
14
+ /**
15
+ * @param {string?} url
16
+ * @param {string?} rightsHolder
17
+ * @param {null | typeof COPYRIGHT | typeof CC_BY | typeof CC_BY_NC | typeof CC0} rights
18
+ */
19
+ constructor( url, rightsHolder, rights ) {
20
+ this.#url = url;
21
+ this.rightsHolder = rightsHolder;
22
+ this.rights = rights;
23
+ }
24
+
25
+ getUrl() {
26
+ return this.#url;
27
+ }
28
+
29
+ /**
30
+ * Return URL of page from whence this photo came
31
+ * @return {string?}
32
+ */
33
+ getSourceUrl() {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ export {
39
+ CC0,
40
+ CC_BY,
41
+ CC_BY_NC,
42
+ COPYRIGHT,
43
+ Photo
44
+ };
package/lib/taxa.js CHANGED
@@ -1,9 +1,13 @@
1
+ import * as fs from "node:fs";
2
+ import path from "node:path";
3
+
1
4
  import { Config } from "./config.js";
2
5
  import { CSV } from "./csv.js";
3
6
  import { Genera } from "./genera.js";
4
7
  import { Taxon } from "./taxon.js";
5
8
  import { Families } from "./families.js";
6
9
  import { FlowerColor } from "./flowercolor.js";
10
+ import { InatPhoto } from "./inat_photo.js";
7
11
 
8
12
  const FLOWER_COLORS = [
9
13
  { name: "white", color: "white" },
@@ -35,6 +39,7 @@ class Taxa {
35
39
  * @param {function(TaxonData,Genera):Taxon} taxonFactory
36
40
  * @param {TaxonData[]} [extraTaxa=[]]
37
41
  * @param {SynonymData[]} [extraSynonyms=[]]
42
+ * @param {boolean} includePhotos
38
43
  */
39
44
  constructor(
40
45
  inclusionList,
@@ -42,7 +47,8 @@ class Taxa {
42
47
  showFlowerErrors,
43
48
  taxonFactory = (td, g) => new Taxon(td, g),
44
49
  extraTaxa = [],
45
- extraSynonyms = []
50
+ extraSynonyms = [],
51
+ includePhotos = true
46
52
  ) {
47
53
  this.#isSubset = inclusionList !== true;
48
54
 
@@ -76,11 +82,39 @@ class Taxa {
76
82
  a.getName().localeCompare(b.getName())
77
83
  );
78
84
 
85
+
86
+ if ( includePhotos ) {
87
+ this.#loadInatPhotos( dataDir );
88
+ }
89
+
79
90
  const synCSV = CSV.parseFile(dataDir, "synonyms.csv");
80
91
  this.#loadSyns(synCSV, inclusionList);
81
92
  this.#loadSyns(extraSynonyms, inclusionList);
82
93
  }
83
94
 
95
+ /**
96
+ * @param {string} dataDir
97
+ */
98
+ #loadInatPhotos( dataDir ) {
99
+ const photosFileName = "inattaxonphotos.csv";
100
+ if ( fs.existsSync( path.join( dataDir, photosFileName ) ) ) {
101
+ /** @type {InatCsvPhoto[]} */
102
+ const csvPhotos = CSV.parseFile( dataDir, photosFileName );
103
+ for ( const csvPhoto of csvPhotos ) {
104
+ const taxon = this.getTaxon(csvPhoto.name);
105
+ if(!taxon) {
106
+ continue;
107
+ }
108
+ taxon.addPhoto(new InatPhoto(
109
+ csvPhoto.id,
110
+ csvPhoto.ext,
111
+ csvPhoto.licenseCode,
112
+ csvPhoto.attrName
113
+ ) );
114
+ }
115
+ }
116
+ }
117
+
84
118
  getFamilies() {
85
119
  return this.#families;
86
120
  }
@@ -175,9 +209,9 @@ class Taxa {
175
209
  const color = this.#flower_colors[colorName];
176
210
  if (!color) {
177
211
  throw new Error(
178
- 'flower color "' +
212
+ "flower color \"" +
179
213
  colorName +
180
- '" not found for ' +
214
+ "\" not found for " +
181
215
  name
182
216
  );
183
217
  }
package/lib/taxon.js CHANGED
@@ -34,6 +34,8 @@ class Taxon {
34
34
  #rankGlobal;
35
35
  /** @type {string[]} */
36
36
  #synonyms = [];
37
+ /** @type {Photo[]} */
38
+ #photos = [];
37
39
 
38
40
  /**
39
41
  * @param {TaxonData} data
@@ -92,6 +94,17 @@ class Taxon {
92
94
  }
93
95
  }
94
96
 
97
+ /**
98
+ * @param {InatPhoto} photo
99
+ */
100
+ addPhoto(photo) {
101
+ this.#photos = this.#photos.concat( [photo] );
102
+ }
103
+
104
+ getPhotos() {
105
+ return this.#photos;
106
+ }
107
+
95
108
  getBaseFileName() {
96
109
  // Convert spaces to "-" and remove ".".
97
110
  return this.#name.replaceAll(" ", "-").replaceAll(".", "");
package/lib/util.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Break an array into chunks of a desired size
3
+ * https://github.com/you-dont-need/You-Dont-Need-Lodash-Underscore?tab=readme-ov-file#_chunk
4
+ * @param {any[]} input
5
+ * @param {number} size
6
+ */
7
+ export function chunk( input, size ) {
8
+ return input.reduce((arr, item, idx) => {
9
+ return idx % size === 0
10
+ ? [...arr, [item]]
11
+ : [...arr.slice(0, -1), [...arr.slice(-1)[0], item]];
12
+ }, []);
13
+ }
14
+
15
+ /**
16
+ * @param {number} time
17
+ */
18
+ export async function sleep( time ) {
19
+ return new Promise( resolve => setTimeout( resolve, time ) );
20
+ }
@@ -194,6 +194,34 @@ class PageTaxon extends GenericPage {
194
194
  );
195
195
  html += "</div>";
196
196
 
197
+ const photos = this.#taxon.getPhotos( );
198
+ if ( photos.length > 0 ) {
199
+ let photosHtml = "";
200
+ for ( const photo of photos ) {
201
+ photosHtml += `
202
+ <figure class="col">
203
+ <a href="${photo.getSourceUrl()}">
204
+ <img
205
+ class="img-fluid"
206
+ src="${photo.getUrl()}"
207
+ />
208
+ </a>
209
+ <figcaption>
210
+ ${photo.rights === "CC0" ? "By" : "(c)"}
211
+ ${photo.rightsHolder}
212
+ ${photo.rights && `(${photo.rights})`}
213
+ </figcaption>
214
+ </figure>
215
+ `;
216
+ }
217
+ html += `
218
+ <h2>Photos</h2>
219
+ <div class="row">
220
+ ${photosHtml}
221
+ </div>
222
+ `;
223
+ }
224
+
197
225
  const footerTextPath =
198
226
  Config.getPackageDir() +
199
227
  "/data/text/" +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ca-plant-list/ca-plant-list",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
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": {
@@ -19,16 +19,19 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "archiver": "^5.3.1",
22
+ "cli-progress": "^3.12.0",
22
23
  "commander": "^12.1.0",
23
24
  "csv-parse": "^5.3.1",
25
+ "csv-stringify": "^6.5.1",
24
26
  "image-size": "^1.1.1",
25
27
  "markdown-it": "^14.1.0",
26
28
  "sharp": "^0.32.1",
27
- "svgo-ll": "^5.5.0",
29
+ "svgo-ll": "^5.6.0",
28
30
  "unzipper": "^0.10.11"
29
31
  },
30
32
  "devDependencies": {
31
33
  "@types/archiver": "^6.0.2",
34
+ "@types/cli-progress": "^3.11.6",
32
35
  "@types/markdown-it": "^14.1.2",
33
36
  "@types/node": "^22.7.8",
34
37
  "@types/unzipper": "^0.10.9",
@@ -42,7 +42,7 @@ class JekyllRenderer {
42
42
  addConfigFile(configFiles, this.#srcDir, "_config.yml");
43
43
  addConfigFile(configFiles, this.#srcDir, "_config-local.yml");
44
44
  addConfigFile(configFiles, ".", "_config-dev.yml");
45
- options.push("--config", '"' + configFiles.join() + '"');
45
+ options.push("--config", `"${configFiles.join()}"`);
46
46
 
47
47
  const result = child_process.execSync(
48
48
  "bundle exec jekyll build " + options.join(" ")
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "fs";
4
+ import cliProgress from "cli-progress";
5
+ import { stringify } from "csv-stringify";
6
+ import path from "path";
7
+
8
+ import { ErrorLog } from "../lib/errorlog.js";
9
+ import { Program } from "../lib/program.js";
10
+ import { Taxa } from "../lib/taxa.js";
11
+ import { chunk, sleep } from "../lib/util.js";
12
+
13
+ // While I'm guessing the products of this data will be non-commercial, it's
14
+ // not clear how they'll be licensed so the ShareAlike clause is out, and
15
+ // they'll probably be derivative works so the "No Derivatives" clause should
16
+ // be respected.
17
+ const ALLOWED_LICENSE_CODES = [
18
+ "cc0", "cc-by", "cc-by-nc"
19
+ ];
20
+
21
+ /**
22
+ * @param {Taxon[]} taxa
23
+ * @return {Promise<InatApiTaxon[]>}
24
+ */
25
+ async function fetchInatTaxa( taxa ) {
26
+ const inatTaxonIDs = taxa.map( taxon => taxon.getINatID( ) ).filter( Boolean );
27
+ const url = `https://api.inaturalist.org/v2/taxa/${inatTaxonIDs.join( "," )}?fields=(taxon_photos:(photo:(medium_url:!t,attribution:!t,license_code:!t)))`;
28
+ const resp = await fetch( url );
29
+ if (!resp.ok) {
30
+ const error = await resp.text();
31
+ throw new Error(`Failed to fetch taxa from iNat: ${error}`);
32
+ }
33
+ const json = await resp.json();
34
+ return json.results;
35
+ }
36
+
37
+ /**
38
+ * @param {CommandLineOptions} options
39
+ */
40
+ async function getTaxonPhotos( options ) {
41
+ const errorLog = new ErrorLog(options.outputdir + "/errors.tsv");
42
+ const taxa = new Taxa(
43
+ Program.getIncludeList(options.datadir),
44
+ errorLog,
45
+ false
46
+ );
47
+ const targetTaxa = taxa.getTaxonList( );
48
+
49
+ const filename = path.join( "data", "inattaxonphotos.csv" );
50
+ const writableStream = fs.createWriteStream( filename );
51
+ const columns = [
52
+ "name",
53
+ "id",
54
+ "ext",
55
+ "licenseCode",
56
+ "attrName",
57
+ ];
58
+ const stringifier = stringify( { header: true, columns: columns } );
59
+ stringifier.pipe(writableStream);
60
+ const prog = new cliProgress.SingleBar({
61
+ format: "Downloading [{bar}] {percentage}% | ETA: {eta}s | {value}/{total}",
62
+ etaBuffer: targetTaxa.length
63
+ });
64
+ prog.setMaxListeners( 100 );
65
+ prog.start( targetTaxa.length, 0 );
66
+
67
+ // Fetch endpoint can load multiple taxa, but it will created some long URLs so best to keep this smallish
68
+ for ( const batch of chunk( targetTaxa, 30 ) ) {
69
+ const inatTaxa = await fetchInatTaxa( batch );
70
+ for ( const taxon of batch ) {
71
+ prog.increment( );
72
+ const iNatTaxon = inatTaxa.find( it => it.id === Number( taxon.getINatID() ) );
73
+ if ( !iNatTaxon ) continue;
74
+ // Just get the CC-licensed ones, 5 per taxon should be fine (max is 20 on iNat). Whether or not
75
+ const taxonPhotos = iNatTaxon.taxon_photos
76
+ .filter( tp => ALLOWED_LICENSE_CODES.includes( tp.photo.license_code ) )
77
+ .slice( 0, 5 );
78
+
79
+ for ( const taxonPhoto of taxonPhotos ) {
80
+ const row = [
81
+ taxon.getName(),
82
+ taxonPhoto.photo.id,
83
+ taxonPhoto.photo.medium_url.split( "." ).at( -1 ),
84
+ // Need the license code to do attribution properly
85
+ taxonPhoto.photo.license_code,
86
+ // Photographers retain copyright for most CC licenses,
87
+ // except CC0, so attribution is a bit different
88
+ (
89
+ taxonPhoto.photo.attribution.match( /\(c\) (.*?),/ )?.[1]
90
+ || taxonPhoto.photo.attribution.match( /uploaded by (.*)/ )?.[1]
91
+ )
92
+ ];
93
+ stringifier.write( row );
94
+ }
95
+ }
96
+ // iNat will throttle you if you make more than 1 request a second.
97
+ // See https://www.inaturalist.org/pages/api+recommended+practices
98
+ await sleep( 1_100 );
99
+ }
100
+ prog.stop();
101
+ }
102
+
103
+ const program = Program.getProgram();
104
+ program.action(getTaxonPhotos).description( "Write a CSV to datadir with iNaturalist taxon photos" );
105
+
106
+ await program.parseAsync();
@@ -91,7 +91,7 @@ declare class Taxa {
91
91
 
92
92
  declare class TaxaCol {
93
93
  class?: string;
94
- data: function (Taxon):string;
94
+ data: (taxon:Taxon)=>string
95
95
  title: string;
96
96
  }
97
97
 
@@ -120,6 +120,7 @@ declare class Taxon {
120
120
  getJepsonID(): string;
121
121
  getLifeCycle(): string;
122
122
  getName(): string;
123
+ getPhotos(): Photo[];
123
124
  getRPIRank(): string;
124
125
  getRPIRankAndThreat(): string;
125
126
  getRPIRankAndThreatTooltip(): string;
@@ -152,3 +153,48 @@ declare class TaxonImage {
152
153
  getCaption(): string | undefined;
153
154
  getSrc(): string;
154
155
  }
156
+
157
+ type PhotoRights = "CC0"| "CC BY"| "CC BY-NC"| "C"|null;
158
+
159
+ declare class Photo {
160
+ url?: string;
161
+ rightsHolder: null | string;
162
+ rights?: PhotoRights;
163
+ getUrl: ( ) => string;
164
+ getSourceUrl: ( ) => string;
165
+ }
166
+
167
+ declare class InatPhoto extends Photo {
168
+ inatPhotoId: number;
169
+ ext: string;
170
+ }
171
+
172
+ type InatLicenseCode = "cc-by-nc-sa"
173
+ | "cc-by-nc"
174
+ | "cc-by-nc-nd"
175
+ | "cc-by"
176
+ | "cc-by-sa"
177
+ | "cc-by-nd"
178
+ | "pd"
179
+ | "gdfl"
180
+ | "cc0";
181
+
182
+ declare class InatCsvPhoto {
183
+ name: string;
184
+ id: number;
185
+ ext: string;
186
+ licenseCode: InatLicenseCode;
187
+ attrName: string;
188
+ }
189
+
190
+ declare class InatApiTaxon {
191
+ id: number;
192
+ taxon_photos: {
193
+ photo: {
194
+ id: number;
195
+ attribution: string;
196
+ license_code: InatLicenseCode
197
+ medium_url: string;
198
+ }
199
+ }[]
200
+ }
@@ -1,12 +0,0 @@
1
- {
2
- "editor.formatOnSave": true,
3
- "editor.defaultFormatter": "esbenp.prettier-vscode",
4
- "prettier.tabWidth": 4,
5
- "json.schemas": [
6
- {
7
- "fileMatch": ["**/exceptions.json"],
8
- "url": "./schemas/exceptions.schema.json"
9
- }
10
- ],
11
- "editor.tabSize": 2
12
- }