feat(species): expand catalog to ~1200 edible species from Wikidata
Grow the bundled species catalog from 14 hand-curated entries to ~1200 edible/cultivated species, internationalized in 13 languages (es, en, fr, de, it, pt, ca, gl, eu, ar, zh, ja, ru — Latin + Arabic RTL + CJK + Cyrillic). - Add a reproducible generator (tool/gen_species_catalog.dart) that queries Wikidata (CC0, no attribution burden) in two phases, filters out non-vernacular noise (author citations, ranks, initials) and applies a relevance floor, then merges hand-curated, authoritative core-crop data (tool/curated_overrides.json: names, family, viability_years). GBIF is used only as an identifier. The generated species.json (v3) is committed. - Carry wikidata_qid and gbif_key through the parse/seed pipeline; the columns already existed, so no DB migration. - Rewrite seedBundled to one read + one batch (was a SELECT per species on every startup — a real cost at ~1200 rows) and keep it idempotent with backfill of the new reference fields. - Move species search filtering to SQL (LIKE) so a large catalog is not pulled into memory on every keystroke. - Cover the generator transform, the generated asset, and the new fields with tests.
This commit is contained in:
parent
ba87bf2719
commit
959fa2551c
9 changed files with 68483 additions and 120 deletions
56
apps/app_seeds/tool/README.md
Normal file
56
apps/app_seeds/tool/README.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# Species catalog generator
|
||||
|
||||
Regenerates [`assets/catalog/species.json`](../assets/catalog/species.json) — the
|
||||
bundled, offline, multilingual catalog of edible / cultivated plant species the
|
||||
app seeds into the encrypted database at first run.
|
||||
|
||||
```sh
|
||||
cd apps/app_seeds
|
||||
dart run tool/gen_species_catalog.dart # writes species.json (version 3)
|
||||
dart run tool/gen_species_catalog.dart --dry # prints stats, writes nothing
|
||||
```
|
||||
|
||||
The generated JSON is **committed** to the repo — the app never queries the
|
||||
network for it (local-first, offline).
|
||||
|
||||
## Sources & licensing
|
||||
|
||||
- **Names** come only from **Wikidata** labels and aliases. Wikidata is **CC0**
|
||||
(public domain), so the bundled data carries **no attribution burden** and is
|
||||
clean for redistribution under the app's AGPL-3.0 license.
|
||||
- **`gbif_key`** links the [GBIF](https://www.gbif.org/) taxonomic backbone. It
|
||||
is used **only as an identifier** (a fact, not copyrightable text) — no GBIF
|
||||
vernacular text is bundled, so no CC-BY attribution is triggered.
|
||||
- **Curated core crops** (names, family and `viability_years`) live in
|
||||
[`curated_overrides.json`](curated_overrides.json), hand-maintained. For a
|
||||
listed species the curated common names **win** (placed first, never dropped)
|
||||
and its family/viability override Wikidata; Wikidata still adds more languages.
|
||||
A curated species is always kept even if it would miss the relevance floor.
|
||||
- **`viability_years`** (seed longevity) is **not** in Wikidata — it comes only
|
||||
from the curated overrides (public-domain agricultural-extension figures).
|
||||
Species without an entry simply get no age-based expiry warning.
|
||||
|
||||
## What it does
|
||||
|
||||
1. **Phase 1 — species list (SPARQL):** taxa with a binomial name that are a
|
||||
*crop* (`P279* Q235352`), the source/ingredient of a *food*
|
||||
(`P1672`/`P366`/reverse `P1582` → `P279* Q2095`), with scientific name
|
||||
(`P225`) and GBIF key (`P846`).
|
||||
2. **Phase 2 — names + family:** `rdfs:label` (primary) + `skos:altLabel`
|
||||
(aliases), batched over the species and filtered to the bundled languages;
|
||||
the family is walked up `P171*` to the family-rank ancestor.
|
||||
3. **Transform (`buildCatalog`, pure & unit-tested):** dedup by scientific name;
|
||||
drop non-vernacular noise (the binomial itself, author citations, `var.`/`×`
|
||||
ranks, abbreviated initials); merge the curated overrides (authoritative,
|
||||
first); require a real vernacular in **≥ 4 languages** as a relevance floor so
|
||||
the long tail of obscure single-label taxa is dropped; sort; serialize.
|
||||
|
||||
The current run yields **~1200 species** (`assets/catalog/species.json` ≈ 1.5 MB).
|
||||
|
||||
## Bundled languages
|
||||
|
||||
`es, en, fr, de, it, pt, ca, gl, eu, ar, zh, ja, ru` — a multilingual **seed**,
|
||||
not a ceiling (Latin + Arabic RTL + CJK + Cyrillic). Edit `kCatalogLanguages` in
|
||||
[`gen_species_catalog.dart`](gen_species_catalog.dart) to widen coverage, then
|
||||
regenerate. A species with no name in any bundled language is dropped (add its
|
||||
language rather than assuming es/en).
|
||||
19
apps/app_seeds/tool/curated_overrides.json
Normal file
19
apps/app_seeds/tool/curated_overrides.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"note": "Hand-curated, authoritative data for core crops, merged into the generated catalog by gen_species_catalog.dart. For a listed species the curated common names win (placed first, never dropped by the vernacular filter), and its family/viability_years override Wikidata; Wikidata still enriches with additional languages. A curated species is always kept even if it would miss the relevance floor. `viability_years` is public-domain agricultural-extension seed longevity (Wikidata has none); a conservative single value used to warn about aging lots. Extend this list to pin the names of species that matter to your community.",
|
||||
"species": {
|
||||
"Solanum lycopersicum": { "family": "Solanaceae", "viability_years": 5, "common": { "es": ["Tomate"], "en": ["Tomato"] } },
|
||||
"Solanum melongena": { "family": "Solanaceae", "viability_years": 5, "common": { "es": ["Berenjena"], "en": ["Aubergine", "Eggplant"] } },
|
||||
"Capsicum annuum": { "family": "Solanaceae", "viability_years": 3, "common": { "es": ["Pimiento", "Guindilla"], "en": ["Pepper", "Chilli"] } },
|
||||
"Phaseolus vulgaris": { "family": "Fabaceae", "viability_years": 3, "common": { "es": ["Judía", "Alubia", "Habichuela"], "en": ["Common bean"] } },
|
||||
"Vicia faba": { "family": "Fabaceae", "viability_years": 4, "common": { "es": ["Haba"], "en": ["Broad bean", "Fava bean"] } },
|
||||
"Pisum sativum": { "family": "Fabaceae", "viability_years": 3, "common": { "es": ["Guisante"], "en": ["Pea"] } },
|
||||
"Zea mays": { "family": "Poaceae", "viability_years": 2, "common": { "es": ["Maíz"], "en": ["Maize", "Corn"] } },
|
||||
"Cucurbita pepo": { "family": "Cucurbitaceae", "viability_years": 5, "common": { "es": ["Calabacín", "Calabaza"], "en": ["Courgette", "Zucchini", "Squash"] } },
|
||||
"Cucumis sativus": { "family": "Cucurbitaceae", "viability_years": 5, "common": { "es": ["Pepino"], "en": ["Cucumber"] } },
|
||||
"Lactuca sativa": { "family": "Asteraceae", "viability_years": 3, "common": { "es": ["Lechuga"], "en": ["Lettuce"] } },
|
||||
"Beta vulgaris": { "family": "Amaranthaceae", "viability_years": 4, "common": { "es": ["Acelga", "Remolacha"], "en": ["Chard", "Beetroot"] } },
|
||||
"Allium cepa": { "family": "Amaryllidaceae", "viability_years": 1, "common": { "es": ["Cebolla"], "en": ["Onion"] } },
|
||||
"Allium sativum": { "family": "Amaryllidaceae", "viability_years": 1, "common": { "es": ["Ajo"], "en": ["Garlic"] } },
|
||||
"Daucus carota": { "family": "Apiaceae", "viability_years": 3, "common": { "es": ["Zanahoria"], "en": ["Carrot"] } }
|
||||
}
|
||||
}
|
||||
368
apps/app_seeds/tool/gen_species_catalog.dart
Normal file
368
apps/app_seeds/tool/gen_species_catalog.dart
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
// Regenerates the bundled species catalog (`assets/catalog/species.json`) from
|
||||
// Wikidata (CC0). Dev-only tool — it is NOT shipped in the app; the app only
|
||||
// reads the committed JSON it produces (local-first, offline).
|
||||
//
|
||||
// dart run tool/gen_species_catalog.dart # writes species.json (v3)
|
||||
// dart run tool/gen_species_catalog.dart --dry # prints stats, writes nothing
|
||||
//
|
||||
// Names come only from Wikidata labels/aliases (CC0 → no attribution burden).
|
||||
// GBIF is used solely as an identifier (`gbif_key`, a fact, not copyrightable).
|
||||
// Seed-longevity (`viability_years`) and authoritative core-crop names are
|
||||
// merged from the hand-maintained `tool/curated_overrides.json`. See
|
||||
// `tool/README.md`.
|
||||
//
|
||||
// The pure transform (`buildCatalog`) is separated from the network fetch so it
|
||||
// is unit-testable without hitting the endpoint.
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
/// Locale set bundled at launch — a multilingual seed, not a ceiling. Covers
|
||||
/// Latin, Arabic (RTL), CJK (Chinese + Japanese) and Cyrillic scripts. Order
|
||||
/// here is the order common names are written in the JSON.
|
||||
const List<String> kCatalogLanguages = [
|
||||
'es',
|
||||
'en',
|
||||
'fr',
|
||||
'de',
|
||||
'it',
|
||||
'pt',
|
||||
'ca',
|
||||
'gl',
|
||||
'eu',
|
||||
'ar',
|
||||
'zh',
|
||||
'ja',
|
||||
'ru',
|
||||
];
|
||||
|
||||
const String _endpoint = 'https://query.wikidata.org/sparql';
|
||||
|
||||
/// Wikimedia asks bots to send a descriptive, contactable User-Agent.
|
||||
const String _userAgent =
|
||||
'TanemakiSpeciesCatalogBot/1.0 (https://tanemaki.app; vjrj@comunes.org)';
|
||||
|
||||
const String _catalogNote =
|
||||
'Edible / cultivated plant species, generated from Wikidata (CC0) by '
|
||||
'tool/gen_species_catalog.dart — see tool/README.md to regenerate. `common` '
|
||||
'is a locale-keyed map (Wikidata labels + aliases) the app resolves by the '
|
||||
"user's locale with graceful fallback, never assuming es/en only. "
|
||||
'`gbif_key` links the GBIF taxonomic backbone (identifier only). '
|
||||
'`viability_years` is public-domain agricultural-extension seed longevity '
|
||||
'merged from tool/curated_overrides.json (not in Wikidata); a conservative '
|
||||
'single value used to warn about aging lots.';
|
||||
|
||||
/// Hand-curated, authoritative data for a core crop (from
|
||||
/// `curated_overrides.json`). Its names win over Wikidata's; its family and
|
||||
/// viability override; a curated species is always kept.
|
||||
class CuratedOverride {
|
||||
const CuratedOverride({
|
||||
this.family,
|
||||
this.viabilityYears,
|
||||
this.common = const {},
|
||||
});
|
||||
|
||||
final String? family;
|
||||
final int? viabilityYears;
|
||||
final Map<String, List<String>> common;
|
||||
}
|
||||
|
||||
/// One aggregated species before it is serialized. Mutable during aggregation.
|
||||
class SpeciesRecord {
|
||||
SpeciesRecord({
|
||||
required this.qid,
|
||||
required this.scientificName,
|
||||
this.family,
|
||||
this.gbifKey,
|
||||
Map<String, Set<String>>? common,
|
||||
}) : common = common ?? {};
|
||||
|
||||
final String qid;
|
||||
final String scientificName;
|
||||
String? family;
|
||||
int? gbifKey;
|
||||
|
||||
/// language code → set of names (dedup, insertion-ordered).
|
||||
final Map<String, Set<String>> common;
|
||||
}
|
||||
|
||||
/// Whether [name] reads as a genuine vernacular rather than a scientific name,
|
||||
/// synonym or author citation. Wikidata aliases are full of the latter (commas,
|
||||
/// parentheses, hybrid `×`, `var.`/`subsp.` ranks, or a straight repeat of the
|
||||
/// binomial); those are noise in a common-name list.
|
||||
bool isVernacularName(String name, String scientificNameLower) {
|
||||
final n = name.trim();
|
||||
if (n.isEmpty) return false;
|
||||
if (n.toLowerCase() == scientificNameLower) return false;
|
||||
// Commas/parentheses (ASCII and CJK full-width) and the hybrid sign mark
|
||||
// author citations, synonym lists and hybrid formulas — never a vernacular.
|
||||
if (n.contains(RegExp(r'[,()(),×]'))) return false;
|
||||
if (n.contains(RegExp(r'\b(var|subsp|ssp|f|cv|nothosubsp)\.'))) return false;
|
||||
// A lone initial + period is an abbreviated genus or author citation
|
||||
// ("Z. mays", "Allium cepa L."), not a vernacular.
|
||||
if (n.contains(RegExp(r'\b[A-Z]\.'))) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Pure transform: dedup by scientific name, keep only [languages], drop
|
||||
/// non-vernacular noise, require a name in at least [minLanguages] languages
|
||||
/// (a relevance floor — obscure taxa carry a vernacular in only one), merge the
|
||||
/// hand-curated [curated] overrides (authoritative names/family/viability that
|
||||
/// win over Wikidata and are always kept), and emit an ordered, JSON-ready
|
||||
/// catalog map (`{version, note, species: [...]}`). No network, no I/O.
|
||||
Map<String, dynamic> buildCatalog(
|
||||
Iterable<SpeciesRecord> records,
|
||||
Map<String, CuratedOverride> curated, {
|
||||
List<String> languages = kCatalogLanguages,
|
||||
int minLanguages = 1,
|
||||
}) {
|
||||
// Dedup by scientific name; merge names and backfill family/gbif across the
|
||||
// duplicate entities Wikidata sometimes has for one accepted binomial.
|
||||
final byName = <String, SpeciesRecord>{};
|
||||
for (final r in records) {
|
||||
final name = r.scientificName.trim();
|
||||
if (name.isEmpty) continue;
|
||||
final existing = byName[name];
|
||||
if (existing == null) {
|
||||
byName[name] = r;
|
||||
continue;
|
||||
}
|
||||
existing.family ??= r.family;
|
||||
existing.gbifKey ??= r.gbifKey;
|
||||
for (final entry in r.common.entries) {
|
||||
existing.common.putIfAbsent(entry.key, () => {}).addAll(entry.value);
|
||||
}
|
||||
}
|
||||
|
||||
final species = <Map<String, dynamic>>[];
|
||||
for (final r in byName.values) {
|
||||
final sciLower = r.scientificName.trim().toLowerCase();
|
||||
final override = curated[r.scientificName];
|
||||
final common = <String, List<String>>{};
|
||||
for (final lang in languages) {
|
||||
final ordered = <String>[];
|
||||
final seen = <String>{};
|
||||
// Curated names first (authoritative, kept as-is), then Wikidata's
|
||||
// vernaculars, de-duplicated case-insensitively.
|
||||
for (final n in override?.common[lang] ?? const <String>[]) {
|
||||
if (n.trim().isNotEmpty && seen.add(n.toLowerCase())) ordered.add(n);
|
||||
}
|
||||
for (final n in r.common[lang] ?? const <String>{}) {
|
||||
final v = n.trim();
|
||||
if (!isVernacularName(v, sciLower)) continue;
|
||||
if (seen.add(v.toLowerCase())) ordered.add(v);
|
||||
}
|
||||
if (ordered.isNotEmpty) common[lang] = ordered;
|
||||
}
|
||||
// A relevance floor for Wikidata-only species: obscure taxa carry a real
|
||||
// vernacular in only one language (a lone CJK/Cyrillic label), well-known
|
||||
// crops in many. Curated species bypass the floor.
|
||||
if (override == null && common.length < minLanguages) continue;
|
||||
if (common.isEmpty) continue;
|
||||
|
||||
final family = override?.family ?? r.family;
|
||||
final viability = override?.viabilityYears;
|
||||
species.add({
|
||||
'scientific_name': r.scientificName,
|
||||
'family': ?family,
|
||||
'wikidata_qid': r.qid,
|
||||
if (r.gbifKey != null) 'gbif_key': r.gbifKey,
|
||||
'common': common,
|
||||
'viability_years': ?viability,
|
||||
});
|
||||
}
|
||||
|
||||
species.sort(
|
||||
(a, b) => (a['scientific_name'] as String).compareTo(
|
||||
b['scientific_name'] as String,
|
||||
),
|
||||
);
|
||||
|
||||
return {'version': 3, 'note': _catalogNote, 'species': species};
|
||||
}
|
||||
|
||||
Future<void> main(List<String> args) async {
|
||||
final dryRun = args.contains('--dry');
|
||||
final toolDir = File(Platform.script.toFilePath()).parent;
|
||||
final overridesFile = File('${toolDir.path}/curated_overrides.json');
|
||||
final outFile = File('${toolDir.path}/../assets/catalog/species.json');
|
||||
|
||||
final curated = _readCuratedOverrides(overridesFile);
|
||||
stdout.writeln('Loaded ${curated.length} curated overrides.');
|
||||
|
||||
final client = HttpClient()..userAgent = _userAgent;
|
||||
try {
|
||||
stdout.writeln('Querying Wikidata for edible/cultivated species…');
|
||||
final skeleton = await _fetchSpeciesSkeleton(client);
|
||||
stdout.writeln(
|
||||
'Found ${skeleton.length} candidate species. Fetching '
|
||||
'names in ${kCatalogLanguages.length} languages…',
|
||||
);
|
||||
await _fetchLabels(client, skeleton);
|
||||
|
||||
final catalog = buildCatalog(skeleton.values, curated, minLanguages: 4);
|
||||
final count = (catalog['species'] as List).length;
|
||||
stdout.writeln('Built catalog: $count species with bundled-locale names.');
|
||||
|
||||
if (dryRun) {
|
||||
stdout.writeln('--dry: not writing ${outFile.path}');
|
||||
return;
|
||||
}
|
||||
const encoder = JsonEncoder.withIndent(' ');
|
||||
await outFile.writeAsString('${encoder.convert(catalog)}\n');
|
||||
stdout.writeln('Wrote ${outFile.path}');
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, CuratedOverride> _readCuratedOverrides(File file) {
|
||||
if (!file.existsSync()) return {};
|
||||
final data = jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
|
||||
final species = (data['species'] as Map<String, dynamic>);
|
||||
return species.map((name, raw) {
|
||||
final o = raw as Map<String, dynamic>;
|
||||
final common = (o['common'] as Map<String, dynamic>? ?? const {}).map(
|
||||
(lang, names) => MapEntry(lang, (names as List).cast<String>()),
|
||||
);
|
||||
return MapEntry(
|
||||
name,
|
||||
CuratedOverride(
|
||||
family: o['family'] as String?,
|
||||
viabilityYears: (o['viability_years'] as num?)?.toInt(),
|
||||
common: common,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Phase 1: the list of species QIDs with scientific name, family and GBIF key.
|
||||
Future<Map<String, SpeciesRecord>> _fetchSpeciesSkeleton(
|
||||
HttpClient client,
|
||||
) async {
|
||||
// Taxa with a binomial (or lower) scientific name — CONTAINS(" ") keeps
|
||||
// species and botanical varieties (relevant for seed savers: e.g. the many
|
||||
// Brassica oleracea var. cultivars) while dropping genus-level rows — that are
|
||||
// edible/cultivated, reached three ways: a crop, the source of a food, or the
|
||||
// source of a food ingredient. Taxon rank is intentionally not required: many
|
||||
// cultivated taxa in Wikidata leave P105 unset.
|
||||
const query = r'''
|
||||
SELECT ?item ?sci ?gbif WHERE {
|
||||
?item wdt:P225 ?sci .
|
||||
FILTER(CONTAINS(?sci, " "))
|
||||
{
|
||||
?item wdt:P279* wd:Q235352 . # a crop
|
||||
} UNION {
|
||||
?item wdt:P1672 ?use . # this taxon is source of …
|
||||
?use wdt:P279* wd:Q2095 . # … a food
|
||||
} UNION {
|
||||
?item wdt:P1672 ?use .
|
||||
?use wdt:P279* wd:Q25403900 . # … a food ingredient
|
||||
} UNION {
|
||||
?food wdt:P1582 ?item . # … the natural source of a food
|
||||
?food wdt:P279* wd:Q2095 .
|
||||
} UNION {
|
||||
?item wdt:P366 ?use . # this taxon has use …
|
||||
?use wdt:P279* wd:Q2095 . # … food
|
||||
}
|
||||
OPTIONAL { ?item wdt:P846 ?gbif . }
|
||||
}''';
|
||||
final rows = await _runSparql(client, query);
|
||||
final out = <String, SpeciesRecord>{};
|
||||
for (final row in rows) {
|
||||
final qid = _qid(row['item']?['value'] as String?);
|
||||
final sci = row['sci']?['value'] as String?;
|
||||
if (qid == null || sci == null) continue;
|
||||
final rec = out.putIfAbsent(
|
||||
qid,
|
||||
() => SpeciesRecord(qid: qid, scientificName: sci),
|
||||
);
|
||||
final gbif = row['gbif']?['value'] as String?;
|
||||
if (gbif != null) rec.gbifKey ??= int.tryParse(gbif);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Phase 2: labels + aliases (and the family, walked up `P171*`) in the bundled
|
||||
/// languages, batched over QIDs to keep each query under the endpoint timeout.
|
||||
Future<void> _fetchLabels(
|
||||
HttpClient client,
|
||||
Map<String, SpeciesRecord> skeleton,
|
||||
) async {
|
||||
final langFilter = kCatalogLanguages.map((l) => '"$l"').join(', ');
|
||||
final qids = skeleton.keys.toList();
|
||||
const batchSize = 100;
|
||||
for (var i = 0; i < qids.length; i += batchSize) {
|
||||
final batch = qids.skip(i).take(batchSize);
|
||||
final values = batch.map((q) => 'wd:$q').join(' ');
|
||||
// `kind` orders the primary label ("a", rdfs:label — usually the real
|
||||
// vernacular) ahead of aliases ("b", skos:altLabel — often synonyms).
|
||||
final query =
|
||||
'''
|
||||
SELECT ?item ?label ?kind ?family WHERE {
|
||||
VALUES ?item { $values }
|
||||
OPTIONAL {
|
||||
?item wdt:P171* ?fam . # walk up to the family-rank ancestor
|
||||
?fam wdt:P105 wd:Q35409 ;
|
||||
wdt:P225 ?family .
|
||||
}
|
||||
{ ?item rdfs:label ?label . BIND("a" AS ?kind) }
|
||||
UNION
|
||||
{ ?item skos:altLabel ?label . BIND("b" AS ?kind) }
|
||||
FILTER(LANG(?label) IN ($langFilter))
|
||||
}''';
|
||||
final rows = await _runSparql(client, query);
|
||||
// Primary labels first so they land first in each language's ordered set.
|
||||
rows.sort(
|
||||
(a, b) => (a['kind']?['value'] as String? ?? 'b').compareTo(
|
||||
b['kind']?['value'] as String? ?? 'b',
|
||||
),
|
||||
);
|
||||
for (final row in rows) {
|
||||
final qid = _qid(row['item']?['value'] as String?);
|
||||
final label = row['label'];
|
||||
if (qid == null || label == null) continue;
|
||||
final rec = skeleton[qid];
|
||||
if (rec == null) continue;
|
||||
rec.family ??= row['family']?['value'] as String?;
|
||||
final lang = label['xml:lang'] as String?;
|
||||
final value = label['value'] as String?;
|
||||
if (lang == null || value == null) continue;
|
||||
rec.common.putIfAbsent(lang, () => {}).add(value);
|
||||
}
|
||||
stdout.writeln(
|
||||
' labels: ${(i + batchSize).clamp(0, qids.length)}'
|
||||
'/${qids.length}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a SPARQL SELECT and returns the `results.bindings` list.
|
||||
Future<List<Map<String, dynamic>>> _runSparql(
|
||||
HttpClient client,
|
||||
String query,
|
||||
) async {
|
||||
final uri = Uri.parse(
|
||||
_endpoint,
|
||||
).replace(queryParameters: {'query': query, 'format': 'json'});
|
||||
final request = await client.getUrl(uri);
|
||||
request.headers.set(
|
||||
HttpHeaders.acceptHeader,
|
||||
'application/sparql-results+json',
|
||||
);
|
||||
final response = await request.close();
|
||||
if (response.statusCode != 200) {
|
||||
final body = await response.transform(utf8.decoder).join();
|
||||
throw HttpException('SPARQL ${response.statusCode}: $body');
|
||||
}
|
||||
final body = await response.transform(utf8.decoder).join();
|
||||
final decoded = jsonDecode(body) as Map<String, dynamic>;
|
||||
final bindings = (decoded['results']?['bindings'] as List?) ?? const [];
|
||||
return bindings.cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
/// Extracts the `Qxxxx` id from a Wikidata entity URI.
|
||||
String? _qid(String? uri) {
|
||||
if (uri == null) return null;
|
||||
final i = uri.lastIndexOf('/');
|
||||
return i == -1 ? uri : uri.substring(i + 1);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue