diff --git a/.github/workflows/systems_csv_pr_checks.yml b/.github/workflows/systems_csv_pr_checks.yml new file mode 100644 index 00000000..cc02b361 --- /dev/null +++ b/.github/workflows/systems_csv_pr_checks.yml @@ -0,0 +1,81 @@ +name: systems.csv PR checks + +# Runs whenever a pull request adds or changes systems.csv. +# 1. sort - re-sort the file and push the result as a separate commit +# (same-repo PR branches only; forks are skipped, see below). +# 2. validate-structure- fail on malformed rows / missing required values. +# 3. check-new-urls - fail if any newly added URL does not return HTTP 200-299. +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - systems.csv + +# Cancel superseded runs when new commits are pushed to the same PR. +concurrency: + group: systems-csv-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + sort: + name: Sort systems.csv + runs-on: ubuntu-latest + # The default GITHUB_TOKEN cannot push to a forked repository, so auto-sorting + # is limited to branches in this repo. Fork PRs are left untouched here and get + # sorted when master is next updated. + if: github.event.pull_request.head.repo.full_name == github.repository + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Sort systems.csv + run: node scripts/sort-systems-csv.js systems.csv + + - name: Commit sorted file if it changed + run: | + if git diff --quiet -- systems.csv; then + echo "systems.csv already sorted; nothing to commit." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add systems.csv + git commit -m "chore: sort systems.csv" + git push origin "HEAD:${{ github.head_ref }}" + + validate-structure: + name: Validate structure + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Validate systems.csv structure + run: node scripts/validate-systems-csv.js systems.csv + + check-new-urls: + name: Check new URLs return 2xx + runs-on: ubuntu-latest + # Only spend network requests once the file is known to be well-formed. + needs: validate-structure + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Extract base systems.csv + run: | + git show "${{ github.event.pull_request.base.sha }}:systems.csv" > /tmp/systems.base.csv 2>/dev/null \ + || : > /tmp/systems.base.csv + - name: Check newly added URLs + run: node scripts/check-new-urls.js /tmp/systems.base.csv systems.csv diff --git a/README.md b/README.md index e8d14d7f..9ee831f1 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,27 @@ If you would like to add a system, please fork this repository and submit a Pull - Create a new branch, and - Propose your changes by opening a [new pull request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests) -Please keep this list alphabetized by country and system name. Alternatively, fill out [this contribution form](https://share.mobilitydata.org/gbfs-feed-contribution-form) for a Github-less contribution. +This list is kept sorted (case-insensitively) by the four leftmost columns, in order: **Country Code**, **Name**, **Location**, then **System ID**. You don't need to insert your new row in exactly the right place: when you open a pull request that changes `systems.csv`, an automated GitHub Action re-sorts the file and pushes the sorted result back to your branch as a separate `chore: sort systems.csv` commit. (This runs for pull requests opened from a branch in this repository. If you are contributing from a fork, the automatic commit is skipped — you can sort the file yourself, see below.) + +Technically, the sort keeps the header row in place and orders the remaining rows by the first four columns, in order: column 1 (Country Code), column 2 (Name), column 3 (Location), then column 4 (System ID). Sorting is **case-insensitive**, so `nextbike Klagenfurt Austria` sorts directly after `LiBike` instead of below every name that happens to start with an uppercase letter. Rows whose keys differ only in case are ordered deterministically by a byte-wise comparison of the whole row. + +Case folding is ASCII-only (`A`-`Z` to `a`-`z`): accented and other non-ASCII characters are compared by their UTF-8 bytes, which means names beginning with such a character (for example `Ökobike`) sort after all ASCII names. Aside from case folding, comparison is byte-wise (equivalent to `LC_ALL=C`). The sorter is also CSV-quoting-aware, so a field that is quoted because it contains a comma is sorted by its real value rather than by the text up to the first comma. Because the comparison is implemented in the scripts rather than delegated to the platform `sort`, the result is identical on macOS and on the Linux CI runner, so sorting locally always matches what the CI produces. + +To reproduce the exact ordering locally, run either of these — pick whichever suits your environment. They are equivalent and produce byte-identical output; the CI workflow runs the Node one. + +```bash +node scripts/sort-systems-csv.js # requires Node.js +./scripts/sort-systems-csv.sh # requires bash, awk, sort, cut +``` + +Both accept an optional path (default `systems.csv`) and a `--check` flag, which reports whether the file is already sorted without modifying it — useful before opening a pull request: + +```bash +node scripts/sort-systems-csv.js --check +./scripts/sort-systems-csv.sh --check +``` + +Alternatively, fill out [this contribution form](https://share.mobilitydata.org/gbfs-feed-contribution-form) for a GitHub-less contribution. * [systems.csv](systems.csv) Field Name | REQUIRED | Definition diff --git a/scripts/check-new-urls.js b/scripts/check-new-urls.js new file mode 100755 index 00000000..f2ead8e2 --- /dev/null +++ b/scripts/check-new-urls.js @@ -0,0 +1,269 @@ +#!/usr/bin/env node +'use strict'; +// +// Check that URLs *newly added* in this PR return an HTTP status in the 200-299 range. +// +// "Newly added" = a URL value present in the head systems.csv that was not present +// anywhere in the base systems.csv. This deliberately ignores pre-existing URLs so a +// PR is never failed for a dead link it did not introduce. +// +// Both URL columns are checked: "URL" (operator homepage) and "Auto-Discovery URL" +// (the GBFS feed). Redirects are followed; the FINAL status must be 2xx. +// +// Usage: node scripts/check-new-urls.js +// baseCsv : systems.csv from the PR base (may be empty/absent if the file is new) +// headCsv : systems.csv from the PR head (default: systems.csv) +// +const fs = require('fs'); +const net = require('net'); +const dns = require('dns').promises; +const http = require('http'); +const https = require('https'); +const { URL } = require('url'); + +const baseFile = process.argv[2] || ''; +const headFile = process.argv[3] || 'systems.csv'; + +const URL_COLUMNS = ['URL', 'Auto-Discovery URL']; +const TIMEOUT_MS = 20000; +const MAX_REDIRECTS = 5; +const CONCURRENCY = 6; +// This job runs on untrusted fork PRs, so cap how many outbound checks a single +// PR can trigger. A legitimate contribution adds a handful of systems at a time. +const MAX_NEW_URLS = 200; +const USER_AGENT = + 'Mozilla/5.0 (compatible; gbfs.org-ci/1.0; +https://github.com/MobilityData/gbfs)'; + +// --- SSRF guard ------------------------------------------------------------ +// URLs come from untrusted PR authors and are fetched by the CI runner, so +// refuse to connect to loopback / private / link-local / CGNAT / ULA addresses +// (this blocks probing of the runner's own network and cloud metadata endpoints +// such as 169.254.169.254). Hostnames are resolved and every resolved address +// is checked, and each redirect hop is re-validated. +function isBlockedIPv4(ip) { + const p = ip.split('.').map(Number); + if (p.length !== 4 || p.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return true; + const [a, b] = p; + if (a === 0) return true; // 0.0.0.0/8 + if (a === 10) return true; // 10.0.0.0/8 + if (a === 127) return true; // loopback + if (a === 169 && b === 254) return true; // link-local (incl. metadata) + if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 + if (a === 192 && b === 168) return true; // 192.168.0.0/16 + if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT 100.64.0.0/10 + return false; +} +function isBlockedIPv6(ip) { + const s = ip.toLowerCase().split('%')[0]; // strip zone id + if (s === '::1' || s === '::') return true; // loopback / unspecified + const mapped = s.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/); // IPv4-mapped + if (mapped) return isBlockedIPv4(mapped[1]); + if (/^fe[89ab]/.test(s)) return true; // fe80::/10 link-local + if (/^f[cd]/.test(s)) return true; // fc00::/7 ULA + return false; +} +function isBlockedIp(ip) { + if (net.isIPv4(ip)) return isBlockedIPv4(ip); + if (net.isIPv6(ip)) return isBlockedIPv6(ip); + return true; // not a recognizable IP -> treat as unsafe +} +async function hostIsSafe(hostname) { + const h = hostname.toLowerCase().replace(/^\[|\]$/g, ''); // strip IPv6 brackets + if (h === 'localhost' || h.endsWith('.localhost')) return false; + if (net.isIP(h)) return !isBlockedIp(h); + let addrs; + try { + addrs = await dns.lookup(h, { all: true }); + } catch (e) { + return false; // unresolvable -> unsafe (also fails the 2xx check) + } + if (!addrs.length) return false; + return addrs.every((a) => !isBlockedIp(a.address)); +} + +// --- CSV parsing (same shape as validate-systems-csv.js) ------------------- +function parseCSV(text) { + const records = []; + let field = ''; + let fields = []; + let inQuotes = false; + let i = 0; + let hasContent = false; + + const pushField = () => { fields.push(field); field = ''; }; + const pushRecord = () => { records.push(fields); fields = []; hasContent = false; }; + + while (i < text.length) { + const c = text[i]; + if (inQuotes) { + if (c === '"') { + if (text[i + 1] === '"') { field += '"'; i += 2; continue; } + inQuotes = false; i++; continue; + } + field += c; i++; continue; + } + if (c === '"') { inQuotes = true; hasContent = true; i++; continue; } + if (c === ',') { pushField(); i++; continue; } + if (c === '\r') { i++; continue; } + if (c === '\n') { pushField(); pushRecord(); i++; continue; } + field += c; hasContent = true; i++; + } + if (hasContent || field.length > 0 || fields.length > 0) { pushField(); pushRecord(); } + return records; +} + +// Return the set of URL values (from URL_COLUMNS) found in a CSV string. +function urlsFrom(text) { + const urls = new Set(); + if (!text || !text.trim()) return urls; + const records = parseCSV(text); + if (records.length === 0) return urls; + const header = records[0]; + const idx = URL_COLUMNS.map((name) => header.indexOf(name)).filter((i) => i >= 0); + for (let r = 1; r < records.length; r++) { + const row = records[r]; + for (const c of idx) { + const v = (row[c] || '').trim(); + if (v) urls.add(v); + } + } + return urls; +} + +// --- HTTP check ------------------------------------------------------------ +// Resolve one URL, following redirects, resolving to { url, status, ok, error }. +// Async because the SSRF host check resolves DNS before any connection is made. +async function checkOnce(rawUrl, redirectsLeft) { + let target; + try { + target = new URL(rawUrl); + } catch (e) { + return { url: rawUrl, status: null, ok: false, error: `invalid URL: ${e.message}` }; + } + if (target.protocol !== 'http:' && target.protocol !== 'https:') { + return { url: rawUrl, status: null, ok: false, error: `unsupported protocol: ${target.protocol}` }; + } + if (!(await hostIsSafe(target.hostname))) { + return { + url: rawUrl, + status: null, + ok: false, + error: `blocked host (local/private address not allowed): ${target.hostname}`, + }; + } + + return new Promise((resolve) => { + const lib = target.protocol === 'https:' ? https : http; + const req = lib.request( + target, + { + method: 'GET', + headers: { 'User-Agent': USER_AGENT, Accept: '*/*' }, + timeout: TIMEOUT_MS, + }, + (res) => { + const status = res.statusCode; + // Follow redirects to a final status. + if (status >= 300 && status < 400 && res.headers.location && redirectsLeft > 0) { + res.resume(); // discard body + let next; + try { + next = new URL(res.headers.location, target).toString(); + } catch (e) { + resolve({ url: rawUrl, status, ok: false, error: `bad redirect target: ${res.headers.location}` }); + return; + } + resolve(checkOnce(next, redirectsLeft - 1)); + return; + } + res.resume(); // drain so the socket can be freed + resolve({ url: rawUrl, status, ok: status >= 200 && status <= 299, error: null }); + } + ); + + req.on('timeout', () => { req.destroy(new Error(`timeout after ${TIMEOUT_MS}ms`)); }); + req.on('error', (e) => { + resolve({ url: rawUrl, status: null, ok: false, error: e.message }); + }); + req.end(); + }); +} + +// Check a URL with one retry on transient failure (network error / timeout). +async function checkUrl(rawUrl) { + let result = await checkOnce(rawUrl, MAX_REDIRECTS); + if (!result.ok && result.status === null) { + result = await checkOnce(rawUrl, MAX_REDIRECTS); + } + return result; +} + +async function runPool(items, worker, size) { + const results = new Array(items.length); + let next = 0; + async function drain() { + while (next < items.length) { + const cur = next++; + results[cur] = await worker(items[cur]); + } + } + await Promise.all(Array.from({ length: Math.min(size, items.length) }, drain)); + return results; +} + +async function main() { + const headText = fs.readFileSync(headFile, 'utf8'); + let baseText = ''; + if (baseFile && fs.existsSync(baseFile)) { + baseText = fs.readFileSync(baseFile, 'utf8'); + } + + const baseUrls = urlsFrom(baseText); + const headUrls = urlsFrom(headText); + const newUrls = [...headUrls].filter((u) => !baseUrls.has(u)).sort(); + + if (newUrls.length === 0) { + console.log('No newly added URLs to check.'); + process.exit(0); + } + + if (newUrls.length > MAX_NEW_URLS) { + console.log( + `::error::This PR adds ${newUrls.length} new URLs, exceeding the limit of ${MAX_NEW_URLS}. ` + + `Please split it into smaller pull requests.` + ); + process.exit(1); + } + + console.log(`Checking ${newUrls.length} newly added URL(s) for HTTP 200-299...\n`); + const results = await runPool(newUrls, checkUrl, CONCURRENCY); + + const failed = []; + for (const r of results) { + if (r.ok) { + console.log(` ok ${r.status} ${r.url}`); + } else { + failed.push(r); + const detail = r.status !== null ? `HTTP ${r.status}` : r.error; + console.log(` FAIL ${detail} ${r.url}`); + } + } + + if (failed.length > 0) { + console.log(''); + for (const r of failed) { + const detail = r.status !== null ? `returned HTTP ${r.status}` : `error: ${r.error}`; + console.log(`::error::New URL ${detail}: ${r.url}`); + } + console.log(`\n${failed.length} of ${newUrls.length} new URL(s) did not return 200-299.`); + process.exit(1); + } + + console.log(`\nAll ${newUrls.length} new URL(s) returned 200-299.`); + process.exit(0); +} + +main().catch((e) => { + console.error(`::error::check-new-urls.js failed: ${e.stack || e.message}`); + process.exit(2); +}); diff --git a/scripts/sort-systems-csv.js b/scripts/sort-systems-csv.js new file mode 100755 index 00000000..7b7affaa --- /dev/null +++ b/scripts/sort-systems-csv.js @@ -0,0 +1,166 @@ +#!/usr/bin/env node +'use strict'; +// +// Sort systems.csv: keep the header row in place, then sort the remaining rows +// by the first four columns, in order: column 1 (Country Code), column 2 +// (Name), column 3 (Location), column 4 (System ID). +// +// This is CSV-quoting-aware: a field that is quoted because it contains a comma +// (e.g. "Veo University of Illinois, Urbana-Champaign") is sorted by its real +// value, not by the text up to the first raw comma. Rows are re-emitted exactly +// as they appeared in the file (original quoting preserved) - only their order +// changes. +// +// Ordering is case-insensitive: keys are compared with A-Z folded to a-z, so +// "nextbike" sorts right after "LiBike" instead of being pushed below every +// uppercase name. Rows whose keys differ only in case fall back to a byte-wise +// comparison of the whole row, keeping the result deterministic. +// +// Case folding is deliberately ASCII-only (A-Z only; accented and other +// non-ASCII characters are left untouched and compared by their UTF-8 bytes). +// Full Unicode folding is avoided because JavaScript's toLowerCase() and awk's +// tolower() disagree about non-ASCII, which would make this script and +// scripts/sort-systems-csv.sh produce different orderings. +// +// Apart from case folding, comparison is byte-wise (equivalent to LC_ALL=C). +// Because the comparison is implemented here rather than delegated to the +// platform `sort`, the result is identical on macOS and on the Linux CI runner, +// so local sorting always matches what the workflow produces. +// +// Usage: +// node scripts/sort-systems-csv.js [path-to-csv] # sort in place (default: systems.csv) +// node scripts/sort-systems-csv.js --check [path] # exit 1 if not already sorted +// +const fs = require('fs'); + +let check = false; +const args = process.argv.slice(2); +if (args[0] === '--check') { + check = true; + args.shift(); +} +const FILE = args[0] || 'systems.csv'; + +// Parse a single CSV line into fields, honoring double-quote quoting and "" +// escapes. systems.csv has no fields containing embedded newlines, so a +// physical line maps 1:1 to a record; we assert this below. +function parseLine(line) { + const fields = []; + let field = ''; + let inQuotes = false; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (inQuotes) { + if (c === '"') { + if (line[i + 1] === '"') { field += '"'; i++; } else { inQuotes = false; } + } else { + field += c; + } + } else if (c === '"') { + inQuotes = true; + } else if (c === ',') { + fields.push(field); + field = ''; + } else { + field += c; + } + } + fields.push(field); + return { fields, balanced: !inQuotes }; +} + +// Byte-wise (UTF-8) comparison, matching `sort` under LC_ALL=C. +function byteCompare(a, b) { + return Buffer.compare(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8')); +} + +// Fold A-Z to a-z and nothing else. Intentionally not toLowerCase(): full +// Unicode folding differs from awk's tolower(), which would desynchronize this +// script from scripts/sort-systems-csv.sh. +function asciiLower(s) { + let out = ''; + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); + out += c >= 65 && c <= 90 ? String.fromCharCode(c + 32) : s[i]; + } + return out; +} + +function sortedText(raw) { + const hadTrailingNewline = raw.endsWith('\n'); + const lines = raw.split('\n'); + if (hadTrailingNewline) lines.pop(); // drop the empty element after the final newline + + if (lines.length <= 1) return raw; // header only (or empty) - nothing to sort + + const header = lines[0]; + // Blank lines carry no row data; drop them rather than sorting them to the + // top of the file. scripts/sort-systems-csv.sh does the same, so the two + // implementations stay byte-identical. + const body = lines.slice(1).filter((l) => l.length > 0); + + // Sort keys: the first four columns, in order. + const KEY_COLUMNS = 4; + + const decorated = body.map((line, index) => { + const { fields, balanced } = parseLine(line); + if (!balanced) { + throw new Error( + `Unbalanced quotes on data line ${index + 2}; refusing to sort a malformed CSV. ` + + `Run the structure validation first.` + ); + } + const keys = []; + for (let k = 0; k < KEY_COLUMNS; k++) keys.push(asciiLower(fields[k] || '')); + return { line, keys }; + }); + + decorated.sort((a, b) => { + for (let k = 0; k < KEY_COLUMNS; k++) { + const c = byteCompare(a.keys[k], b.keys[k]); + if (c) return c; + } + return byteCompare(a.line, b.line); // deterministic tie-break on the full row + }); + + let out = [header, ...decorated.map((d) => d.line)].join('\n'); + if (hadTrailingNewline) out += '\n'; + return out; +} + +function main() { + let raw; + try { + raw = fs.readFileSync(FILE, 'utf8'); + } catch (e) { + console.error(`error: cannot read ${FILE}: ${e.message}`); + process.exit(2); + } + + let sorted; + try { + sorted = sortedText(raw); + } catch (e) { + // Malformed CSV: exit 3, matching scripts/sort-systems-csv.sh. + console.error(`error: ${e.message}`); + process.exit(3); + } + + if (check) { + if (sorted === raw) { + console.log(`${FILE} is already sorted.`); + process.exit(0); + } + console.error(`error: ${FILE} is not sorted. Run: node scripts/sort-systems-csv.js ${FILE}`); + process.exit(1); + } + + if (sorted === raw) { + console.log(`${FILE} is already sorted; no changes.`); + } else { + fs.writeFileSync(FILE, sorted); + console.log(`Sorted ${FILE}.`); + } +} + +main(); diff --git a/scripts/sort-systems-csv.sh b/scripts/sort-systems-csv.sh new file mode 100755 index 00000000..8da37b92 --- /dev/null +++ b/scripts/sort-systems-csv.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# +# Sort systems.csv: keep the header row in place, then sort the remaining rows +# by the first four columns, in order: column 1 (Country Code), column 2 (Name), +# column 3 (Location), column 4 (System ID). +# +# This is a shell equivalent of scripts/sort-systems-csv.js, provided for +# convenience. Both implementations produce byte-identical output; the CI +# workflow runs the Node one. Use whichever you prefer locally. +# +# Like the Node sorter, this is CSV-quoting-aware: a field that is quoted +# because it contains a comma (e.g. "Veo University of Illinois, +# Urbana-Champaign") is sorted by its real value, not by the text up to the +# first raw comma. Rows are re-emitted verbatim - only their order changes. +# +# Sorting is case-insensitive (A-Z folded to a-z), so "nextbike" sorts right +# after "LiBike" rather than below every uppercase name. Folding is ASCII-only; +# see scripts/sort-systems-csv.js for why full Unicode folding is avoided. +# +# How it works: awk parses each data row and prepends the four sort keys, +# separated by a control character (\x01) that cannot occur in the CSV. The +# result is sorted byte-wise (LC_ALL=C) on those key fields, with the original +# row as a final tie-break, then the key prefix is stripped off. +# +# Usage: +# ./scripts/sort-systems-csv.sh [path-to-csv] # sort in place (default: systems.csv) +# ./scripts/sort-systems-csv.sh --check [path] # exit 1 if not already sorted +# +set -euo pipefail + +CHECK=0 +if [[ "${1:-}" == "--check" ]]; then + CHECK=1 + shift +fi + +CSV="${1:-systems.csv}" + +if [[ ! -f "$CSV" ]]; then + echo "error: file not found: $CSV" >&2 + exit 2 +fi + +SEP=$'\001' + +sorted="$(mktemp)" +trap 'rm -f "$sorted"' EXIT + +{ + head -n 1 "$CSV" + + # Decorate: + # LC_ALL=C keeps awk byte-oriented, so the ASCII fold below cannot be affected + # by the ambient locale. + tail -n +2 "$CSV" | LC_ALL=C awk -v SEP="$SEP" ' + BEGIN { + # Explicit A-Z -> a-z map. Not tolower(): its treatment of non-ASCII + # varies by awk implementation and locale, which would desynchronize this + # script from scripts/sort-systems-csv.js. + u = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + l = "abcdefghijklmnopqrstuvwxyz" + for (i = 1; i <= 26; i++) fold[substr(u, i, 1)] = substr(l, i, 1) + } + + # Fold A-Z to a-z, passing every other byte through untouched. + function ascii_lower(s, i, c, out) { + out = "" + for (i = 1; i <= length(s); i++) { + c = substr(s, i, 1) + out = out ((c in fold) ? fold[c] : c) + } + return out + } + + # Parse a CSV record into the array out[1..n], honoring "" escapes. + function parse_csv(line, out, i, c, field, inq, n) { + n = 0; field = ""; inq = 0 + for (i = 1; i <= length(line); i++) { + c = substr(line, i, 1) + if (inq) { + if (c == "\"") { + if (substr(line, i + 1, 1) == "\"") { field = field "\""; i++ } + else { inq = 0 } + } else { field = field c } + } else if (c == "\"") { + inq = 1 + } else if (c == ",") { + out[++n] = field; field = "" + } else { + field = field c + } + } + out[++n] = field + return inq # nonzero => unbalanced quotes + } + { + if (length($0) == 0) next # skip blank lines + delete f + if (parse_csv($0, f) != 0) { + printf "error: unbalanced quotes on data line %d; refusing to sort a malformed CSV.\n", NR + 1 > "/dev/stderr" + exit 3 + } + printf "%s%s%s%s%s%s%s%s%s\n", ascii_lower(f[1]), SEP, ascii_lower(f[2]), SEP, + ascii_lower(f[3]), SEP, ascii_lower(f[4]), SEP, $0 + } + ' | LC_ALL=C sort -t "$SEP" -k1,1 -k2,2 -k3,3 -k4,4 -k5,5 | + # Strip the four key fields, leaving the original row. The row itself cannot + # contain SEP, so field 5 onward is exactly the original line. + cut -d "$SEP" -f5- +} > "$sorted" + +if [[ "$CHECK" -eq 1 ]]; then + if cmp -s "$CSV" "$sorted"; then + echo "$CSV is already sorted." + exit 0 + fi + echo "error: $CSV is not sorted. Run: ./scripts/sort-systems-csv.sh $CSV" >&2 + diff -u "$CSV" "$sorted" || true + exit 1 +fi + +if cmp -s "$CSV" "$sorted"; then + echo "$CSV is already sorted; no changes." +else + cat "$sorted" > "$CSV" + echo "Sorted $CSV." +fi diff --git a/scripts/validate-systems-csv.js b/scripts/validate-systems-csv.js new file mode 100755 index 00000000..3d416724 --- /dev/null +++ b/scripts/validate-systems-csv.js @@ -0,0 +1,198 @@ +#!/usr/bin/env node +'use strict'; +// +// Validate the structural integrity of systems.csv. +// +// Checks: +// 1. The header row exactly matches the expected GBFS systems schema (order + names). +// 2. Every data row parses to exactly 10 correctly-quoted fields (catches missing +// values, extra commas, and broken/unterminated quoting). +// 3. Required columns must be non-empty -> ERROR (fails the build). +// Country Code, Name, Location, System ID, URL, Auto-Discovery URL +// 4. "Supported Versions" empty -> WARNING (surfaced to author/reviewers, non-fatal). +// 5. The last 3 Authentication columns are optional and may be empty. +// +// Emits GitHub Actions annotations (::error / ::warning) so issues appear inline on the PR. +// +// Usage: node scripts/validate-systems-csv.js [path-to-csv] (default: systems.csv) +// +const fs = require('fs'); + +const FILE = process.argv[2] || 'systems.csv'; + +const EXPECTED_HEADER = [ + 'Country Code', + 'Name', + 'Location', + 'System ID', + 'URL', + 'Auto-Discovery URL', + 'Supported Versions', + 'Authentication Info URL', + 'Authentication Type', + 'Authentication Parameter Name', +]; + +// Columns that may legitimately be empty (no error, no warning). +const OPTIONAL = new Set([ + 'Authentication Info URL', + 'Authentication Type', + 'Authentication Parameter Name', +]); + +// Columns whose emptiness is only a warning. +const WARN_IF_EMPTY = new Set(['Supported Versions']); + +// Escape a GitHub Actions workflow-command message so newlines, carriage +// returns, and percent signs survive intact instead of truncating the +// annotation at the first newline. +function ghEscapeData(s) { + return String(s).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'); +} + +// Parse CSV into records, tracking the 1-based source line where each record starts. +// Returns { records: [{ fields, line }], error: {line, message} | null }. +function parseCSV(text) { + const records = []; + let field = ''; + let fields = []; + let inQuotes = false; + let line = 1; // current line number in the source + let recordStartLine = 1; + let started = false; // whether the current record has any content + let i = 0; + + const pushField = () => { fields.push(field); field = ''; }; + const pushRecord = () => { records.push({ fields, line: recordStartLine }); fields = []; started = false; }; + + while (i < text.length) { + const c = text[i]; + + if (!started && !inQuotes) { + recordStartLine = line; + started = true; + } + + if (inQuotes) { + if (c === '"') { + if (text[i + 1] === '"') { field += '"'; i += 2; continue; } + inQuotes = false; i++; continue; + } + if (c === '\n') { field += '\n'; line++; i++; continue; } + field += c; i++; continue; + } + + if (c === '"') { inQuotes = true; i++; continue; } + if (c === ',') { pushField(); i++; continue; } + if (c === '\r') { i++; continue; } + if (c === '\n') { + pushField(); + pushRecord(); + line++; i++; + continue; + } + field += c; i++; + } + + if (inQuotes) { + return { records, error: { line: recordStartLine, message: 'Unterminated quoted field (broken CSV quoting).' } }; + } + + // Flush trailing record if the file did not end with a newline and had content. + if (started || field.length > 0 || fields.length > 0) { + pushField(); + pushRecord(); + } + + return { records, error: null }; +} + +function isBlankRecord(fields) { + return fields.length === 1 && fields[0].trim() === ''; +} + +function main() { + let text; + try { + text = fs.readFileSync(FILE, 'utf8'); + } catch (e) { + console.error(`::error file=${FILE}::Cannot read ${FILE}: ${e.message}`); + process.exit(2); + } + + const { records, error } = parseCSV(text); + const errors = []; + const warnings = []; + + if (error) { + errors.push({ line: error.line, message: error.message }); + } + + if (records.length === 0) { + console.error(`::error file=${FILE}::${FILE} is empty.`); + process.exit(1); + } + + // 1. Header check. + const header = records[0].fields; + const headerOk = + header.length === EXPECTED_HEADER.length && + EXPECTED_HEADER.every((h, idx) => header[idx] === h); + if (!headerOk) { + errors.push({ + line: 1, + message: + `Header does not match the expected schema.\n` + + ` expected: ${EXPECTED_HEADER.join(',')}\n` + + ` found: ${header.join(',')}`, + }); + } + + const N = EXPECTED_HEADER.length; + + // 2 + 3 + 4. Per-row checks. + for (let r = 1; r < records.length; r++) { + const { fields, line } = records[r]; + + // Ignore fully blank lines (e.g. a trailing newline at end of file). + if (isBlankRecord(fields)) continue; + + if (fields.length !== N) { + errors.push({ + line, + message: `Expected ${N} fields but found ${fields.length}. Check for missing values, stray commas, or unquoted commas.`, + }); + // Field-count is wrong; skip per-column emptiness to avoid noise. + continue; + } + + for (let c = 0; c < N; c++) { + const name = EXPECTED_HEADER[c]; + const empty = fields[c].trim() === ''; + if (!empty) continue; + if (OPTIONAL.has(name)) continue; + if (WARN_IF_EMPTY.has(name)) { + warnings.push({ line, message: `Column "${name}" is empty.` }); + } else { + errors.push({ line, message: `Required column "${name}" is empty.` }); + } + } + } + + for (const w of warnings) { + console.log(`::warning file=${FILE},line=${w.line}::${ghEscapeData(w.message)}`); + } + for (const e of errors) { + console.log(`::error file=${FILE},line=${e.line}::${ghEscapeData(e.message)}`); + } + + const dataRows = records.length - 1; + console.log( + `\nsystems.csv structure check: ${dataRows} data rows, ` + + `${errors.length} error(s), ${warnings.length} warning(s).` + ); + + process.exit(errors.length > 0 ? 1 : 0); +} + +main(); diff --git a/systems.csv b/systems.csv index 0940328d..ffb61364 100644 --- a/systems.csv +++ b/systems.csv @@ -86,7 +86,6 @@ BR,Bike Itaú - Sampa,São Paulo,bike_sampa,https://bikeitau.com.br/bikesampa,ht BR,Bike Porto Alegre,Porto Alegre,bike_poa,https://bikeitau.com.br/porto-alegre/,https://portoalegre.publicbikesystem.net/customer/gbfs/v3.0/gbfs.json,1.1 ; 2.3 ; 3.0,,, BR,Rivi Bike,Largo Dos Coqueiros,rivi_bike,https://www.rivieradesaolourenco.com/lazer-e-servicos/rivibike/,https://riviera.publicbikesystem.net/customer/gbfs/v3.0/gbfs.json,1.1 ; 2.3 ; 3.0,,, CA,Accès Vélo,Saguenay,saguenay_bike,https://sts.saguenay.ca/infos-pratiques/acces-velo/acces-velo,https://saguenay.publicbikesystem.net/customer/gbfs/v3.0/gbfs.json,1.1 ; 2.3 ; 3.0,,, -CA,àVélo,Ville de Québec,avelo_quebec,https://aveloquebec.ca/,https://quebec.publicbikesystem.net/customer/gbfs/v3.0/gbfs.json,1.1 ; 2.3 ; 3.0,,, CA,Bike Share Toronto,Toronto,bike_share_toronto,https://www.bikesharetoronto.com/,https://toronto.publicbikesystem.net/customer/gbfs/v3.0/gbfs.json,1.1 ; 2.3 ; 3.0,,, CA,Bird Calgary,Calgary,bird-calgary,https://www.bird.co,https://mds.bird.co/gbfs/v2/public/calgary/gbfs.json,1.1 ; 2.3,,, CA,Bird Edmonton,Edmonton,bird-edmonton,https://www.bird.co,https://mds.bird.co/gbfs/v2/public/edmonton/gbfs.json,1.1 ; 2.3,,, @@ -108,6 +107,7 @@ CA,LocoMotion Sherbrooke,Sherbrooke,locomotion_sherbrooke,https://locomotion.app CA,Mobi Bike Share,Vancouver,Mobibikes_CA_Vancouver,https://www.mobibikes.ca/,https://gbfs.kappa.fifteen.eu/gbfs/2.2/mobi/en/gbfs.json,2.2,,, CA,PBSC HQ,Montreal,pbsn,https://lyfturbansolutions.com/cities,https://pbsn.publicbikesystem.net/customer/gbfs/v3.0/gbfs.json,1.1 ; 2.3 ; 3.0,,, CA,Sobi Hamilton,Hamilton,sobi_hamilton,https://hamilton.socialbicycles.com/,https://hamilton.socialbicycles.com/opendata/gbfs.json,1.0,,, +CA,àVélo,Ville de Québec,avelo_quebec,https://aveloquebec.ca/,https://quebec.publicbikesystem.net/customer/gbfs/v3.0/gbfs.json,1.1 ; 2.3 ; 3.0,,, CH,2EM Car Sharing,Switzerland,zem_ch,https://www.2em.ch,https://api.mobidata-bw.de/sharing/gbfs/v3/zem_ch/gbfs,2.3 ; 3.0,,, CH,Bird Basel,Basel,bird-basel,https://www.bird.co,https://mds.bird.co/gbfs/v2/public/basel/gbfs.json,1.1 ; 2.3,,, CH,Bird Biel,Biel,bird-biel,https://www.bird.co,https://mds.bird.co/gbfs/v2/public/biel/gbfs.json,1.1 ; 2.3,,, @@ -162,8 +162,6 @@ CY,nextbike Cyprus,Cyprus,nextbike_cy,https://www.nextbike.com.cy/el/,https://gb CZ,nextbike Benešov,Benešov,nextbike_co,https://www.nextbikeczech.com/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_co/gbfs.json,2.3,,, CZ,nextbike Berounsko,Berounsko,nextbike_td,https://www.nextbikeczech.com/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_td/gbfs.json,2.3,,, CZ,nextbike Brno,Czechia,nextbike_te,https://www.nextbikeczech.com/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_te/gbfs.json,2.3,,, -CZ,nextbike Česká Třebová,Česká Třebová,nextbike_nc,https://www.nextbikeczech.com/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_nc/gbfs.json,2.3,,, -CZ,nextbike Český Brod,Český Brod,nextbike_nd,https://www.nextbikeczech.com/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_nd/gbfs.json,2.3,,, CZ,nextbike Dvůr Králové,Dvůr Králové,nextbike_tf,https://www.nextbikeczech.com/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_tf/gbfs.json,2.3,,, CZ,nextbike Frýdek-Místek,Frýdek-Místek,nextbike_ts,https://www.nextbikeczech.com/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_ts/gbfs.json,2.3,,, CZ,nextbike Hodonín,"Hodonín, CZ",nextbike_nh,https://www.nextbikeczech.com/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_nh/gbfs.json,2.3,,, @@ -190,20 +188,22 @@ CZ,nextbike Opava,Opava,nextbike_tj,https://www.nextbikeczech.com/,https://gbfs. CZ,nextbike Ostrava,Czechia,nextbike_to,https://www.nextbikeczech.com/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_to/gbfs.json,2.3,,, CZ,nextbike Otrokovice,Otrokovice,nextbike_ot,https://www.nextbikeczech.com/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_ot/gbfs.json,2.3,,, CZ,nextbike Pelhřimov,Pelhřimov,nextbike_cq,https://www.nextbikeczech.com/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_cq/gbfs.json,2.3,,, -CZ,nextbike Písek,Písek,nextbike_ty,https://www.nextbikeczech.com/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_ty/gbfs.json,2.3,,, CZ,nextbike Praha,Czechia,nextbike_tg,https://www.nextbikeczech.com/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_tg/gbfs.json,2.3,,, +CZ,nextbike Písek,Písek,nextbike_ty,https://www.nextbikeczech.com/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_ty/gbfs.json,2.3,,, CZ,nextbike Přerov,Přerov,nextbike_nr,https://www.nextbikeczech.com/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_nr/gbfs.json,2.3,,, CZ,nextbike Roudnice nad Labem,Roudnice nad Labem,nextbike_rl,https://www.nextbikeczech.com/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_rl/gbfs.json,2.3,,, CZ,nextbike Rychnovsko,Rychnovsko,nextbike_tx,https://www.nextbikeczech.com/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_tx/gbfs.json,2.3,,, -CZ,nextbike Šumperk,Šumperk,nextbike_ns,https://www.nextbikeczech.com/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_ns/gbfs.json,2.3,,, -CZ,nextbike Třebíč,Třebíč,nextbike_tu,https://www.nextbikeczech.com/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_tu/gbfs.json,2.3,,, CZ,nextbike Trutnov,"Trutnov, CZ",nextbike_xb,https://www.nextbikeczech.com/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_xb/gbfs.json,2.3,,, +CZ,nextbike Třebíč,Třebíč,nextbike_tu,https://www.nextbikeczech.com/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_tu/gbfs.json,2.3,,, CZ,nextbike Uherské Hradiště,Uherské Hradiště,nextbike_tt,https://www.nextbikeczech.com/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_tt/gbfs.json,2.3,,, CZ,nextbike Uničov,Uničov,nextbike_nu,https://www.nextbikeczech.com/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_nu/gbfs.json,2.3,,, CZ,nextbike Valašsko,CZ,nextbike_vm,https://www.nextbikeczech.com/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_vm/gbfs.json,2.3,,, CZ,nextbike Vrchlabí,Vrchlabí,nextbike_vr,https://www.nextbikeczech.com/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_vr/gbfs.json,2.3,,, -CZ,nextbike Ždár nad Sázavou,Ždár nad Sázavou,nextbike_zs,https://www.nextbikeczech.com/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_zs/gbfs.json,2.3,,, CZ,nextbike Zlín,Zlín,nextbike_tv,https://www.nextbikeczech.com/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_tv/gbfs.json,2.3,,, +CZ,nextbike Česká Třebová,Česká Třebová,nextbike_nc,https://www.nextbikeczech.com/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_nc/gbfs.json,2.3,,, +CZ,nextbike Český Brod,Český Brod,nextbike_nd,https://www.nextbikeczech.com/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_nd/gbfs.json,2.3,,, +CZ,nextbike Šumperk,Šumperk,nextbike_ns,https://www.nextbikeczech.com/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_ns/gbfs.json,2.3,,, +CZ,nextbike Ždár nad Sázavou,Ždár nad Sázavou,nextbike_zs,https://www.nextbikeczech.com/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_zs/gbfs.json,2.3,,, DE,AB mit Lara,Aschaffenburg,5534922f6997f4d861ce1b33f2efce21a470c22,https://www.abmitlara.de,https://abmitlara.de/wp-json/commonsbooking/v1/gbfs.json,2.3,,, DE,ALF - das Affstätter Lastenfahrrad,Herrenberg,herrenberg_alf,https://herrenberg.adfc.de/adfc-herrenberg,https://api.mobidata-bw.de/sharing/gbfs/v3/herrenberg_alf/gbfs,3.0,,, DE,AW-bike,"Remagen, DE",nextbike_rh,https://www.nextbike.de/aw-bike/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_rh/gbfs.json,2.3,,, @@ -241,13 +241,13 @@ DE,Donkey Republic Schleswig,Schleswig,donkey_schleswig,https://www.donkey.bike/ DE,Dott Aachen,Aachen,dott-aachen,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/aachen/gbfs.json,2.3,,, DE,Dott Berlin,Berlin,dott-berlin,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/berlin/gbfs.json,2.3,,, DE,Dott Bielefeld,Bielefeld,dott-bielefeld,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/bielefeld/gbfs.json,2.3,,, -DE,Dott Böblingen,Böblingen,dott-boblingen,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/boblingen/gbfs.json,2.3,,, DE,Dott Bochum,Bochum,dott-bochum,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/bochum/gbfs.json,2.3,,, DE,Dott Bonn,Bonn,dott-bonn,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/bonn/gbfs.json,2.3,,, DE,Dott Bremen,Bremen,dott-bremen,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/bremen/gbfs.json,2.3,,, DE,Dott Bremerhaven,Bremerhaven,dott-bremerhaven,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/bremerhaven/gbfs.json,2.3,,, -DE,Dott Brühl,Brühl,dott-bruhl,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/bruhl/gbfs.json,2.3,,, DE,Dott Brunswick,Braunschweig,dott-brunswick,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/brunswick/gbfs.json,2.3,,, +DE,Dott Brühl,Brühl,dott-bruhl,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/bruhl/gbfs.json,2.3,,, +DE,Dott Böblingen,Böblingen,dott-boblingen,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/boblingen/gbfs.json,2.3,,, DE,Dott Celle,Celle,dott-celle,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/celle/gbfs.json,2.3,,, DE,Dott Chemnitz,Chemnitz,dott-chemnitz,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/chemnitz/gbfs.json,2.3,,, DE,Dott Cologne,Cologne,dott-cologne,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/cologne/gbfs.json,2.3,,, @@ -286,8 +286,8 @@ DE,Dott Kiel,Kiel,dott-kiel,https://ridedott.com/,https://gbfs.api.ridedott.com/ DE,Dott Langenfeld,Langenfeld,dott-langenfeld,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/langenfeld/gbfs.json,2.3,,, DE,Dott Leipzig,Leipzig,dott-leipzig,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/leipzig/gbfs.json,2.3,,, DE,Dott Lindau,Lindau,dott-lindau,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/lindau/gbfs.json,2.3,,, -DE,Dott Lübeck,Lübeck,dott-lubeck,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/lubeck/gbfs.json,2.3,,, DE,Dott Ludwigsburg,Ludwigsburg,dott-ludwigsburg,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/ludwigsburg/gbfs.json,2.3,,, +DE,Dott Lübeck,Lübeck,dott-lubeck,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/lubeck/gbfs.json,2.3,,, DE,Dott Mannheim,Mannheim,dott-mannheim,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/mannheim/gbfs.json,2.3,,, DE,Dott Minden,Minden,dott-minden,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/minden/gbfs.json,2.3,,, DE,Dott Monchengladbach,Monchengladbach,dott-monchengladbach,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/monchengladbach/gbfs.json,2.3,,, @@ -316,11 +316,11 @@ DE,Dott Solingen,Solingen,dott-solingen,https://ridedott.com/,https://gbfs.api.r DE,Dott Stuttgart,Stuttgart,dott-stuttgart,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/stuttgart/gbfs.json,2.3,,, DE,Dott Troisdorf,Troisdorf,dott-troisdorf,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/troisdorf/gbfs.json,2.3,,, DE,Dott Tübingen,Tübingen,dott-tubingen,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/tubingen/gbfs.json,2.3,,, -DE,Dott Überlingen,Überlingen,dott-uberlingen,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/uberlingen/gbfs.json,2.3,,, DE,Dott Ulm,Ulm,dott-ulm,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/ulm/gbfs.json,2.3,,, DE,Dott Wiesbaden,Wiesbaden,dott-wiesbaden,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/wiesbaden/gbfs.json,2.3,,, DE,Dott Wolfsburg,Wolfsburg,dott-wolfsburg,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/wolfsburg/gbfs.json,2.3,,, DE,Dott Zwickau,Zwickau,dott-zwickau,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/zwickau/gbfs.json,2.3,,, +DE,Dott Überlingen,Überlingen,dott-uberlingen,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/uberlingen/gbfs.json,2.3,,, DE,EDEKA Grünheide,Grünheide (Mark),nextbike_ed,https://www.nextbike.de/gruenheide/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_ed/gbfs.json,2.3,,, DE,Eifel e-Bike,Eifel,nextbike_eb,https://www.nextbike.de/eifel-ebike/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_eb/gbfs.json,2.3,,, DE,Einfach Unterwegs,Germany,einfach_unterwegs,https://www.einfach-unterwegs.eu/,https://api.mobidata-bw.de/sharing/gbfs/v3/einfach_unterwegs/gbfs,3.0,,, @@ -441,8 +441,8 @@ DE,Zeus Regensburg,Regensburg,zeus_regensburg,https://zeusscooters.com/,https:// DE,Zeus Renningen-Malmsheim,Renningen-Malmsheim,zeus_renningen-malmsheim,https://zeusscooters.com/,https://zeus.city/api/v1/mds/gbfs/renningen-malmsheim/gbfs.json,2.2,,, DE,Zeus Reutlingen,Reutlingen,zeus_reutlingen,https://zeusscooters.com,https://zeus.city/api/v1/mds/gbfs/reutlingen/gbfs.json,2.2,,, DE,Zeus Russelsheim,Russelsheim,zeus_russelsheim,https://zeusscooters.com/,https://zeus.city/api/v1/mds/gbfs/russelsheim/gbfs.json,2.2,,, -DE,Zeus Schwäbisch Gmünd,Schwäbisch Gmünd,zeus_schwabisch gmund,https://zeusscooters.com/,https://zeus.city/api/v1/mds/gbfs/schwabisch%20gmund/gbfs.json,2.2,,, DE,Zeus Schweinfurt,Schweinfurt,zeus_schweinfurt,https://zeusscooters.com/,https://zeus.city/api/v1/mds/gbfs/schweinfurt/gbfs.json,2.2,,, +DE,Zeus Schwäbisch Gmünd,Schwäbisch Gmünd,zeus_schwabisch gmund,https://zeusscooters.com/,https://zeus.city/api/v1/mds/gbfs/schwabisch%20gmund/gbfs.json,2.2,,, DE,Zeus Straubing,Straubing,zeus_straubing,https://zeusscooters.com,https://zeus.city/api/v1/mds/gbfs/straubing/gbfs.json,2.2,,, DE,Zeus Tubingen,Tubingen,zeus_tubingen,https://zeusscooters.com/,https://zeus.city/api/v1/mds/gbfs/tubingen/gbfs.json,2.2,,, DE,Zeus Tuttlingen,Tuttlingen,zeus_tuttlingen,https://zeusscooters.com,https://zeus.city/api/v1/mds/gbfs/tuttlingen/gbfs.json,2.2,,, @@ -535,16 +535,16 @@ ES,TUeBICI,"Santander, ES",nextbike_ek,https://www.tuebici.es/,https://gbfs.next ES,Valenbisi,Valencia,valence,https://www.valenbisi.es/,https://api.cyclocity.fr/contracts/valence/gbfs/v3/gbfs.json,2.3 ; 3.0,,, ES,Valladolid,Valladolid,BIKI_valladolid,https://biki-valladolid.es,https://valladolid.publicbikesystem.net/customer/gbfs/v3.0/gbfs.json,1.1 ; 2.3 ; 3.0,,, FI,Bird Helsinki,Helsinki,bird-helsinki,https://www.bird.co,https://mds.bird.co/gbfs/v2/public/helsinki/gbfs.json,1.1 ; 2.3,,, -FI,Donkey Republic Hämeenlinna,Hämeenlinna,donkey_haemeenlinna,https://www.donkey.bike/cities/bike-rental-hameenlinna/,https://stables.donkey.bike/api/public/gbfs/3.0/donkey_haemeenlinna/gbfs.json,1.0 ; 2.3 ; 3.0,,, FI,Donkey Republic Hamina,Hamina,donkey_hamina,https://www.donkey.bike/cities/bike-rental-hamina/,https://stables.donkey.bike/api/public/gbfs/3.0/donkey_hamina/gbfs.json,1.0 ; 2.3 ; 3.0,,, FI,Donkey Republic Hyvinkää,Hyvinkää,donkey_hyvinkaa,https://www.donkey.bike/cities/bike-rental-hyvinkaa/,https://stables.donkey.bike/api/public/gbfs/3.0/donkey_hyvinkaa/gbfs,1.0 ; 2.3 ; 3.0,,, +FI,Donkey Republic Hämeenlinna,Hämeenlinna,donkey_haemeenlinna,https://www.donkey.bike/cities/bike-rental-hameenlinna/,https://stables.donkey.bike/api/public/gbfs/3.0/donkey_haemeenlinna/gbfs.json,1.0 ; 2.3 ; 3.0,,, FI,Donkey Republic Iisalmi,Iisalmi,donkey_iisalmi,https://www.donkey.bike/cities/bike-rental-iisalmi/,https://stables.donkey.bike/api/public/gbfs/3.0/donkey_iisalmi/gbfs.json,1.0 ; 2.3 ; 3.0,,, FI,Donkey Republic Imatra,Imatra,donkey_imatra,https://www.donkey.bike/cities/bike-rental-imatra/,https://stables.donkey.bike/api/public/gbfs/3.0/donkey_imatra/gbfs.json,1.0 ; 2.3 ; 3.0,,, FI,Donkey Republic Kotka,Kotka,donkey_kotka,https://www.donkey.bike/cities/bike-rental-kotka/,https://stables.donkey.bike/api/public/gbfs/3.0/donkey_kotka/gbfs.json,1.0 ; 2.3 ; 3.0,,, FI,Donkey Republic Kouvola,Kouvola,donkey_kouvola,https://www.donkey.bike/cities/bike-rental-kouvola/,https://stables.donkey.bike/api/public/gbfs/3.0/donkey_kouvola/gbfs.json,1.0 ; 2.3 ; 3.0,,, FI,Donkey Republic Lappeenranta,Lappeenranta,donkey_lappeenranta,https://www.donkey.bike/cities/bike-rental-lappeenranta/,https://stables.donkey.bike/api/public/gbfs/3.0/donkey_lappeenranta/gbfs.json,1.0 ; 2.3 ; 3.0,,, -FI,Donkey Republic Mäntsälä,Mäntsälä,donkey_maentsaelae,https://www.donkey.bike/cities/bike-rental-mantsala/,https://stables.donkey.bike/api/public/gbfs/3.0/donkey_maentsaelae/gbfs.json,1.0 ; 2.3 ; 3.0,,, FI,Donkey Republic Mikkeli,Mikkeli,donkey_mikkeli,https://www.donkey.bike,https://stables.donkey.bike/api/public/gbfs/3.0/donkey_mikkeli/gbfs.json,1.0 ; 2.3 ; 3.0,,, +FI,Donkey Republic Mäntsälä,Mäntsälä,donkey_maentsaelae,https://www.donkey.bike/cities/bike-rental-mantsala/,https://stables.donkey.bike/api/public/gbfs/3.0/donkey_maentsaelae/gbfs.json,1.0 ; 2.3 ; 3.0,,, FI,Donkey Republic Porvoo,Porvoo,donkey_porvoo,https://www.donkey.bike/cities/bike-rental-porvoo/,https://stables.donkey.bike/api/public/gbfs/3.0/donkey_porvoo/gbfs.json,1.0 ; 2.3 ; 3.0,,, FI,Donkey Republic Raasepori,Raasepori,donkey_raasepori,https://www.donkey.bike/cities/bike-rental-raasepori/,https://stables.donkey.bike/api/public/gbfs/3.0/donkey_raasepori/gbfs,1.0 ; 2.3 ; 3.0,,, FI,Donkey Republic Riihimäki,Riihimäki,donkey_riihimaki,https://www.donkey.bike/cities/bike-rental-riihimaki/,https://stables.donkey.bike/api/public/gbfs/3.0/donkey_riihimaki/gbfs,1.0 ; 2.3 ; 3.0,,, @@ -622,8 +622,6 @@ FR,CaliVélo,Libourne,calivelo,https://calivelo.ecovelo.mobi,https://api.gbfs.v3 FR,CapCotentin,Cherbourg-en-Cotentin,capcotentin,https://capcotentin.ecovelo.mobi/,https://api.gbfs.v3.0.ecovelo.mobi/capcotentin/gbfs.json,2.2 ; 3.0,,, FR,CHATELLERAULT,Châtellerault,mp_CHATELLERAULT,https://www.mobility-parc.net/,https://www.mobility-parc.net/gbfs/v3/CHATELLERAULT/gbfs.json,3.0,,, FR,Choletbus 2 Roues,Cholet,choletbus,https://choletbus.ecovelo.mobi,https://api.gbfs.v3.0.ecovelo.mobi/choletbus/gbfs.json,2.2 ; 3.0,,, -FR,Cité Cycle,Saint-Flour,citecycle,https://citecyclesaintflour.ecovelo.mobi,https://api.gbfs.v3.0.ecovelo.mobi/citecycle/gbfs.json,2.2 ; 3.0,,, -FR,Citédia Services,Rennes,citedia_services,https://www.citedia.com/,https://backend.citiz.fr/public/provider/22/gbfs/3.0/gbfs.json,3.0,,, FR,Citiz Alpes Loire,Grenoble,citiz_alpes_loire,https://citiz.coop/,https://backend.citiz.fr/public/provider/5/gbfs/3.0/gbfs.json,3.0,,, FR,Citiz Angers,Angers,citiz_angers,https://citiz.coop/,https://backend.citiz.fr/public/provider/7/gbfs/3.0/gbfs.json,3.0,,, FR,Citiz AUPA,Bayonne,aupa,https://citiz.coop/,https://backend.citiz.fr/public/provider/20/gbfs/3.0/gbfs.json,3.0,,, @@ -639,6 +637,8 @@ FR,Citiz Nantes,Nantes,citiz_nantes,https://citiz.coop/,https://backend.citiz.fr FR,Citiz Occitanie,Toulouse,citiz_occitanie,https://citiz.coop/,https://backend.citiz.fr/public/provider/8/gbfs/3.0/gbfs.json,3.0,,, FR,Citiz Provence,Marseille,citiz_provence,https://citiz.coop/,https://backend.citiz.fr/public/provider/6/gbfs/3.0/gbfs.json,3.0,,, FR,Citiz Rennes Métropole,Rennes,citiz_rennes_metropole,https://citiz.coop/,https://backend.citiz.fr/public/provider/19/gbfs/3.0/gbfs.json,3.0,,, +FR,Cité Cycle,Saint-Flour,citecycle,https://citecyclesaintflour.ecovelo.mobi,https://api.gbfs.v3.0.ecovelo.mobi/citecycle/gbfs.json,2.2 ; 3.0,,, +FR,Citédia Services,Rennes,citedia_services,https://www.citedia.com/,https://backend.citiz.fr/public/provider/22/gbfs/3.0/gbfs.json,3.0,,, FR,Clem,France,clem-france,https://www.clem-e.com/,https://gbfs.clem.mobi/gbfs.json,3.0,,, FR,Cycl'AM,Ardenne Métropole,cyclam,https://cyclam.ecovelo.mobi,https://api.gbfs.v3.0.ecovelo.mobi/cyclam/gbfs.json,2.2 ; 3.0,,, FR,Cyclolibre,Carcassonne,cyclolibre,https://cyclolibre.ecovelo.mobi,https://api.gbfs.v3.0.ecovelo.mobi/cyclolibre/gbfs.json,2.2 ; 3.0,,, @@ -756,8 +756,8 @@ FR,Getaround Vannes,Vannes,getaround_vannes,https://getaround.com/car-rental/van FR,Getaround Versailles,Versailles,getaround_versailles,https://getaround.com/car-rental/versailles,https://getaround.com/gbfs/v3/versailles/gbfs,3.0,,, FR,Getaround Villeurbanne,Villeurbanne,getaround_villeurbanne,https://getaround.com/car-rental/villeurbanne,https://getaround.com/gbfs/v3/villeurbanne/gbfs,3.0,,, FR,Getaround Vitry-Sur-Seine,Vitry-Sur-Seine,getaround_vitry-sur-seine,https://getaround.com/car-rental/vitry-sur-seine,https://getaround.com/gbfs/v3/vitry-sur-seine/gbfs,3.0,,, -FR,Gévaudan,Gévaudan,mp_GEVAUDAN,https://www.mobility-parc.net/,https://www.mobility-parc.net/gbfs/v3/GEVAUDAN/gbfs.json,3.0,,, FR,GraouLib',Metz,graoulib_metz,https://www.eurometropolemetz.eu/a-la-une/une/graoulib-la-location-de-velos-electriques-en-libre-service,https://gbfs.partners.fifteen.eu/gbfs/2.2/metz/en/gbfs.json,2.2,,, +FR,Gévaudan,Gévaudan,mp_GEVAUDAN,https://www.mobility-parc.net/,https://www.mobility-parc.net/gbfs/v3/GEVAUDAN/gbfs.json,3.0,,, FR,IDEcycle (Pau),PAU,idecycle_pau,https://gbfs.partners.fifteen.eu,https://gbfs.partners.fifteen.eu/gbfs/2.2/pau/en/gbfs.json,2.2,,, FR,Impulsyon,La Roche-sur-Yon,impulsyon,https://impulsyon.ecovelo.mobi,https://api.gbfs.v3.0.ecovelo.mobi/impulsyon/gbfs.json,2.2 ; 3.0,,, FR,Karu'Vélo,Pointe-à-Pitre,karu,https://karuvelo.ecovelo.mobi/,https://api.gbfs.v3.0.ecovelo.mobi/karu/gbfs.json,2.2 ; 3.0,,, @@ -787,7 +787,6 @@ FR,Optymo Belfort Auto libre-service,Belfort,Optymo_Belfort_ALS,https://www.opty FR,PAYS D'OPALE,Pays d'Opale,mp_Opale,https://www.mobility-parc.net/,https://www.mobility-parc.net/gbfs/v3/Opale/gbfs.json,3.0,,, FR,PAYS DE L'ARBRESLE - VEL'PAR,Courzieu,mp_ARBRESLE,https://www.mobility-parc.net/,https://www.mobility-parc.net/gbfs/v3/ARBRESLE/gbfs.json,3.0,,, FR,PAYS DU COQUELICOT,Albert,mp_COQUELICOT,https://www.mobility-parc.net/,https://www.mobility-parc.net/gbfs/v3/COQUELICOT/gbfs.json,3.0,,, -FR,Périvélo,Périgueux,perivelo,https://perivelolibreservice.ecovelo.mobi,https://api.gbfs.v3.0.ecovelo.mobi/perivelo/gbfs.json,2.2 ; 3.0,,, FR,Pony Angers,Angers,pony_Angers,https://getapony.com/,https://proxy.transport.data.gouv.fr/resource/pony-angers-gbfs/gbfs.json,2.2,,, FR,Pony Basque Country,Basque Country,pony_Basque_Country,https://getapony.com/,https://proxy.transport.data.gouv.fr/resource/pony-pays-basque-gbfs/gbfs.json,2.2,,, FR,Pony Beauvais,Beauvais,pony_Beauvais,https://getapony.com/,https://proxy.transport.data.gouv.fr/resource/pony-beauvais-gbfs/gbfs.json,2.2,,, @@ -801,6 +800,7 @@ FR,Pony Nice,"Nice, FR",pony_Nice,https://getapony.com/,https://proxy.transport. FR,Pony Perpignan,Perpignan,pony_Perpignan,https://getapony.com/,https://proxy.transport.data.gouv.fr/resource/pony-perpignan-gbfs/gbfs.json,2.2,,, FR,Pony Poitiers,Poitiers,pony_poitiers,https://getapony.com/,https://proxy.transport.data.gouv.fr/resource/pony-poitiers-gbfs/gbfs.json,2.2,,, FR,Porte de Vassivière,Eymoutiers,mp_VASSIVIERE,https://www.mobility-parc.net/,https://www.mobility-parc.net/gbfs/v3/VASSIVIERE/gbfs.json,3.0,,, +FR,Périvélo,Périgueux,perivelo,https://perivelolibreservice.ecovelo.mobi,https://api.gbfs.v3.0.ecovelo.mobi/perivelo/gbfs.json,2.2 ; 3.0,,, FR,Rhoule,Piriac-sur-Mer,rhoule,https://rhoule.ecovelo.mobi,https://api.gbfs.v3.0.ecovelo.mobi/rhoule/gbfs.json,2.2 ; 3.0,,, FR,RLV'Lib,Riom,rlvlib,https://rlvlib.ecovelo.mobi,https://api.gbfs.v3.0.ecovelo.mobi/rlvlib/gbfs.json,2.2 ; 3.0,,, FR,Rubis'Velo,Bourg-en-Bresse,beb,https://rubis.ecovelo.mobi,https://api.gbfs.v3.0.ecovelo.mobi/beb/gbfs.json,2.2 ; 3.0,,, @@ -811,9 +811,22 @@ FR,TLP Mobilites,Tarbes,tlpmobilites,https://tlpmobilites.ecovelo.mobi/,https:// FR,Twisto Vélolib,Caen,twisto_velolib_caen,https://www.twisto.fr/se-deplacer/velo/velolib,https://gbfs.partners.fifteen.eu/gbfs/2.2/caen/en/gbfs.json,2.2,,, FR,V'lille,Lille,v_lille,https://www.ilevia.fr/v-lille,https://media.ilevia.fr/opendata/gbfs.json,2.3,,, FR,VALLEE D'OSSAU,Arundy,mp_OSSAU,https://www.mobility-parc.net/,https://www.mobility-parc.net/gbfs/v3/OSSAU/gbfs.json,3.0,,, -FR,Vel’in,Calais,calais-velos,https://www.vel-in.fr/,https://stce.transdev-hdf.fr/gbfs/,1.0,,, FR,Velam,Amiens,amiens,https://velam.cyclocity.fr/,https://api.cyclocity.fr/contracts/amiens/gbfs/v3/gbfs.json,2.3 ; 3.0,,, FR,Velect'in,Calais,calais,https://velectin.ecovelo.mobi,https://api.gbfs.v3.0.ecovelo.mobi/calais/gbfs.json,2.2 ; 3.0,,, +FR,VELO VEZERE LASCAUX,Lascaux,mp_LASCAUX,https://www.mobility-parc.net/,https://www.mobility-parc.net/gbfs/v3/LASCAUX/gbfs.json,3.0,,, +FR,Vel’in,Calais,calais-velos,https://www.vel-in.fr/,https://stce.transdev-hdf.fr/gbfs/,1.0,,, +FR,Vernou Vélo,Vernou-en-Sologne,vernouvelo,https://vernouvelo.ecovelo.mobi/,https://api.gbfs.v3.0.ecovelo.mobi/vernouvelo/gbfs.json,2.2 ; 3.0,,, +FR,Vertuose,Longwy,vertuose,https://vertuose.ecovelo.mobi,https://api.gbfs.v3.0.ecovelo.mobi/vertuose/gbfs.json,2.2 ; 3.0,,, +FR,Viavélo,Villefranche-sur-Saone,viavelo,https://viavelo.ecovelo.mobi/,https://api.gbfs.v3.0.ecovelo.mobi/viavelo/gbfs.json,2.2 ; 3.0,,, +FR,Vilvolt,Épinal,vilvolt_epinal,https://vilvolt.fr/,https://gbfs.partners.fifteen.eu/gbfs/epinal/gbfs.json,2.2,,, +FR,Vivélo,Vichy,vivelo,https://www.mobivie.fr/se-deplacer/vivelo/,https://gbfs.partners.fifteen.eu/gbfs/vichy/gbfs.json,2.2,,, +FR,Voi Grand Paris Seine et Oise,Grand Paris Seine et Oise,voi_Grand_Paris_Seine_et_Oise,https://www.voi.com/,https://api.voiapp.io/gbfs/fr/6bb6b5dc-1cda-4da7-9216-d3023a0bc54a/v2/422/gbfs.json,2.3,,, +FR,Voi Grenoble,Grenoble,voi_Grenoble,https://www.voi.com/,https://api.voiapp.io/gbfs/fr/6bb6b5dc-1cda-4da7-9216-d3023a0bc54a/v2/358/gbfs.json,2.3,,, +FR,Voi Le Havre,Le Havre,voi_Le_Havre,https://www.voi.com/,https://api.voiapp.io/gbfs/fr/6bb6b5dc-1cda-4da7-9216-d3023a0bc54a/v2/336/gbfs.json,2.3,,, +FR,Voi Marseille,Marseille,voi_Marseille,https://www.voi.com/,https://api.voiapp.io/gbfs/fr/6bb6b5dc-1cda-4da7-9216-d3023a0bc54a/v2/66/gbfs.json,2.3,,, +FR,Voi Paris,Paris,voi_Paris,https://www.voi.com/,https://api.voiapp.io/gbfs/fr/6bb6b5dc-1cda-4da7-9216-d3023a0bc54a/v2/352/gbfs.json,2.3,,, +FR,Voi Saint-Quentin-en-Yvelines,Saint-Quentin-en-Yvelines,voi_SQY,https://www.voi.com/,https://api.voiapp.io/gbfs/fr/6bb6b5dc-1cda-4da7-9216-d3023a0bc54a/v2/355/gbfs.json,2.3,,, +FR,Voi V'Lônes,Saint-Genis-Laval,voi_V'Lônes,https://www.voi.com/,https://api.voiapp.io/gbfs/fr/6bb6b5dc-1cda-4da7-9216-d3023a0bc54a/v2/406/gbfs.json,2.3,,, FR,Vélhop - Strasbourg,"Strasbourg, FR",nextbike_ae,https://vls.velhop.strasbourg.eu/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_ae/gbfs.json,2.3,,, FR,Vélib' Metropole,Paris,Paris,https://www.velib-metropole.fr/,https://velib-metropole-opendata.smovengo.cloud/opendata/Velib_Metropole/gbfs.json,1.0,,, FR,Vélibéo,Brive-la-Gaillarde,velibeo,https://velibeo.ecovelo.mobi,https://api.gbfs.v3.0.ecovelo.mobi/velibeo/gbfs.json,2.2 ; 3.0,,, @@ -825,7 +838,6 @@ FR,Vélo Modalis Grand Cognac,Cognac,velo-modalis,https://velomodalis.fr/,https: FR,Vélo Modalis Royan,Royan,modalis_royan,https://velomodalis.fr/,https://gbfs.partners.fifteen.eu/gbfs/2.2/modalis-royan/en/gbfs.json,2.2,,, FR,Vélo Modalis Saintes,Saintes,velo-modalis-saintes,https://velomodalis.fr/fr/,https://gbfs.partners.fifteen.eu/gbfs/2.2/saintes/en/gbfs.json,2.2,,, FR,Vélo Tanlib,Niort,tanlib,https://tanlib.ecovelo.mobi,https://api.gbfs.v3.0.ecovelo.mobi/tanlib/gbfs.json,2.2 ; 3.0,,, -FR,VELO VEZERE LASCAUX,Lascaux,mp_LASCAUX,https://www.mobility-parc.net/,https://www.mobility-parc.net/gbfs/v3/LASCAUX/gbfs.json,3.0,,, FR,Vélo'Baie,Saint-Brieuc,saintbrieuc,https://www.saintbrieuc-armor-agglo.bzh/velobaie,https://gbfs.partners.fifteen.eu/gbfs/2.2/saintbrieuc/en/gbfs.json,2.2,,, FR,Vélo'Cité,Laon,velocite,https://velocite.ecovelo.mobi,https://api.gbfs.v3.0.ecovelo.mobi/velocite/gbfs.json,2.2 ; 3.0,,, FR,Vélo'v,Lyon,lyon,https://velov.grandlyon.com/en/home,https://api.cyclocity.fr/contracts/lyon/gbfs/v3/gbfs.json,2.3 ; 3.0,,, @@ -838,21 +850,9 @@ FR,VéloMoove,Pompey,velomoove_pompey,https://velomoove.bassinpompey.fr,https:// FR,Vélonecy,Annecy,velonecy60minutes_annecy,https://mobilites.grandannecy.fr/,https://gbfs.partners.fifteen.eu/gbfs/2.2/annecy/en/gbfs.json,2.2,,, FR,Vélopop,Avignon,velopop,https://velo-grandavignon.fr/fr/,https://gbfs.partners.fifteen.eu/gbfs/avignon/gbfs.json,2.2,,, FR,VélOstan'lib,Nancy,nancy,https://www.velostanlib.fr/,https://api.cyclocity.fr/contracts/nancy/gbfs/v3/gbfs.json,2.3 ; 3.0,,, -FR,VélÔToulouse,Toulouse,toulouse,http://www.velo.toulouse.fr/,https://api.cyclocity.fr/contracts/toulouse/gbfs/v3/gbfs.json,2.3 ; 3.0,,, FR,VéloZef,Brest,velozef,https://www.bibus.fr/services/velozef-le-velo-assistance-electrique-en-libre-service,https://gbfs.partners.fifteen.eu/gbfs/2.2/brest/en/gbfs.json,2.2,,, FR,VélYcéo,Saint-Nazaire,velyceo,https://velyceo.ecovelo.mobi,https://api.gbfs.v3.0.ecovelo.mobi/velyceo/gbfs.json,2.2 ; 3.0,,, -FR,Vernou Vélo,Vernou-en-Sologne,vernouvelo,https://vernouvelo.ecovelo.mobi/,https://api.gbfs.v3.0.ecovelo.mobi/vernouvelo/gbfs.json,2.2 ; 3.0,,, -FR,Vertuose,Longwy,vertuose,https://vertuose.ecovelo.mobi,https://api.gbfs.v3.0.ecovelo.mobi/vertuose/gbfs.json,2.2 ; 3.0,,, -FR,Viavélo,Villefranche-sur-Saone,viavelo,https://viavelo.ecovelo.mobi/,https://api.gbfs.v3.0.ecovelo.mobi/viavelo/gbfs.json,2.2 ; 3.0,,, -FR,Vilvolt,Épinal,vilvolt_epinal,https://vilvolt.fr/,https://gbfs.partners.fifteen.eu/gbfs/epinal/gbfs.json,2.2,,, -FR,Vivélo,Vichy,vivelo,https://www.mobivie.fr/se-deplacer/vivelo/,https://gbfs.partners.fifteen.eu/gbfs/vichy/gbfs.json,2.2,,, -FR,Voi Grand Paris Seine et Oise,Grand Paris Seine et Oise,voi_Grand_Paris_Seine_et_Oise,https://www.voi.com/,https://api.voiapp.io/gbfs/fr/6bb6b5dc-1cda-4da7-9216-d3023a0bc54a/v2/422/gbfs.json,2.3,,, -FR,Voi Grenoble,Grenoble,voi_Grenoble,https://www.voi.com/,https://api.voiapp.io/gbfs/fr/6bb6b5dc-1cda-4da7-9216-d3023a0bc54a/v2/358/gbfs.json,2.3,,, -FR,Voi Le Havre,Le Havre,voi_Le_Havre,https://www.voi.com/,https://api.voiapp.io/gbfs/fr/6bb6b5dc-1cda-4da7-9216-d3023a0bc54a/v2/336/gbfs.json,2.3,,, -FR,Voi Marseille,Marseille,voi_Marseille,https://www.voi.com/,https://api.voiapp.io/gbfs/fr/6bb6b5dc-1cda-4da7-9216-d3023a0bc54a/v2/66/gbfs.json,2.3,,, -FR,Voi Paris,Paris,voi_Paris,https://www.voi.com/,https://api.voiapp.io/gbfs/fr/6bb6b5dc-1cda-4da7-9216-d3023a0bc54a/v2/352/gbfs.json,2.3,,, -FR,Voi Saint-Quentin-en-Yvelines,Saint-Quentin-en-Yvelines,voi_SQY,https://www.voi.com/,https://api.voiapp.io/gbfs/fr/6bb6b5dc-1cda-4da7-9216-d3023a0bc54a/v2/355/gbfs.json,2.3,,, -FR,Voi V'Lônes,Saint-Genis-Laval,voi_V'Lônes,https://www.voi.com/,https://api.voiapp.io/gbfs/fr/6bb6b5dc-1cda-4da7-9216-d3023a0bc54a/v2/406/gbfs.json,2.3,,, +FR,VélÔToulouse,Toulouse,toulouse,http://www.velo.toulouse.fr/,https://api.cyclocity.fr/contracts/toulouse/gbfs/v3/gbfs.json,2.3 ; 3.0,,, FR,YEGO Bordeaux,Bordeaux,yego bordeaux,https://www.rideyego.com/,https://services.rideyego.com/gbfs/2-3/bordeaux/fr/gbfs,1.1 ; 2.0 ; 2.1 ; 2.2 ; 2.3,,, FR,YEGO Nice,Nice,yego nice,https://www.rideyego.com/,https://services.rideyego.com/gbfs/2-3/nice/fr/gbfs,1.1 ; 2.0 ; 2.1 ; 2.2 ; 2.3,,, FR,YEGO Paris,Paris,yego paris,https://www.rideyego.com/,https://services.rideyego.com/gbfs/2-3/paris/fr/gbfs,1.1 ; 2.0 ; 2.1 ; 2.2 ; 2.3,,, @@ -927,7 +927,6 @@ HR,Grad Drniš (Croatia),Drniš,nextbike_gd,https://www.nextbike.hr/hr/drnis/,ht HR,Grad Ivanić-Grad (Croatia),Ivanic Grad,nextbike_ig,https://www.nextbike.hr/hr/ivanicgrad/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_ig/gbfs.json,2.3,,, HR,Grad Karlovac (Croatia),Karlovac,nextbike_kc,https://www.nextbike.hr/hr/karlovac/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_kc/gbfs.json,2.3,,, HR,Grad Križevci (Croatia),Križevci,nextbike_gk,https://www.nextbike.hr/hr/krizevci/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_gk/gbfs.json,2.3,,, -HR,Grad Šibenik (Croatia),Šibenik,nextbike_bc,https://www.nextbike.hr/hr/sibenik/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_bc/gbfs.json,2.3,,, HR,Grad Sisak (Croatia),Sisak,nextbike_cs,https://www.nextbike.hr/hr/sisak/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_cs/gbfs.json,2.3,,, HR,Grad Slavonski Brod (Croatia),Slavonski Brod,nextbike_sb,https://www.nextbike.hr/hr/slavonskibrod/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_sb/gbfs.json,2.3,,, HR,Grad Split (Croatia),Croatia,nextbike_gt,https://www.nextbike.hr/hr/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_gt/gbfs.json,2.3,,, @@ -936,6 +935,7 @@ HR,Grad Vinkovci (Croatia),Metković,nextbike_cm,https://www.nextbike.hr/hr/vink HR,Grad Vukovar (Croatia),Vukovar,nextbike_vu,https://www.nextbike.hr/hr/vukovar/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_vu/gbfs.json,2.3,,, HR,Grad Zadar (Croatia),Zadar,nextbike_zd,https://www.nextbike.hr/hr/zadar/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_zd/gbfs.json,2.3,,, HR,Grad Zaprešić (Croatia),Zaprešić,nextbike_sg,https://www.nextbike.hr/hr/zapresic/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_sg/gbfs.json,2.3,,, +HR,Grad Šibenik (Croatia),Šibenik,nextbike_bc,https://www.nextbike.hr/hr/sibenik/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_bc/gbfs.json,2.3,,, HR,Hvar (Croatia),Stari Grad,nextbike_ol,https://www.nextbike.hr/hr/starigrad/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_ol/gbfs.json,2.3,,, HR,Jastrebarsko (Croatia),Jastrebarsko,nextbike_cj,https://www.nextbike.hr/hr/jastrebarsko/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_cj/gbfs.json,2.3,,, HR,nextbike Croatia,Croatia,nextbike_hr,https://www.nextbike.hr/hr/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_hr/gbfs.json,2.3,,, @@ -1129,7 +1129,6 @@ NO,Hyre,Norway,hyrenorge,https://www.hyre.no/,https://api.entur.io/mobility/v2/g NO,Kolumbus Bysykkel,Stavanger,kolumbusbysykkel,https://www.kolumbus.no/reise/sykkel-oversikt/bysykkelen/,https://api.entur.io/mobility/v2/gbfs/v3/kolumbusbysykkel/gbfs,3.0,,, NO,Oslo Bysykkel,Oslo,oslobysykkel,https://oslobysykkel.no/,https://api.entur.io/mobility/v2/gbfs/v3/oslobysykkel/gbfs,3.0,,, NO,Otto,Norway,otto,https://www.otto.no/,https://api.entur.io/mobility/v2/gbfs/v3/otto/gbfs,3.0,,, -NO,Ryde Ålesund,Ålesund,rydeaalesund,https://www.ryde-technology.com/,https://api.entur.io/mobility/v2/gbfs/v3/rydeaalesund/gbfs,2.3 ; 3.0,,, NO,Ryde Fredrikstad,Fredrikstad,rydefredrikstad,https://www.ryde-technology.com/,https://api.entur.io/mobility/v2/gbfs/v3/rydefredrikstad/gbfs,2.3 ; 3.0,,, NO,Ryde Oslo,Oslo,rydeoslo,https://www.ryde-technology.com/,https://api.entur.io/mobility/v2/gbfs/v3/rydeoslo/gbfs,2.3 ; 3.0,,, NO,Ryde Porsgrunn,Porsgrunn,rydeporsgrunn,https://www.ryde-technology.com/,https://api.entur.io/mobility/v2/gbfs/v3/rydeporsgrunn/gbfs,2.3 ; 3.0,,, @@ -1137,9 +1136,10 @@ NO,Ryde Sandefjord,Sandefjord,rydesandefjord,https://www.ryde-technology.com/,ht NO,Ryde Sarpsborg,Sarpsborg,rydesarpsborg,https://www.ryde-technology.com/,https://api.entur.io/mobility/v2/gbfs/v3/rydesarpsborg/gbfs,2.3 ; 3.0,,, NO,Ryde Skien,Skien,rydeskien,https://www.ryde-technology.com/,https://api.entur.io/mobility/v2/gbfs/v3/rydeskien/gbfs,2.3 ; 3.0,,, NO,Ryde Stavanger,Stavanger,rydestavanger,https://www.ryde-technology.com/,https://api.entur.io/mobility/v2/gbfs/v3/rydestavanger/gbfs,3.0,,, -NO,Ryde Tønsberg,Tønsberg,rydetonsberg,https://www.ryde-technology.com/,https://api.entur.io/mobility/v2/gbfs/v3/rydetonsberg/gbfs,2.3 ; 3.0,,, NO,Ryde Tromsø,Tromsø,rydetromso,https://www.ryde-technology.com/,https://api.entur.io/mobility/v2/gbfs/v3/rydetromso/gbfs,2.3 ; 3.0,,, NO,Ryde Trondheim,Trondheim,rydetrondheim,https://www.ryde-technology.com/,https://api.entur.io/mobility/v2/gbfs/v3/rydetrondheim/gbfs,2.3 ; 3.0,,, +NO,Ryde Tønsberg,Tønsberg,rydetonsberg,https://www.ryde-technology.com/,https://api.entur.io/mobility/v2/gbfs/v3/rydetonsberg/gbfs,2.3 ; 3.0,,, +NO,Ryde Ålesund,Ålesund,rydeaalesund,https://www.ryde-technology.com/,https://api.entur.io/mobility/v2/gbfs/v3/rydeaalesund/gbfs,2.3 ; 3.0,,, NO,Trondheim Bysykkel,Trondheim,trondheimbysykkel,https://trondheimbysykkel.no/,https://api.entur.io/mobility/v2/gbfs/v3/trondheimbysykkel/gbfs,3.0,,, NO,Voi Arendal,Arendal,voiarendal,https://www.voi.com/,https://api.entur.io/mobility/v2/gbfs/v3/voiarendal/gbfs,3.0,,, NO,Voi Baerum,Baerum,voibaerum,https://www.voi.com/,https://api.entur.io/mobility/v2/gbfs/v3/voibaerum/gbfs,2.3 ; 3.0,,, @@ -1188,50 +1188,47 @@ PL,Dott Jastrzebie-Zdroj,Jastrzebie-Zdroj,dott-jastrzebie-zdroj,https://ridedott PL,Dott Jelenia Góra,Jelenia Góra,dott-jelenia-gora,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/jelenia-gora/gbfs.json,2.3,,, PL,Dott Kalisz,Kalisz,dott-kalisz,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/kalisz/gbfs.json,2.3,,, PL,Dott Katowice,Katowice,dott-katowice,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/katowice/gbfs.json,2.3,,, -PL,Dott Kołobrzeg,Kołobrzeg,dott-kołobrzeg,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/kołobrzeg/gbfs.json,2.3,,, PL,Dott Konin,Konin,dott-konin,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/konin/gbfs.json,2.3,,, PL,Dott Koscierzyna,Koscierzyna,dott-koscierzyna,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/koscierzyna/gbfs.json,2.3,,, PL,Dott Koszalin,Koszalin,dott-koszalin,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/koszalin/gbfs.json,2.3,,, +PL,Dott Kołobrzeg,Kołobrzeg,dott-kołobrzeg,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/kołobrzeg/gbfs.json,2.3,,, PL,Dott Krakow,Krakow,dott-krakow,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/krakow/gbfs.json,2.3,,, PL,Dott Krynica Morska,Krynica Morska,dott-krynica-morska,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/krynica-morska/gbfs.json,2.3,,, PL,Dott Kwidzyn,Kwidzyn,dott-kwidzyn,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/kwidzyn/gbfs.json,2.3,,, -PL,Dott Łeba,Łeba,dott-łeba,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/łeba/gbfs.json,2.3,,, PL,Dott Lebork,Lęborska,dott-lebork,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/lebork/gbfs.json,2.3,,, PL,Dott Legionowo,Legionowo,dott-legionowo,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/legionowo/gbfs.json,2.3,,, PL,Dott Leszno,Leszno,dott-leszno,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/leszno/gbfs.json,2.3,,, PL,Dott Lubliniec,Lubliniec,dott-lubliniec,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/lubliniec/gbfs.json,2.3,,, -PL,Dott Łuków,Łuków,dott-łukow,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/łukow/gbfs.json,2.3,,, PL,Dott Malbork,Malbork,dott-malbork,https://ridedott.com,https://gbfs.api.ridedott.com/public/v2/malbork/gbfs.json,2.3,,, -PL,Dott Międzyzdroje,Międzyzdroje,dott-miedzyzdroje,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/miedzyzdroje/gbfs.json,2.3,,, PL,Dott Mielno,Mielno,dott-mielno,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/mielno/gbfs.json,2.3,,, PL,Dott Mikołów,Mikołów,dott-mikołow,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/mikołow/gbfs.json,2.3,,, +PL,Dott Międzyzdroje,Międzyzdroje,dott-miedzyzdroje,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/miedzyzdroje/gbfs.json,2.3,,, PL,Dott Nowogard,Nowogard,dott-nowogard,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/nowogard/gbfs.json,2.3,,, PL,Dott Nowy Dwór Mazowiecki,Nowy Dwór Mazowiecki,dott-nowy-dwor-mazowiecki,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/nowy-dwor-mazowiecki/gbfs.json,2.3,,, PL,Dott Nowy Sącz,Nowy Sącz,dott-nowy-sacz,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/nowy-sacz/gbfs.json,2.3,,, PL,Dott Nowy-Targ,Nowy-Targ,dott-nowy-targ,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/nowy-targ/gbfs.json,2.3,,, PL,Dott Nysa,Nysa,dott-nysa,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/nysa/gbfs.json,2.3,,, -PL,Dott Oława,Oława,dott-oława,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/oława/gbfs.json,2.3,,, PL,Dott Oleśnica,Oleśnica,dott-olesnica,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/olesnica/gbfs.json,2.3,,, PL,Dott Ostroda,"Ostróda, PL",dott-ostroda,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/ostroda/gbfs.json,2.3,,, +PL,Dott Oława,Oława,dott-oława,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/oława/gbfs.json,2.3,,, PL,Dott Oświęcim,Oświęcim,dott-oswiecim,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/oswiecim/gbfs.json,2.3,,, PL,Dott Piekary Śląskie,Piekary Śląskie,dott-piekary-slaskie,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/piekary-slaskie/gbfs.json,2.3,,, -PL,Dott Płock,Płock,dott-płock,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/płock/gbfs.json,2.3,,, PL,Dott Police,Police,dott-police,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/police/gbfs.json,2.3,,, PL,Dott Poznan,Poznan,dott-poznan,https://ridedott.com,https://gbfs.api.ridedott.com/public/v2/poznan/gbfs.json,2.3,,, PL,Dott Prawobrzeże,Prawobrzeże,dott-prawobrzeze,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/prawobrzeze/gbfs.json,2.3,,, PL,Dott Pruszcz Gdański,Pruszcz Gdański,dott-pruszcz-gdanski,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/pruszcz-gdanski/gbfs.json,2.3,,, +PL,Dott Płock,Płock,dott-płock,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/płock/gbfs.json,2.3,,, PL,Dott Rewal,Rewal,dott-rewal,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/rewal/gbfs.json,2.3,,, PL,Dott Ruda Śląska,Ruda Śląska,dott-ruda-slaska,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/ruda-slaska/gbfs.json,2.3,,, PL,Dott Siedlce,Siedlce,dott-siedlce,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/siedlce/gbfs.json,2.3,,, PL,Dott Skawina,Skawina,dott-skawina,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/skawina/gbfs.json,2.3,,, -PL,Dott Słupsk,Słupsk,dott-słupsk,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/słupsk/gbfs.json,2.3,,, PL,Dott Sobieszewo Island,Sobieszewo Island,dott-sobieszewo-island,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/sobieszewo-island/gbfs.json,2.3,,, PL,Dott Sosnowiec,Sosnowiec,dott-sosnowiec,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/sosnowiec/gbfs.json,2.3,,, PL,Dott Starogard Gdański,Starogard Gdański,dott-starogard-gdanski,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/starogard-gdanski/gbfs.json,2.3,,, PL,Dott Swidnica,Swidnica,dott-swidnica,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/swidnica/gbfs.json,2.3,,, PL,Dott Swiecie,Swiecie,dott-swiecie,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/swiecie/gbfs.json,2.3,,, -PL,Dott Świnoujście,Świnoujście,dott-swinoujscie,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/swinoujscie/gbfs.json,2.3,,, PL,Dott Szczecin,Szczecin,dott-szczecin,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/szczecin/gbfs.json,2.3,,, +PL,Dott Słupsk,Słupsk,dott-słupsk,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/słupsk/gbfs.json,2.3,,, PL,Dott Tarnowskie Góry,Tarnowskie Góry,dott-tarnowskie-gory,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/tarnowskie-gory/gbfs.json,2.3,,, PL,Dott Tczew,Tczew,dott-tczew,https://ridedott.com,https://gbfs.api.ridedott.com/public/v2/tczew/gbfs.json,2.3,,, PL,Dott Toruń,Toruń,dott-torun,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/torun/gbfs.json,2.3,,, @@ -1240,13 +1237,15 @@ PL,Dott Ustka,Ustka,dott-ustka,https://ridedott.com/,https://gbfs.api.ridedott.c PL,Dott Warsaw,Warsaw,dott-warsaw,https://ridedott.com,https://gbfs.api.ridedott.com/public/v2/warsaw/gbfs.json,2.3,,, PL,Dott Wrocław,Wrocław,dott-wrocław,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/wrocław/gbfs.json,2.3,,, PL,Dott Zawiercie,Zawiercie,dott-zawiercie,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/zawiercie/gbfs.json,2.3,,, +PL,Dott Łeba,Łeba,dott-łeba,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/łeba/gbfs.json,2.3,,, +PL,Dott Łuków,Łuków,dott-łukow,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/łukow/gbfs.json,2.3,,, +PL,Dott Świnoujście,Świnoujście,dott-swinoujscie,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/swinoujscie/gbfs.json,2.3,,, PL,Dott Żory,Żory,dott-zory,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/zory/gbfs.json,2.3,,, PL,GRM Grodzisk Poland,Grodzisk,nextbike_gp,https://www.rowerygrodzisk.pl/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_gp/gbfs.json,2.3,,, PL,JasKółka,Jastrzębie-Zdrój,nextbike_pj,https://www.rower.jastrzebie.pl/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_pj/gbfs.json,2.3,,, -PL,Kołobrzeski Rower Nextbike,Kołobrzeg,nextbike_kr,https://kolobrzeskirower.pl/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_kr/gbfs.json,2.3,,, PL,Koniński Rower Miejski Poland,Konin,nextbike_pn,https://koninskirower.pl,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_pn/gbfs.json,2.3,,, PL,Koszaliński Rower Miejski Poland,Koszalin,nextbike_ps,https://koszalinskirowermiejski.pl/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_ps/gbfs.json,2.3,,, -PL,ŁoKeR - Łomża,Łomża,nextbike_oa,https://lomzarower.pl/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_oa/gbfs.json,2.3,,, +PL,Kołobrzeski Rower Nextbike,Kołobrzeg,nextbike_kr,https://kolobrzeskirower.pl/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_kr/gbfs.json,2.3,,, PL,METROROWER,"Tychy, PL",nextbike_zz,https://metrorower.transportgzm.pl/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_zz/gbfs.json,2.3,,, PL,MEVO,Gdansk,inurba-gdansk,https://rowermevo.pl/,https://gbfs.urbansharing.com/rowermevo.pl/gbfs.json,2.3,,, PL,Mławskie Rowery Miejskie,Mława,nextbike_oe,https://www.mlawskirower.pl/pl/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_oe/gbfs.json,2.3,,, @@ -1254,16 +1253,17 @@ PL,Otwocki Rower Miejski,Otwock,nextbike_os,https://otwockirower.pl/,https://gbf PL,Piaseczyński Rower Miejski,Piaseczno,nextbike_pi,https://www.piaseczynskirower.pl/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_pi/gbfs.json,2.3,,, PL,Pruszkowski Rower Miejski,Pruszków,nextbike_or,https://www.pruszkowskirower.pl/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_or/gbfs.json,2.3,,, PL,Rower Powiatowy Sokołów Podlaski,Sokołów Podlaski,nextbike_rq,https://powiatowyrower.pl/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_rq/gbfs.json,2.3,,, -PL,System Rowerów Miejskich w Pszczynie,Pszczyna,nextbike_ap,https://www.pszczynskirower.pl/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_ap/gbfs.json,2.3,,, PL,System Roweru Gminnego,Pielgrzymka,nextbike_pg,https://rowery.pielgrzymka.biz/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_pg/gbfs.json,2.3,,, +PL,System Rowerów Miejskich w Pszczynie,Pszczyna,nextbike_ap,https://www.pszczynskirower.pl/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_ap/gbfs.json,2.3,,, PL,Tarnowski Rower Miejski,Tarnów,nextbike_tn,https://rower.tarnow.pl/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_tn/gbfs.json,2.3,,, PL,Toruński Rower Miejski,Toruń,nextbike_tr,https://torunskirower.pl/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_tr/gbfs.json,2.3,,, PL,Tychowski Rower Miejski,Tychowo,nextbike_py,https://www.tychowskirower.pl/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_py/gbfs.json,2.3,,, PL,VETURILO 3.0,Poland,nextbike_vw,https://www.veturilo.waw.pl/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_vw/gbfs.json,2.3,,, -PL,Włower - Włocławski Rower Miejski,Włocławek,nextbike_wf,https://www.wlower.pl/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_wf/gbfs.json,2.3,,, PL,Wolsztyński Rower Miejski,Wolsztyn,nextbike_fp,https://www.rower.wolsztyn.pl/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_fp/gbfs.json,2.3,,, PL,WRM nextbike Poland,Wrocław,nextbike_pl,https://www.wroclawskirower.pl/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_pl/gbfs.json,2.3,,, +PL,Włower - Włocławski Rower Miejski,Włocławek,nextbike_wf,https://www.wlower.pl/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_wf/gbfs.json,2.3,,, PL,Zielonogórski Rower Miejski,Zielona Góra,nextbike_pm,https://zielonogorskirowermiejski.pl/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_pm/gbfs.json,2.3,,, +PL,ŁoKeR - Łomża,Łomża,nextbike_oa,https://lomzarower.pl/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_oa/gbfs.json,2.3,,, PL,Żyrardowski Rower Miejski,Żyrardów,nextbike_zy,https://zyrardowskirower.pl/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_zy/gbfs.json,2.3,,, PT,Bird Braga,Braga,bird-braga,https://www.bird.co,https://mds.bird.co/gbfs/v2/public/braga/gbfs.json,1.1 ; 2.3,,, PT,Bird Cascais,Cascais,bird-cascais,https://www.bird.co,https://mds.bird.co/gbfs/v2/public/cascais/gbfs.json,1.1 ; 2.3,,, @@ -1290,10 +1290,10 @@ SA,Dott Makkah,Makkah,dott-makkah,https://ridedott.com/,https://gbfs.api.ridedot SA,Dott Riyadh,Riyadh,dott-riyadh,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/riyadh/gbfs.json,2.3,,, SA,Dott SBF,Riyadh,dott-sbf,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/sbf/gbfs.json,2.3,,, SA,Dott STC City,Riyadh,dott-stc-city,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/stc-city/gbfs.json,2.3,,, -SE,Donkey Republic Ängelholm,Ängelholm,donkey_aengelholm,https://www.donkey.bike/cities/bike-rental-angelholm/,https://stables.donkey.bike/api/public/gbfs/3.0/donkey_aengelholm/gbfs.json,1.0 ; 2.3 ; 3.0,,, SE,Donkey Republic Båstad,Båstad,donkey_baastad,https://www.donkey.bike/cities/bike-rental-bastad/,https://stables.donkey.bike/api/public/gbfs/3.0/donkey_baastad/gbfs.json,1.0 ; 2.3 ; 3.0,,, SE,Donkey Republic Malmö,Malmö,donkey_malmoe,https://www.donkey.bike/cities/bike-rental-malmo/,https://stables.donkey.bike/api/public/gbfs/3.0/donkey_malmoe/gbfs.json,1.0 ; 2.3 ; 3.0,,, SE,Donkey Republic Ystad,Ystad,donkey_ystad,https://www.donkey.bike/cities/bike-rental-ystad/,https://stables.donkey.bike/api/public/gbfs/3.0/donkey_ystad/gbfs.json,1.0 ; 2.3 ; 3.0,,, +SE,Donkey Republic Ängelholm,Ängelholm,donkey_aengelholm,https://www.donkey.bike/cities/bike-rental-angelholm/,https://stables.donkey.bike/api/public/gbfs/3.0/donkey_aengelholm/gbfs.json,1.0 ; 2.3 ; 3.0,,, SE,Dott Eskilstuna,Eskilstuna,dott-eskilstuna,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/eskilstuna/gbfs.json,2.3,,, SE,Dott Gothenburg,Gothenburg,dott-gothenburg,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/gothenburg/gbfs.json,2.3,,, SE,Dott Helsingborg,Helsingborg,dott-helsingborg,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/helsingborg/gbfs.json,2.3,,, @@ -1304,8 +1304,8 @@ SE,Dott Partille,Partille,dott-partille,https://ridedott.com/,https://gbfs.api.r SE,Dott Skovde,Skovde,dott-skovde,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/skovde/gbfs.json,2.3,,, SE,Dott Trollhättan,Trollhättan,dott-trollhattan,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/trollhattan/gbfs.json,2.3,,, SE,Dott Varberg,Varberg,dott-varberg,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/varberg/gbfs.json,2.3,,, -SE,Dott Värnamo,Värnamo,dott-varnamo,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/varnamo/gbfs.json,2.3,,, SE,Dott Vaxjo,Vaxjo,dott-vaxjo,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/vaxjo/gbfs.json,2.3,,, +SE,Dott Värnamo,Värnamo,dott-varnamo,https://ridedott.com/,https://gbfs.api.ridedott.com/public/v2/varnamo/gbfs.json,2.3,,, SE,Gothenburg Cargo,Gothenburg,nextbike_zc,https://gothenburg-cargo.nextbike.com/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_zc/gbfs.json,2.3,,, SE,Lundahoj,Lund,lund,https://www.lundahoj.se/,https://api.cyclocity.fr/contracts/lund/gbfs/v3/gbfs.json,2.3 ; 3.0,,, SE,"Styr & Ställ (Sweden, Göteborg)",Göteborg,nextbike_zg,https://styrochstall.se/sv/,https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_zg/gbfs.json,2.3,,,