modified
fixtures/catalog/idfm-v1.json +6 -6 | @@ -22,12 +22,12 @@ | ||
| 22 | 22 | { "agency_name": "Fixture IDFM operator", "agency_url": "https://www.iledefrance-mobilites.fr/", "agency_timezone": "Europe/Paris" } |
| 23 | 23 | ], |
| 24 | 24 | "lines": [ |
| 25 | - { "id_line": "C100", "name_line": "Bus 42", "shortname_line": "42", "transportmode": "bus", "transportsubmode": "", "status": "active" }, | |
| 26 | - { "id_line": "C200", "name_line": "Métro 4", "shortname_line": "4", "transportmode": "metro", "transportsubmode": "", "status": "active" }, | |
| 27 | - { "id_line": "C300", "name_line": "Tram T3a", "shortname_line": "T3a", "transportmode": "tram", "transportsubmode": "", "status": "active" }, | |
| 28 | - { "id_line": "C400", "name_line": "RER A", "shortname_line": "A", "transportmode": "rail", "transportsubmode": "local", "status": "active" }, | |
| 29 | - { "id_line": "C500", "name_line": "Transilien L", "shortname_line": "L", "transportmode": "rail", "transportsubmode": "suburbanRailway", "status": "active" }, | |
| 30 | - { "id_line": "C600", "name_line": "Funiculaire", "shortname_line": "F", "transportmode": "funicular", "transportsubmode": "", "status": "active" } | |
| 25 | + { "id_line": "C100", "name_line": "Bus 42", "shortname_line": "42", "transportmode": "bus", "transportsubmode": "", "status": "active", "colourweb_hexa": "E86A10", "textcolourweb_hexa": "FFFFFF" }, | |
| 26 | + { "id_line": "C200", "name_line": "Métro 4", "shortname_line": "4", "transportmode": "metro", "transportsubmode": "", "status": "active", "colourweb_hexa": "#BE418D", "textcolourweb_hexa": "#FFFFFF" }, | |
| 27 | + { "id_line": "C300", "name_line": "Tram T3a", "shortname_line": "T3a", "transportmode": "tram", "transportsubmode": "", "status": "active", "colourweb_hexa": "85216F", "textcolourweb_hexa": "FFFFFF" }, | |
| 28 | + { "id_line": "C400", "name_line": "RER A", "shortname_line": "A", "transportmode": "rail", "transportsubmode": "local", "status": "active", "colourweb_hexa": "E3051C", "textcolourweb_hexa": "FFFFFF" }, | |
| 29 | + { "id_line": "C500", "name_line": "Transilien L", "shortname_line": "L", "transportmode": "rail", "transportsubmode": "suburbanRailway", "status": "active", "colourweb_hexa": "7584BC", "textcolourweb_hexa": "000000" }, | |
| 30 | + { "id_line": "C600", "name_line": "Funiculaire", "shortname_line": "F", "transportmode": "funicular", "transportsubmode": "", "status": "active", "colourweb_hexa": "000000", "textcolourweb_hexa": "FFFFFF" } | |
| 31 | 31 | ], |
| 32 | 32 | "routes": [ |
| 33 | 33 | { "route_id": "IDFM:C100", "route_short_name": "42", "route_long_name": "Bus 42" }, |
| @@ -22,12 +22,12 @@ | |||
| 22 | { "agency_name": "Fixture IDFM operator", "agency_url": "https://www.iledefrance-mobilites.fr/", "agency_timezone": "Europe/Paris" } | 22 | { "agency_name": "Fixture IDFM operator", "agency_url": "https://www.iledefrance-mobilites.fr/", "agency_timezone": "Europe/Paris" } |
| 23 | ], | 23 | ], |
| 24 | "lines": [ | 24 | "lines": [ |
| 25 | - { "id_line": "C100", "name_line": "Bus 42", "shortname_line": "42", "transportmode": "bus", "transportsubmode": "", "status": "active" }, | 25 | + { "id_line": "C100", "name_line": "Bus 42", "shortname_line": "42", "transportmode": "bus", "transportsubmode": "", "status": "active", "colourweb_hexa": "E86A10", "textcolourweb_hexa": "FFFFFF" }, |
| 26 | - { "id_line": "C200", "name_line": "Métro 4", "shortname_line": "4", "transportmode": "metro", "transportsubmode": "", "status": "active" }, | 26 | + { "id_line": "C200", "name_line": "Métro 4", "shortname_line": "4", "transportmode": "metro", "transportsubmode": "", "status": "active", "colourweb_hexa": "#BE418D", "textcolourweb_hexa": "#FFFFFF" }, |
| 27 | - { "id_line": "C300", "name_line": "Tram T3a", "shortname_line": "T3a", "transportmode": "tram", "transportsubmode": "", "status": "active" }, | 27 | + { "id_line": "C300", "name_line": "Tram T3a", "shortname_line": "T3a", "transportmode": "tram", "transportsubmode": "", "status": "active", "colourweb_hexa": "85216F", "textcolourweb_hexa": "FFFFFF" }, |
| 28 | - { "id_line": "C400", "name_line": "RER A", "shortname_line": "A", "transportmode": "rail", "transportsubmode": "local", "status": "active" }, | 28 | + { "id_line": "C400", "name_line": "RER A", "shortname_line": "A", "transportmode": "rail", "transportsubmode": "local", "status": "active", "colourweb_hexa": "E3051C", "textcolourweb_hexa": "FFFFFF" }, |
| 29 | - { "id_line": "C500", "name_line": "Transilien L", "shortname_line": "L", "transportmode": "rail", "transportsubmode": "suburbanRailway", "status": "active" }, | 29 | + { "id_line": "C500", "name_line": "Transilien L", "shortname_line": "L", "transportmode": "rail", "transportsubmode": "suburbanRailway", "status": "active", "colourweb_hexa": "7584BC", "textcolourweb_hexa": "000000" }, |
| 30 | - { "id_line": "C600", "name_line": "Funiculaire", "shortname_line": "F", "transportmode": "funicular", "transportsubmode": "", "status": "active" } | 30 | + { "id_line": "C600", "name_line": "Funiculaire", "shortname_line": "F", "transportmode": "funicular", "transportsubmode": "", "status": "active", "colourweb_hexa": "000000", "textcolourweb_hexa": "FFFFFF" } |
| 31 | ], | 31 | ], |
| 32 | "routes": [ | 32 | "routes": [ |
| 33 | { "route_id": "IDFM:C100", "route_short_name": "42", "route_long_name": "Bus 42" }, | 33 | { "route_id": "IDFM:C100", "route_short_name": "42", "route_long_name": "Bus 42" }, |
modified
fixtures/departures/foundation.json +3 -0 | @@ -7,6 +7,9 @@ | ||
| 7 | 7 | "stopLabel": "Châtelet", |
| 8 | 8 | "lineLabel": "Métro 1", |
| 9 | 9 | "destinationLabel": "La Défense", |
| 10 | + "lineMode": "METRO", | |
| 11 | + "lineColor": "#ffbe00", | |
| 12 | + "lineTextColor": "#000000", | |
| 10 | 13 | "sortOrder": 0 |
| 11 | 14 | }, |
| 12 | 15 | "result": { |
| @@ -7,6 +7,9 @@ | |||
| 7 | "stopLabel": "Châtelet", | 7 | "stopLabel": "Châtelet", |
| 8 | "lineLabel": "Métro 1", | 8 | "lineLabel": "Métro 1", |
| 9 | "destinationLabel": "La Défense", | 9 | "destinationLabel": "La Défense", |
| 10 | + "lineMode": "METRO", | ||
| 11 | + "lineColor": "#ffbe00", | ||
| 12 | + "lineTextColor": "#000000", | ||
| 10 | "sortOrder": 0 | 13 | "sortOrder": 0 |
| 11 | }, | 14 | }, |
| 12 | "result": { | 15 | "result": { |
modified
package.json +2 -0 | @@ -13,8 +13,10 @@ | ||
| 13 | 13 | "build": "node scripts/build.mjs", |
| 14 | 14 | "catalog:probe": "node scripts/probe-catalog.mjs", |
| 15 | 15 | "catalog:refresh": "node scripts/refresh-catalog.mjs", |
| 16 | + "badges:sync": "node scripts/sync-line-badges.mjs", | |
| 16 | 17 | "measure:radio": "node scripts/measure-radio.mjs", |
| 17 | 18 | "measure:catalog": "node scripts/measure-catalog.mjs", |
| 19 | + "measure:config-page": "node scripts/measure-config-page.mjs", | |
| 18 | 20 | "test": "node scripts/test.mjs", |
| 19 | 21 | "test:contracts": "node --test packages/contracts/test/contracts.test.ts", |
| 20 | 22 | "test:watch": "node --test packages/watch/test/*.test.js", |
| @@ -13,8 +13,10 @@ | |||
| 13 | "build": "node scripts/build.mjs", | 13 | "build": "node scripts/build.mjs", |
| 14 | "catalog:probe": "node scripts/probe-catalog.mjs", | 14 | "catalog:probe": "node scripts/probe-catalog.mjs", |
| 15 | "catalog:refresh": "node scripts/refresh-catalog.mjs", | 15 | "catalog:refresh": "node scripts/refresh-catalog.mjs", |
| 16 | + "badges:sync": "node scripts/sync-line-badges.mjs", | ||
| 16 | "measure:radio": "node scripts/measure-radio.mjs", | 17 | "measure:radio": "node scripts/measure-radio.mjs", |
| 17 | "measure:catalog": "node scripts/measure-catalog.mjs", | 18 | "measure:catalog": "node scripts/measure-catalog.mjs", |
| 19 | + "measure:config-page": "node scripts/measure-config-page.mjs", | ||
| 18 | "test": "node scripts/test.mjs", | 20 | "test": "node scripts/test.mjs", |
| 19 | "test:contracts": "node --test packages/contracts/test/contracts.test.ts", | 21 | "test:contracts": "node --test packages/contracts/test/contracts.test.ts", |
| 20 | "test:watch": "node --test packages/watch/test/*.test.js", | 22 | "test:watch": "node --test packages/watch/test/*.test.js", |
modified
packages/backend/src/catalog-import.ts +35 -12 | @@ -24,7 +24,7 @@ import { | ||
| 24 | 24 | } from "../../contracts/src/index.ts"; |
| 25 | 25 | import type { TransportMode } from "../../contracts/src/index.ts"; |
| 26 | 26 | |
| 27 | -export const CATALOG_VERSION = 1 as const; | |
| 27 | +export const CATALOG_VERSION = 2 as const; | |
| 28 | 28 | export const DOWNLOAD_TIMEOUT_MS = 15 * 60 * 1_000; |
| 29 | 29 | export const MAX_COMPRESSED_DOWNLOAD_BYTES = 2 * 1024 ** 3; |
| 30 | 30 | export const MAX_SELECTED_ENTRY_BYTES = 8 * 1024 ** 3; |
| @@ -224,6 +224,12 @@ function canonicalLineId(value: unknown, field: string): string { | ||
| 224 | 224 | return text.startsWith("IDFM:") ? text.slice("IDFM:".length) : text; |
| 225 | 225 | } |
| 226 | 226 | |
| 227 | +function normalizeLineColor(value: unknown, field: string): string { | |
| 228 | + const text = nonEmpty(value, field); | |
| 229 | + if (!/^#?[0-9a-f]{6}$/iu.test(text)) fail("INVALID_SOURCE", `${field} must be a six-digit hexadecimal color`); | |
| 230 | + return `#${text.replace(/^#/u, "").toLowerCase()}`; | |
| 231 | +} | |
| 232 | + | |
| 227 | 233 | function isoTimestamp(value: string, field: string): string { |
| 228 | 234 | if (value.length === 0 || !Number.isFinite(Date.parse(value))) { |
| 229 | 235 | fail("INVALID_METADATA", `${field} must be an ISO timestamp`); |
| @@ -500,6 +506,8 @@ function createSchema(database: DatabaseSync): void { | ||
| 500 | 506 | stop_label TEXT NOT NULL, |
| 501 | 507 | line_label TEXT NOT NULL, |
| 502 | 508 | destination_label TEXT NOT NULL, |
| 509 | + line_color TEXT NOT NULL, | |
| 510 | + line_text_color TEXT NOT NULL, | |
| 503 | 511 | monitoring_ref TEXT NOT NULL, |
| 504 | 512 | line_ref TEXT NOT NULL, |
| 505 | 513 | direction_id TEXT NOT NULL, |
| @@ -575,7 +583,9 @@ function createSchema(database: DatabaseSync): void { | ||
| 575 | 583 | shortname_line TEXT NOT NULL, |
| 576 | 584 | transport_mode TEXT NOT NULL, |
| 577 | 585 | transport_submode TEXT NOT NULL, |
| 578 | - status TEXT NOT NULL | |
| 586 | + status TEXT NOT NULL, | |
| 587 | + line_color TEXT NOT NULL, | |
| 588 | + line_text_color TEXT NOT NULL | |
| 579 | 589 | ) STRICT; |
| 580 | 590 | CREATE TABLE stage_arrets_lignes ( |
| 581 | 591 | route_id TEXT NOT NULL, |
| @@ -596,7 +606,7 @@ function prepareSpecs(database: DatabaseSync): Readonly<Record<keyof CatalogReco | ||
| 596 | 606 | const arret = database.prepare("INSERT INTO stage_arrets VALUES (?, ?)"); |
| 597 | 607 | const zone = database.prepare("INSERT INTO stage_zones VALUES (?, ?, ?)"); |
| 598 | 608 | const relation = database.prepare("INSERT OR IGNORE INTO stage_relations VALUES (?, ?, ?)"); |
| 599 | - const line = database.prepare("INSERT INTO stage_lines VALUES (?, ?, ?, ?, ?, ?)"); | |
| 609 | + const line = database.prepare("INSERT INTO stage_lines VALUES (?, ?, ?, ?, ?, ?, ?, ?)"); | |
| 600 | 610 | const arretLigne = database.prepare("INSERT OR IGNORE INTO stage_arrets_lignes VALUES (?, ?)"); |
| 601 | 611 | |
| 602 | 612 | return { |
| @@ -649,8 +659,8 @@ function prepareSpecs(database: DatabaseSync): Readonly<Record<keyof CatalogReco | ||
| 649 | 659 | insert: (row) => relation.run(nonEmpty(row.zdaid, "relations.zdaid"), nonEmpty(row.arrid, "relations.arrid"), nonEmpty(row.artid, "relations.artid")), |
| 650 | 660 | }, |
| 651 | 661 | lines: { |
| 652 | - required: ["id_line", "name_line", "shortname_line", "transportmode", "transportsubmode", "status"], | |
| 653 | - insert: (row) => line.run(canonicalLineId(row.id_line, "lines.id_line"), nonEmpty(row.name_line, "lines.name_line"), optionalText(row.shortname_line), nonEmpty(row.transportmode, "lines.transportmode"), optionalText(row.transportsubmode), nonEmpty(row.status, "lines.status")), | |
| 662 | + required: ["id_line", "name_line", "shortname_line", "transportmode", "transportsubmode", "status", "colourweb_hexa", "textcolourweb_hexa"], | |
| 663 | + insert: (row) => line.run(canonicalLineId(row.id_line, "lines.id_line"), nonEmpty(row.name_line, "lines.name_line"), optionalText(row.shortname_line), nonEmpty(row.transportmode, "lines.transportmode"), optionalText(row.transportsubmode), nonEmpty(row.status, "lines.status"), normalizeLineColor(row.colourweb_hexa, "lines.colourweb_hexa"), normalizeLineColor(row.textcolourweb_hexa, "lines.textcolourweb_hexa")), | |
| 654 | 664 | }, |
| 655 | 665 | arretsLignes: { |
| 656 | 666 | required: ["id", "stop_id"], |
| @@ -959,7 +969,9 @@ function deriveCandidates(database: DatabaseSync): void { | ||
| 959 | 969 | CREATE TABLE stage_line_modes ( |
| 960 | 970 | id_line TEXT PRIMARY KEY, |
| 961 | 971 | mode TEXT NOT NULL, |
| 962 | - line_label TEXT NOT NULL | |
| 972 | + line_label TEXT NOT NULL, | |
| 973 | + line_color TEXT NOT NULL, | |
| 974 | + line_text_color TEXT NOT NULL | |
| 963 | 975 | ) STRICT; |
| 964 | 976 | |
| 965 | 977 | INSERT INTO stage_line_modes |
| @@ -971,7 +983,9 @@ function deriveCandidates(database: DatabaseSync): void { | ||
| 971 | 983 | WHEN lower(trim(transport_mode)) = 'rail' AND lower(trim(transport_submode)) = 'local' THEN 'RER' |
| 972 | 984 | WHEN lower(trim(transport_mode)) = 'rail' AND lower(trim(transport_submode)) = 'suburbanrailway' THEN 'TRANSILIEN' |
| 973 | 985 | END, |
| 974 | - coalesce(nullif(trim(shortname_line), ''), trim(name_line)) | |
| 986 | + coalesce(nullif(trim(shortname_line), ''), trim(name_line)), | |
| 987 | + line_color, | |
| 988 | + line_text_color | |
| 975 | 989 | FROM stage_lines |
| 976 | 990 | WHERE lower(trim(status)) = 'active' |
| 977 | 991 | AND ( |
| @@ -1040,6 +1054,8 @@ function deriveCandidates(database: DatabaseSync): void { | ||
| 1040 | 1054 | zone.zdatown AS locality_label, |
| 1041 | 1055 | line_mode.id_line AS id_line, |
| 1042 | 1056 | line_mode.line_label AS line_label, |
| 1057 | + line_mode.line_color AS line_color, | |
| 1058 | + line_mode.line_text_color AS line_text_color, | |
| 1043 | 1059 | CASE |
| 1044 | 1060 | WHEN line_mode.mode IN ('RER', 'TRANSILIEN') THEN terminal.stop_name |
| 1045 | 1061 | ELSE coalesce(nullif(trim(trip.trip_headsign), ''), terminal.stop_name) |
| @@ -1089,7 +1105,8 @@ function deriveCandidates(database: DatabaseSync): void { | ||
| 1089 | 1105 | AND trim(destination_code.object_code) <> '' |
| 1090 | 1106 | ) |
| 1091 | 1107 | SELECT mode, zdaid, min(stop_label) AS stop_label, min(locality_label) AS locality_label, |
| 1092 | - id_line, min(line_label) AS line_label, min(destination_label) AS destination_label, | |
| 1108 | + id_line, min(line_label) AS line_label, min(line_color) AS line_color, | |
| 1109 | + min(line_text_color) AS line_text_color, min(destination_label) AS destination_label, | |
| 1093 | 1110 | monitoring_ref, line_ref, direction_id, destination_ref, |
| 1094 | 1111 | max(CASE WHEN resolution = 'arrets' THEN 1 ELSE 0 END) AS via_arrets |
| 1095 | 1112 | FROM joined |
| @@ -1107,7 +1124,7 @@ function insertFinalCatalog(database: DatabaseSync, sourceRevision: string, crea | ||
| 1107 | 1124 | |
| 1108 | 1125 | const insertPlace = database.prepare("INSERT INTO places VALUES (?, ?, ?, ?, ?)"); |
| 1109 | 1126 | const insertSearch = database.prepare("INSERT INTO place_search(search_text, place_id) VALUES (?, ?)"); |
| 1110 | - const insertService = database.prepare("INSERT INTO services VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); | |
| 1127 | + const insertService = database.prepare("INSERT INTO services VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); | |
| 1111 | 1128 | const selectPlace = database.prepare("SELECT canonical_tuple FROM places WHERE place_id = ?"); |
| 1112 | 1129 | const selectService = database.prepare("SELECT place_id, canonical_tuple FROM services WHERE service_id = ?"); |
| 1113 | 1130 | |
| @@ -1146,7 +1163,7 @@ function insertFinalCatalog(database: DatabaseSync, sourceRevision: string, crea | ||
| 1146 | 1163 | |
| 1147 | 1164 | const candidates = database.prepare(` |
| 1148 | 1165 | SELECT mode, zdaid, stop_label, line_label, destination_label, |
| 1149 | - id_line, monitoring_ref, line_ref, direction_id, destination_ref | |
| 1166 | + line_color, line_text_color, id_line, monitoring_ref, line_ref, direction_id, destination_ref | |
| 1150 | 1167 | FROM stage_candidates |
| 1151 | 1168 | ORDER BY mode, zdaid, id_line, monitoring_ref, direction_id, destination_ref |
| 1152 | 1169 | `).iterate() as Iterable<QueryRow>; |
| @@ -1174,6 +1191,8 @@ function insertFinalCatalog(database: DatabaseSync, sourceRevision: string, crea | ||
| 1174 | 1191 | nonEmpty(row.stop_label, "candidate.stop_label"), |
| 1175 | 1192 | nonEmpty(row.line_label, "candidate.line_label"), |
| 1176 | 1193 | nonEmpty(row.destination_label, "candidate.destination_label"), |
| 1194 | + normalizeLineColor(row.line_color, "candidate.line_color"), | |
| 1195 | + normalizeLineColor(row.line_text_color, "candidate.line_text_color"), | |
| 1177 | 1196 | nonEmpty(row.monitoring_ref, "candidate.monitoring_ref"), |
| 1178 | 1197 | nonEmpty(row.line_ref, "candidate.line_ref"), |
| 1179 | 1198 | nonEmpty(row.direction_id, "candidate.direction_id"), |
| @@ -1206,6 +1225,8 @@ function validateByteBounds(database: DatabaseSync): void { | ||
| 1206 | 1225 | OR length(CAST(stop_label AS BLOB)) > ? |
| 1207 | 1226 | OR length(CAST(line_label AS BLOB)) > ? |
| 1208 | 1227 | OR length(CAST(destination_label AS BLOB)) > ? |
| 1228 | + OR length(CAST(line_color AS BLOB)) <> 7 | |
| 1229 | + OR length(CAST(line_text_color AS BLOB)) <> 7 | |
| 1209 | 1230 | LIMIT 1 |
| 1210 | 1231 | `).get(LIMITS.idUtf8Bytes, LIMITS.labelUtf8Bytes, LIMITS.labelUtf8Bytes, LIMITS.labelUtf8Bytes); |
| 1211 | 1232 | if (invalidService !== undefined) fail("CATALOG_LIMIT", "service exceeds a shared UTF-8 byte limit"); |
| @@ -1228,7 +1249,7 @@ function validateOpenCatalog(database: DatabaseSync): CatalogBuildResult { | ||
| 1228 | 1249 | database.exec("PRAGMA foreign_keys = ON"); |
| 1229 | 1250 | exactColumns(database, "metadata", ["key", "value"]); |
| 1230 | 1251 | exactColumns(database, "places", ["place_id", "stop_label", "locality_label", "mode", "canonical_tuple"]); |
| 1231 | - exactColumns(database, "services", ["service_id", "place_id", "stop_label", "line_label", "destination_label", "monitoring_ref", "line_ref", "direction_id", "destination_ref", "canonical_tuple"]); | |
| 1252 | + exactColumns(database, "services", ["service_id", "place_id", "stop_label", "line_label", "destination_label", "line_color", "line_text_color", "monitoring_ref", "line_ref", "direction_id", "destination_ref", "canonical_tuple"]); | |
| 1232 | 1253 | exactColumns(database, "place_search", ["search_text", "place_id"]); |
| 1233 | 1254 | |
| 1234 | 1255 | const sourceRevision = metadataValue(database, "source_revision"); |
| @@ -1276,9 +1297,11 @@ function validateOpenCatalog(database: DatabaseSync): CatalogBuildResult { | ||
| 1276 | 1297 | const unresolved = database.prepare(` |
| 1277 | 1298 | SELECT 1 FROM services |
| 1278 | 1299 | WHERE trim(monitoring_ref) = '' OR trim(line_ref) = '' OR trim(direction_id) = '' OR trim(destination_ref) = '' |
| 1300 | + OR line_color NOT GLOB '#[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]' | |
| 1301 | + OR line_text_color NOT GLOB '#[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]' | |
| 1279 | 1302 | LIMIT 1 |
| 1280 | 1303 | `).get(); |
| 1281 | - if (unresolved !== undefined) fail("CATALOG_UNRESOLVED", "catalog contains an unresolvable offered service"); | |
| 1304 | + if (unresolved !== undefined) fail("CATALOG_UNRESOLVED", "catalog contains an unresolvable or malformed offered service"); | |
| 1282 | 1305 | |
| 1283 | 1306 | for (const row of database.prepare("SELECT place_id, canonical_tuple FROM places").iterate() as Iterable<QueryRow>) { |
| 1284 | 1307 | if (opaqueId("plc_", String(row.canonical_tuple)) !== row.place_id) fail("HASH_COLLISION", "place ID does not match its canonical tuple"); |
| @@ -24,7 +24,7 @@ import { | |||
| 24 | } from "../../contracts/src/index.ts"; | 24 | } from "../../contracts/src/index.ts"; |
| 25 | import type { TransportMode } from "../../contracts/src/index.ts"; | 25 | import type { TransportMode } from "../../contracts/src/index.ts"; |
| 26 | 26 | ||
| 27 | -export const CATALOG_VERSION = 1 as const; | 27 | +export const CATALOG_VERSION = 2 as const; |
| 28 | export const DOWNLOAD_TIMEOUT_MS = 15 * 60 * 1_000; | 28 | export const DOWNLOAD_TIMEOUT_MS = 15 * 60 * 1_000; |
| 29 | export const MAX_COMPRESSED_DOWNLOAD_BYTES = 2 * 1024 ** 3; | 29 | export const MAX_COMPRESSED_DOWNLOAD_BYTES = 2 * 1024 ** 3; |
| 30 | export const MAX_SELECTED_ENTRY_BYTES = 8 * 1024 ** 3; | 30 | export const MAX_SELECTED_ENTRY_BYTES = 8 * 1024 ** 3; |
| @@ -224,6 +224,12 @@ function canonicalLineId(value: unknown, field: string): string { | |||
| 224 | return text.startsWith("IDFM:") ? text.slice("IDFM:".length) : text; | 224 | return text.startsWith("IDFM:") ? text.slice("IDFM:".length) : text; |
| 225 | } | 225 | } |
| 226 | 226 | ||
| 227 | +function normalizeLineColor(value: unknown, field: string): string { | ||
| 228 | + const text = nonEmpty(value, field); | ||
| 229 | + if (!/^#?[0-9a-f]{6}$/iu.test(text)) fail("INVALID_SOURCE", `${field} must be a six-digit hexadecimal color`); | ||
| 230 | + return `#${text.replace(/^#/u, "").toLowerCase()}`; | ||
| 231 | +} | ||
| 232 | + | ||
| 227 | function isoTimestamp(value: string, field: string): string { | 233 | function isoTimestamp(value: string, field: string): string { |
| 228 | if (value.length === 0 || !Number.isFinite(Date.parse(value))) { | 234 | if (value.length === 0 || !Number.isFinite(Date.parse(value))) { |
| 229 | fail("INVALID_METADATA", `${field} must be an ISO timestamp`); | 235 | fail("INVALID_METADATA", `${field} must be an ISO timestamp`); |
| @@ -500,6 +506,8 @@ function createSchema(database: DatabaseSync): void { | |||
| 500 | stop_label TEXT NOT NULL, | 506 | stop_label TEXT NOT NULL, |
| 501 | line_label TEXT NOT NULL, | 507 | line_label TEXT NOT NULL, |
| 502 | destination_label TEXT NOT NULL, | 508 | destination_label TEXT NOT NULL, |
| 509 | + line_color TEXT NOT NULL, | ||
| 510 | + line_text_color TEXT NOT NULL, | ||
| 503 | monitoring_ref TEXT NOT NULL, | 511 | monitoring_ref TEXT NOT NULL, |
| 504 | line_ref TEXT NOT NULL, | 512 | line_ref TEXT NOT NULL, |
| 505 | direction_id TEXT NOT NULL, | 513 | direction_id TEXT NOT NULL, |
| @@ -575,7 +583,9 @@ function createSchema(database: DatabaseSync): void { | |||
| 575 | shortname_line TEXT NOT NULL, | 583 | shortname_line TEXT NOT NULL, |
| 576 | transport_mode TEXT NOT NULL, | 584 | transport_mode TEXT NOT NULL, |
| 577 | transport_submode TEXT NOT NULL, | 585 | transport_submode TEXT NOT NULL, |
| 578 | - status TEXT NOT NULL | 586 | + status TEXT NOT NULL, |
| 587 | + line_color TEXT NOT NULL, | ||
| 588 | + line_text_color TEXT NOT NULL | ||
| 579 | ) STRICT; | 589 | ) STRICT; |
| 580 | CREATE TABLE stage_arrets_lignes ( | 590 | CREATE TABLE stage_arrets_lignes ( |
| 581 | route_id TEXT NOT NULL, | 591 | route_id TEXT NOT NULL, |
| @@ -596,7 +606,7 @@ function prepareSpecs(database: DatabaseSync): Readonly<Record<keyof CatalogReco | |||
| 596 | const arret = database.prepare("INSERT INTO stage_arrets VALUES (?, ?)"); | 606 | const arret = database.prepare("INSERT INTO stage_arrets VALUES (?, ?)"); |
| 597 | const zone = database.prepare("INSERT INTO stage_zones VALUES (?, ?, ?)"); | 607 | const zone = database.prepare("INSERT INTO stage_zones VALUES (?, ?, ?)"); |
| 598 | const relation = database.prepare("INSERT OR IGNORE INTO stage_relations VALUES (?, ?, ?)"); | 608 | const relation = database.prepare("INSERT OR IGNORE INTO stage_relations VALUES (?, ?, ?)"); |
| 599 | - const line = database.prepare("INSERT INTO stage_lines VALUES (?, ?, ?, ?, ?, ?)"); | 609 | + const line = database.prepare("INSERT INTO stage_lines VALUES (?, ?, ?, ?, ?, ?, ?, ?)"); |
| 600 | const arretLigne = database.prepare("INSERT OR IGNORE INTO stage_arrets_lignes VALUES (?, ?)"); | 610 | const arretLigne = database.prepare("INSERT OR IGNORE INTO stage_arrets_lignes VALUES (?, ?)"); |
| 601 | 611 | ||
| 602 | return { | 612 | return { |
| @@ -649,8 +659,8 @@ function prepareSpecs(database: DatabaseSync): Readonly<Record<keyof CatalogReco | |||
| 649 | insert: (row) => relation.run(nonEmpty(row.zdaid, "relations.zdaid"), nonEmpty(row.arrid, "relations.arrid"), nonEmpty(row.artid, "relations.artid")), | 659 | insert: (row) => relation.run(nonEmpty(row.zdaid, "relations.zdaid"), nonEmpty(row.arrid, "relations.arrid"), nonEmpty(row.artid, "relations.artid")), |
| 650 | }, | 660 | }, |
| 651 | lines: { | 661 | lines: { |
| 652 | - required: ["id_line", "name_line", "shortname_line", "transportmode", "transportsubmode", "status"], | 662 | + required: ["id_line", "name_line", "shortname_line", "transportmode", "transportsubmode", "status", "colourweb_hexa", "textcolourweb_hexa"], |
| 653 | - insert: (row) => line.run(canonicalLineId(row.id_line, "lines.id_line"), nonEmpty(row.name_line, "lines.name_line"), optionalText(row.shortname_line), nonEmpty(row.transportmode, "lines.transportmode"), optionalText(row.transportsubmode), nonEmpty(row.status, "lines.status")), | 663 | + insert: (row) => line.run(canonicalLineId(row.id_line, "lines.id_line"), nonEmpty(row.name_line, "lines.name_line"), optionalText(row.shortname_line), nonEmpty(row.transportmode, "lines.transportmode"), optionalText(row.transportsubmode), nonEmpty(row.status, "lines.status"), normalizeLineColor(row.colourweb_hexa, "lines.colourweb_hexa"), normalizeLineColor(row.textcolourweb_hexa, "lines.textcolourweb_hexa")), |
| 654 | }, | 664 | }, |
| 655 | arretsLignes: { | 665 | arretsLignes: { |
| 656 | required: ["id", "stop_id"], | 666 | required: ["id", "stop_id"], |
| @@ -959,7 +969,9 @@ function deriveCandidates(database: DatabaseSync): void { | |||
| 959 | CREATE TABLE stage_line_modes ( | 969 | CREATE TABLE stage_line_modes ( |
| 960 | id_line TEXT PRIMARY KEY, | 970 | id_line TEXT PRIMARY KEY, |
| 961 | mode TEXT NOT NULL, | 971 | mode TEXT NOT NULL, |
| 962 | - line_label TEXT NOT NULL | 972 | + line_label TEXT NOT NULL, |
| 973 | + line_color TEXT NOT NULL, | ||
| 974 | + line_text_color TEXT NOT NULL | ||
| 963 | ) STRICT; | 975 | ) STRICT; |
| 964 | 976 | ||
| 965 | INSERT INTO stage_line_modes | 977 | INSERT INTO stage_line_modes |
| @@ -971,7 +983,9 @@ function deriveCandidates(database: DatabaseSync): void { | |||
| 971 | WHEN lower(trim(transport_mode)) = 'rail' AND lower(trim(transport_submode)) = 'local' THEN 'RER' | 983 | WHEN lower(trim(transport_mode)) = 'rail' AND lower(trim(transport_submode)) = 'local' THEN 'RER' |
| 972 | WHEN lower(trim(transport_mode)) = 'rail' AND lower(trim(transport_submode)) = 'suburbanrailway' THEN 'TRANSILIEN' | 984 | WHEN lower(trim(transport_mode)) = 'rail' AND lower(trim(transport_submode)) = 'suburbanrailway' THEN 'TRANSILIEN' |
| 973 | END, | 985 | END, |
| 974 | - coalesce(nullif(trim(shortname_line), ''), trim(name_line)) | 986 | + coalesce(nullif(trim(shortname_line), ''), trim(name_line)), |
| 987 | + line_color, | ||
| 988 | + line_text_color | ||
| 975 | FROM stage_lines | 989 | FROM stage_lines |
| 976 | WHERE lower(trim(status)) = 'active' | 990 | WHERE lower(trim(status)) = 'active' |
| 977 | AND ( | 991 | AND ( |
| @@ -1040,6 +1054,8 @@ function deriveCandidates(database: DatabaseSync): void { | |||
| 1040 | zone.zdatown AS locality_label, | 1054 | zone.zdatown AS locality_label, |
| 1041 | line_mode.id_line AS id_line, | 1055 | line_mode.id_line AS id_line, |
| 1042 | line_mode.line_label AS line_label, | 1056 | line_mode.line_label AS line_label, |
| 1057 | + line_mode.line_color AS line_color, | ||
| 1058 | + line_mode.line_text_color AS line_text_color, | ||
| 1043 | CASE | 1059 | CASE |
| 1044 | WHEN line_mode.mode IN ('RER', 'TRANSILIEN') THEN terminal.stop_name | 1060 | WHEN line_mode.mode IN ('RER', 'TRANSILIEN') THEN terminal.stop_name |
| 1045 | ELSE coalesce(nullif(trim(trip.trip_headsign), ''), terminal.stop_name) | 1061 | ELSE coalesce(nullif(trim(trip.trip_headsign), ''), terminal.stop_name) |
| @@ -1089,7 +1105,8 @@ function deriveCandidates(database: DatabaseSync): void { | |||
| 1089 | AND trim(destination_code.object_code) <> '' | 1105 | AND trim(destination_code.object_code) <> '' |
| 1090 | ) | 1106 | ) |
| 1091 | SELECT mode, zdaid, min(stop_label) AS stop_label, min(locality_label) AS locality_label, | 1107 | SELECT mode, zdaid, min(stop_label) AS stop_label, min(locality_label) AS locality_label, |
| 1092 | - id_line, min(line_label) AS line_label, min(destination_label) AS destination_label, | 1108 | + id_line, min(line_label) AS line_label, min(line_color) AS line_color, |
| 1109 | + min(line_text_color) AS line_text_color, min(destination_label) AS destination_label, | ||
| 1093 | monitoring_ref, line_ref, direction_id, destination_ref, | 1110 | monitoring_ref, line_ref, direction_id, destination_ref, |
| 1094 | max(CASE WHEN resolution = 'arrets' THEN 1 ELSE 0 END) AS via_arrets | 1111 | max(CASE WHEN resolution = 'arrets' THEN 1 ELSE 0 END) AS via_arrets |
| 1095 | FROM joined | 1112 | FROM joined |
| @@ -1107,7 +1124,7 @@ function insertFinalCatalog(database: DatabaseSync, sourceRevision: string, crea | |||
| 1107 | 1124 | ||
| 1108 | const insertPlace = database.prepare("INSERT INTO places VALUES (?, ?, ?, ?, ?)"); | 1125 | const insertPlace = database.prepare("INSERT INTO places VALUES (?, ?, ?, ?, ?)"); |
| 1109 | const insertSearch = database.prepare("INSERT INTO place_search(search_text, place_id) VALUES (?, ?)"); | 1126 | const insertSearch = database.prepare("INSERT INTO place_search(search_text, place_id) VALUES (?, ?)"); |
| 1110 | - const insertService = database.prepare("INSERT INTO services VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); | 1127 | + const insertService = database.prepare("INSERT INTO services VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); |
| 1111 | const selectPlace = database.prepare("SELECT canonical_tuple FROM places WHERE place_id = ?"); | 1128 | const selectPlace = database.prepare("SELECT canonical_tuple FROM places WHERE place_id = ?"); |
| 1112 | const selectService = database.prepare("SELECT place_id, canonical_tuple FROM services WHERE service_id = ?"); | 1129 | const selectService = database.prepare("SELECT place_id, canonical_tuple FROM services WHERE service_id = ?"); |
| 1113 | 1130 | ||
| @@ -1146,7 +1163,7 @@ function insertFinalCatalog(database: DatabaseSync, sourceRevision: string, crea | |||
| 1146 | 1163 | ||
| 1147 | const candidates = database.prepare(` | 1164 | const candidates = database.prepare(` |
| 1148 | SELECT mode, zdaid, stop_label, line_label, destination_label, | 1165 | SELECT mode, zdaid, stop_label, line_label, destination_label, |
| 1149 | - id_line, monitoring_ref, line_ref, direction_id, destination_ref | 1166 | + line_color, line_text_color, id_line, monitoring_ref, line_ref, direction_id, destination_ref |
| 1150 | FROM stage_candidates | 1167 | FROM stage_candidates |
| 1151 | ORDER BY mode, zdaid, id_line, monitoring_ref, direction_id, destination_ref | 1168 | ORDER BY mode, zdaid, id_line, monitoring_ref, direction_id, destination_ref |
| 1152 | `).iterate() as Iterable<QueryRow>; | 1169 | `).iterate() as Iterable<QueryRow>; |
| @@ -1174,6 +1191,8 @@ function insertFinalCatalog(database: DatabaseSync, sourceRevision: string, crea | |||
| 1174 | nonEmpty(row.stop_label, "candidate.stop_label"), | 1191 | nonEmpty(row.stop_label, "candidate.stop_label"), |
| 1175 | nonEmpty(row.line_label, "candidate.line_label"), | 1192 | nonEmpty(row.line_label, "candidate.line_label"), |
| 1176 | nonEmpty(row.destination_label, "candidate.destination_label"), | 1193 | nonEmpty(row.destination_label, "candidate.destination_label"), |
| 1194 | + normalizeLineColor(row.line_color, "candidate.line_color"), | ||
| 1195 | + normalizeLineColor(row.line_text_color, "candidate.line_text_color"), | ||
| 1177 | nonEmpty(row.monitoring_ref, "candidate.monitoring_ref"), | 1196 | nonEmpty(row.monitoring_ref, "candidate.monitoring_ref"), |
| 1178 | nonEmpty(row.line_ref, "candidate.line_ref"), | 1197 | nonEmpty(row.line_ref, "candidate.line_ref"), |
| 1179 | nonEmpty(row.direction_id, "candidate.direction_id"), | 1198 | nonEmpty(row.direction_id, "candidate.direction_id"), |
| @@ -1206,6 +1225,8 @@ function validateByteBounds(database: DatabaseSync): void { | |||
| 1206 | OR length(CAST(stop_label AS BLOB)) > ? | 1225 | OR length(CAST(stop_label AS BLOB)) > ? |
| 1207 | OR length(CAST(line_label AS BLOB)) > ? | 1226 | OR length(CAST(line_label AS BLOB)) > ? |
| 1208 | OR length(CAST(destination_label AS BLOB)) > ? | 1227 | OR length(CAST(destination_label AS BLOB)) > ? |
| 1228 | + OR length(CAST(line_color AS BLOB)) <> 7 | ||
| 1229 | + OR length(CAST(line_text_color AS BLOB)) <> 7 | ||
| 1209 | LIMIT 1 | 1230 | LIMIT 1 |
| 1210 | `).get(LIMITS.idUtf8Bytes, LIMITS.labelUtf8Bytes, LIMITS.labelUtf8Bytes, LIMITS.labelUtf8Bytes); | 1231 | `).get(LIMITS.idUtf8Bytes, LIMITS.labelUtf8Bytes, LIMITS.labelUtf8Bytes, LIMITS.labelUtf8Bytes); |
| 1211 | if (invalidService !== undefined) fail("CATALOG_LIMIT", "service exceeds a shared UTF-8 byte limit"); | 1232 | if (invalidService !== undefined) fail("CATALOG_LIMIT", "service exceeds a shared UTF-8 byte limit"); |
| @@ -1228,7 +1249,7 @@ function validateOpenCatalog(database: DatabaseSync): CatalogBuildResult { | |||
| 1228 | database.exec("PRAGMA foreign_keys = ON"); | 1249 | database.exec("PRAGMA foreign_keys = ON"); |
| 1229 | exactColumns(database, "metadata", ["key", "value"]); | 1250 | exactColumns(database, "metadata", ["key", "value"]); |
| 1230 | exactColumns(database, "places", ["place_id", "stop_label", "locality_label", "mode", "canonical_tuple"]); | 1251 | exactColumns(database, "places", ["place_id", "stop_label", "locality_label", "mode", "canonical_tuple"]); |
| 1231 | - exactColumns(database, "services", ["service_id", "place_id", "stop_label", "line_label", "destination_label", "monitoring_ref", "line_ref", "direction_id", "destination_ref", "canonical_tuple"]); | 1252 | + exactColumns(database, "services", ["service_id", "place_id", "stop_label", "line_label", "destination_label", "line_color", "line_text_color", "monitoring_ref", "line_ref", "direction_id", "destination_ref", "canonical_tuple"]); |
| 1232 | exactColumns(database, "place_search", ["search_text", "place_id"]); | 1253 | exactColumns(database, "place_search", ["search_text", "place_id"]); |
| 1233 | 1254 | ||
| 1234 | const sourceRevision = metadataValue(database, "source_revision"); | 1255 | const sourceRevision = metadataValue(database, "source_revision"); |
| @@ -1276,9 +1297,11 @@ function validateOpenCatalog(database: DatabaseSync): CatalogBuildResult { | |||
| 1276 | const unresolved = database.prepare(` | 1297 | const unresolved = database.prepare(` |
| 1277 | SELECT 1 FROM services | 1298 | SELECT 1 FROM services |
| 1278 | WHERE trim(monitoring_ref) = '' OR trim(line_ref) = '' OR trim(direction_id) = '' OR trim(destination_ref) = '' | 1299 | WHERE trim(monitoring_ref) = '' OR trim(line_ref) = '' OR trim(direction_id) = '' OR trim(destination_ref) = '' |
| 1300 | + OR line_color NOT GLOB '#[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]' | ||
| 1301 | + OR line_text_color NOT GLOB '#[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]' | ||
| 1279 | LIMIT 1 | 1302 | LIMIT 1 |
| 1280 | `).get(); | 1303 | `).get(); |
| 1281 | - if (unresolved !== undefined) fail("CATALOG_UNRESOLVED", "catalog contains an unresolvable offered service"); | 1304 | + if (unresolved !== undefined) fail("CATALOG_UNRESOLVED", "catalog contains an unresolvable or malformed offered service"); |
| 1282 | 1305 | ||
| 1283 | for (const row of database.prepare("SELECT place_id, canonical_tuple FROM places").iterate() as Iterable<QueryRow>) { | 1306 | for (const row of database.prepare("SELECT place_id, canonical_tuple FROM places").iterate() as Iterable<QueryRow>) { |
| 1284 | if (opaqueId("plc_", String(row.canonical_tuple)) !== row.place_id) fail("HASH_COLLISION", "place ID does not match its canonical tuple"); | 1307 | if (opaqueId("plc_", String(row.canonical_tuple)) !== row.place_id) fail("HASH_COLLISION", "place ID does not match its canonical tuple"); |
modified
packages/backend/src/catalog.ts +22 -6 | @@ -48,6 +48,9 @@ interface ServiceRow { | ||
| 48 | 48 | readonly stopLabel: string; |
| 49 | 49 | readonly lineLabel: string; |
| 50 | 50 | readonly destinationLabel: string; |
| 51 | + readonly lineMode: TransportMode; | |
| 52 | + readonly lineColor: string; | |
| 53 | + readonly lineTextColor: string; | |
| 51 | 54 | } |
| 52 | 55 | |
| 53 | 56 | interface ResolutionRow { |
| @@ -87,7 +90,7 @@ interface ExpectedColumn { | ||
| 87 | 90 | const CLOSE_READER = Symbol("closeCatalogReader"); |
| 88 | 91 | const OPAQUE_ID_BYTES = 47; |
| 89 | 92 | const CATALOG_SCHEMA_VERSION = "1"; |
| 90 | -const CATALOG_FORMAT_VERSION = "1"; | |
| 93 | +const CATALOG_FORMAT_VERSION = "2"; | |
| 91 | 94 | const REQUIRED_METADATA = [ |
| 92 | 95 | "schema_version", |
| 93 | 96 | "catalog_version", |
| @@ -123,6 +126,8 @@ const TABLE_COLUMNS: Readonly<Record<string, readonly ExpectedColumn[]>> = { | ||
| 123 | 126 | { name: "stop_label", type: "TEXT", notNull: true }, |
| 124 | 127 | { name: "line_label", type: "TEXT", notNull: true }, |
| 125 | 128 | { name: "destination_label", type: "TEXT", notNull: true }, |
| 129 | + { name: "line_color", type: "TEXT", notNull: true }, | |
| 130 | + { name: "line_text_color", type: "TEXT", notNull: true }, | |
| 126 | 131 | { name: "monitoring_ref", type: "TEXT", notNull: true }, |
| 127 | 132 | { name: "line_ref", type: "TEXT", notNull: true }, |
| 128 | 133 | { name: "direction_id", type: "TEXT", notNull: true }, |
| @@ -322,6 +327,10 @@ function validateData(database: DatabaseSync): void { | ||
| 322 | 327 | OR length(CAST(stop_label AS BLOB)) NOT BETWEEN 1 AND ${LIMITS.labelUtf8Bytes} |
| 323 | 328 | OR length(CAST(line_label AS BLOB)) NOT BETWEEN 1 AND ${LIMITS.labelUtf8Bytes} |
| 324 | 329 | OR length(CAST(destination_label AS BLOB)) NOT BETWEEN 1 AND ${LIMITS.labelUtf8Bytes} |
| 330 | + OR length(CAST(line_color AS BLOB)) <> 7 | |
| 331 | + OR line_color NOT GLOB '#[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]' | |
| 332 | + OR length(CAST(line_text_color AS BLOB)) <> 7 | |
| 333 | + OR line_text_color NOT GLOB '#[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]' | |
| 325 | 334 | OR length(CAST(monitoring_ref AS BLOB)) < 1 |
| 326 | 335 | OR length(CAST(line_ref AS BLOB)) < 1 |
| 327 | 336 | OR length(CAST(direction_id AS BLOB)) < 1 |
| @@ -482,12 +491,16 @@ export class SqliteCatalogReader implements CatalogReader { | ||
| 482 | 491 | `); |
| 483 | 492 | this.#listServicesStatement = database.prepare(` |
| 484 | 493 | SELECT |
| 485 | - service_id AS serviceId, | |
| 486 | - stop_label AS stopLabel, | |
| 487 | - line_label AS lineLabel, | |
| 488 | - destination_label AS destinationLabel | |
| 494 | + services.service_id AS serviceId, | |
| 495 | + services.stop_label AS stopLabel, | |
| 496 | + services.line_label AS lineLabel, | |
| 497 | + services.destination_label AS destinationLabel, | |
| 498 | + places.mode AS lineMode, | |
| 499 | + services.line_color AS lineColor, | |
| 500 | + services.line_text_color AS lineTextColor | |
| 489 | 501 | FROM services |
| 490 | - WHERE place_id = ? | |
| 502 | + JOIN places ON places.place_id = services.place_id | |
| 503 | + WHERE services.place_id = ? | |
| 491 | 504 | ORDER BY |
| 492 | 505 | line_label COLLATE NOCASE, |
| 493 | 506 | destination_label COLLATE NOCASE, |
| @@ -544,6 +557,9 @@ export class SqliteCatalogReader implements CatalogReader { | ||
| 544 | 557 | stopLabel: entry.stopLabel, |
| 545 | 558 | lineLabel: entry.lineLabel, |
| 546 | 559 | destinationLabel: entry.destinationLabel, |
| 560 | + lineMode: entry.lineMode, | |
| 561 | + lineColor: entry.lineColor, | |
| 562 | + lineTextColor: entry.lineTextColor, | |
| 547 | 563 | })); |
| 548 | 564 | } |
| 549 | 565 | |
| @@ -48,6 +48,9 @@ interface ServiceRow { | |||
| 48 | readonly stopLabel: string; | 48 | readonly stopLabel: string; |
| 49 | readonly lineLabel: string; | 49 | readonly lineLabel: string; |
| 50 | readonly destinationLabel: string; | 50 | readonly destinationLabel: string; |
| 51 | + readonly lineMode: TransportMode; | ||
| 52 | + readonly lineColor: string; | ||
| 53 | + readonly lineTextColor: string; | ||
| 51 | } | 54 | } |
| 52 | 55 | ||
| 53 | interface ResolutionRow { | 56 | interface ResolutionRow { |
| @@ -87,7 +90,7 @@ interface ExpectedColumn { | |||
| 87 | const CLOSE_READER = Symbol("closeCatalogReader"); | 90 | const CLOSE_READER = Symbol("closeCatalogReader"); |
| 88 | const OPAQUE_ID_BYTES = 47; | 91 | const OPAQUE_ID_BYTES = 47; |
| 89 | const CATALOG_SCHEMA_VERSION = "1"; | 92 | const CATALOG_SCHEMA_VERSION = "1"; |
| 90 | -const CATALOG_FORMAT_VERSION = "1"; | 93 | +const CATALOG_FORMAT_VERSION = "2"; |
| 91 | const REQUIRED_METADATA = [ | 94 | const REQUIRED_METADATA = [ |
| 92 | "schema_version", | 95 | "schema_version", |
| 93 | "catalog_version", | 96 | "catalog_version", |
| @@ -123,6 +126,8 @@ const TABLE_COLUMNS: Readonly<Record<string, readonly ExpectedColumn[]>> = { | |||
| 123 | { name: "stop_label", type: "TEXT", notNull: true }, | 126 | { name: "stop_label", type: "TEXT", notNull: true }, |
| 124 | { name: "line_label", type: "TEXT", notNull: true }, | 127 | { name: "line_label", type: "TEXT", notNull: true }, |
| 125 | { name: "destination_label", type: "TEXT", notNull: true }, | 128 | { name: "destination_label", type: "TEXT", notNull: true }, |
| 129 | + { name: "line_color", type: "TEXT", notNull: true }, | ||
| 130 | + { name: "line_text_color", type: "TEXT", notNull: true }, | ||
| 126 | { name: "monitoring_ref", type: "TEXT", notNull: true }, | 131 | { name: "monitoring_ref", type: "TEXT", notNull: true }, |
| 127 | { name: "line_ref", type: "TEXT", notNull: true }, | 132 | { name: "line_ref", type: "TEXT", notNull: true }, |
| 128 | { name: "direction_id", type: "TEXT", notNull: true }, | 133 | { name: "direction_id", type: "TEXT", notNull: true }, |
| @@ -322,6 +327,10 @@ function validateData(database: DatabaseSync): void { | |||
| 322 | OR length(CAST(stop_label AS BLOB)) NOT BETWEEN 1 AND ${LIMITS.labelUtf8Bytes} | 327 | OR length(CAST(stop_label AS BLOB)) NOT BETWEEN 1 AND ${LIMITS.labelUtf8Bytes} |
| 323 | OR length(CAST(line_label AS BLOB)) NOT BETWEEN 1 AND ${LIMITS.labelUtf8Bytes} | 328 | OR length(CAST(line_label AS BLOB)) NOT BETWEEN 1 AND ${LIMITS.labelUtf8Bytes} |
| 324 | OR length(CAST(destination_label AS BLOB)) NOT BETWEEN 1 AND ${LIMITS.labelUtf8Bytes} | 329 | OR length(CAST(destination_label AS BLOB)) NOT BETWEEN 1 AND ${LIMITS.labelUtf8Bytes} |
| 330 | + OR length(CAST(line_color AS BLOB)) <> 7 | ||
| 331 | + OR line_color NOT GLOB '#[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]' | ||
| 332 | + OR length(CAST(line_text_color AS BLOB)) <> 7 | ||
| 333 | + OR line_text_color NOT GLOB '#[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]' | ||
| 325 | OR length(CAST(monitoring_ref AS BLOB)) < 1 | 334 | OR length(CAST(monitoring_ref AS BLOB)) < 1 |
| 326 | OR length(CAST(line_ref AS BLOB)) < 1 | 335 | OR length(CAST(line_ref AS BLOB)) < 1 |
| 327 | OR length(CAST(direction_id AS BLOB)) < 1 | 336 | OR length(CAST(direction_id AS BLOB)) < 1 |
| @@ -482,12 +491,16 @@ export class SqliteCatalogReader implements CatalogReader { | |||
| 482 | `); | 491 | `); |
| 483 | this.#listServicesStatement = database.prepare(` | 492 | this.#listServicesStatement = database.prepare(` |
| 484 | SELECT | 493 | SELECT |
| 485 | - service_id AS serviceId, | 494 | + services.service_id AS serviceId, |
| 486 | - stop_label AS stopLabel, | 495 | + services.stop_label AS stopLabel, |
| 487 | - line_label AS lineLabel, | 496 | + services.line_label AS lineLabel, |
| 488 | - destination_label AS destinationLabel | 497 | + services.destination_label AS destinationLabel, |
| 498 | + places.mode AS lineMode, | ||
| 499 | + services.line_color AS lineColor, | ||
| 500 | + services.line_text_color AS lineTextColor | ||
| 489 | FROM services | 501 | FROM services |
| 490 | - WHERE place_id = ? | 502 | + JOIN places ON places.place_id = services.place_id |
| 503 | + WHERE services.place_id = ? | ||
| 491 | ORDER BY | 504 | ORDER BY |
| 492 | line_label COLLATE NOCASE, | 505 | line_label COLLATE NOCASE, |
| 493 | destination_label COLLATE NOCASE, | 506 | destination_label COLLATE NOCASE, |
| @@ -544,6 +557,9 @@ export class SqliteCatalogReader implements CatalogReader { | |||
| 544 | stopLabel: entry.stopLabel, | 557 | stopLabel: entry.stopLabel, |
| 545 | lineLabel: entry.lineLabel, | 558 | lineLabel: entry.lineLabel, |
| 546 | destinationLabel: entry.destinationLabel, | 559 | destinationLabel: entry.destinationLabel, |
| 560 | + lineMode: entry.lineMode, | ||
| 561 | + lineColor: entry.lineColor, | ||
| 562 | + lineTextColor: entry.lineTextColor, | ||
| 547 | })); | 563 | })); |
| 548 | } | 564 | } |
| 549 | 565 | ||
modified
packages/backend/test/catalog-http.test.ts +5 -5 | @@ -55,20 +55,20 @@ function fixtureManager(): CatalogManager { | ||
| 55 | 55 | PRAGMA foreign_keys = ON; |
| 56 | 56 | CREATE TABLE metadata(key TEXT PRIMARY KEY, value TEXT NOT NULL); |
| 57 | 57 | CREATE TABLE places(place_id TEXT PRIMARY KEY, stop_label TEXT NOT NULL, locality_label TEXT, mode TEXT NOT NULL, canonical_tuple TEXT NOT NULL UNIQUE); |
| 58 | - CREATE TABLE services(service_id TEXT PRIMARY KEY, place_id TEXT NOT NULL REFERENCES places(place_id), stop_label TEXT NOT NULL, line_label TEXT NOT NULL, destination_label TEXT NOT NULL, monitoring_ref TEXT NOT NULL, line_ref TEXT NOT NULL, direction_id TEXT NOT NULL, destination_ref TEXT NOT NULL, canonical_tuple TEXT NOT NULL UNIQUE); | |
| 58 | + CREATE TABLE services(service_id TEXT PRIMARY KEY, place_id TEXT NOT NULL REFERENCES places(place_id), stop_label TEXT NOT NULL, line_label TEXT NOT NULL, destination_label TEXT NOT NULL, line_color TEXT NOT NULL, line_text_color TEXT NOT NULL, monitoring_ref TEXT NOT NULL, line_ref TEXT NOT NULL, direction_id TEXT NOT NULL, destination_ref TEXT NOT NULL, canonical_tuple TEXT NOT NULL UNIQUE); | |
| 59 | 59 | CREATE VIRTUAL TABLE place_search USING fts5(search_text, place_id UNINDEXED); |
| 60 | 60 | `); |
| 61 | 61 | const metadata = database.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)"); |
| 62 | - metadata.run("schema_version", "1"); metadata.run("catalog_version", "1"); | |
| 62 | + metadata.run("schema_version", "1"); metadata.run("catalog_version", "2"); | |
| 63 | 63 | metadata.run("source_revision", "http-fixture-revision"); |
| 64 | 64 | metadata.run("created_at", "2026-08-30T12:00:00.000Z"); |
| 65 | 65 | const place = database.prepare("INSERT INTO places VALUES (?, ?, ?, ?, ?)"); |
| 66 | 66 | const search = database.prepare("INSERT INTO place_search(search_text, place_id) VALUES (?, ?)"); |
| 67 | - const service = database.prepare("INSERT INTO services VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); | |
| 67 | + const service = database.prepare("INSERT INTO services VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); | |
| 68 | 68 | FIXTURE.forEach((entry, index) => { |
| 69 | 69 | place.run(entry.id, entry.label, entry.locality, entry.mode, `place:v1|${index}:${entry.id}`); |
| 70 | 70 | search.run(normalizedSearchText(entry), entry.id); |
| 71 | - service.run(entry.serviceId, entry.id, entry.label, entry.line, entry.destination, entry.monitoringRef, entry.lineRef, `direction-${index}`, `raw-terminal-${index}`, `service:v1|${index}:${entry.serviceId}`); | |
| 71 | + service.run(entry.serviceId, entry.id, entry.label, entry.line, entry.destination, "#123456", "#ffffff", entry.monitoringRef, entry.lineRef, `direction-${index}`, `raw-terminal-${index}`, `service:v1|${index}:${entry.serviceId}`); | |
| 72 | 72 | }); |
| 73 | 73 | database.close(); |
| 74 | 74 | const manager = new CatalogManager(); |
| @@ -100,7 +100,7 @@ test("GET routes return exact public response bodies without source identifiers | ||
| 100 | 100 | assert.equal(services.status, 200); |
| 101 | 101 | assert.ok(isServiceOptionsResult(services.body)); |
| 102 | 102 | assert.deepEqual(services.body, { schemaVersion: SCHEMA_VERSION, placeId: PLACE_ID, services: [ |
| 103 | - { serviceId: SERVICE_ID, stopLabel: "Châtelet", lineLabel: "4", destinationLabel: "Bagneux – Lucie Aubrac" }, | |
| 103 | + { serviceId: SERVICE_ID, stopLabel: "Châtelet", lineLabel: "4", destinationLabel: "Bagneux – Lucie Aubrac", lineMode: "METRO", lineColor: "#123456", lineTextColor: "#ffffff" }, | |
| 104 | 104 | ] }); |
| 105 | 105 | const publicJson = JSON.stringify([places.body, services.body]); |
| 106 | 106 | for (const forbidden of [PERSONAL_KEY, "raw-monitoring-metro-chatelet", "raw-line-metro-4", "raw-terminal-0", "direction-0"]) { |
| @@ -55,20 +55,20 @@ function fixtureManager(): CatalogManager { | |||
| 55 | PRAGMA foreign_keys = ON; | 55 | PRAGMA foreign_keys = ON; |
| 56 | CREATE TABLE metadata(key TEXT PRIMARY KEY, value TEXT NOT NULL); | 56 | CREATE TABLE metadata(key TEXT PRIMARY KEY, value TEXT NOT NULL); |
| 57 | CREATE TABLE places(place_id TEXT PRIMARY KEY, stop_label TEXT NOT NULL, locality_label TEXT, mode TEXT NOT NULL, canonical_tuple TEXT NOT NULL UNIQUE); | 57 | CREATE TABLE places(place_id TEXT PRIMARY KEY, stop_label TEXT NOT NULL, locality_label TEXT, mode TEXT NOT NULL, canonical_tuple TEXT NOT NULL UNIQUE); |
| 58 | - CREATE TABLE services(service_id TEXT PRIMARY KEY, place_id TEXT NOT NULL REFERENCES places(place_id), stop_label TEXT NOT NULL, line_label TEXT NOT NULL, destination_label TEXT NOT NULL, monitoring_ref TEXT NOT NULL, line_ref TEXT NOT NULL, direction_id TEXT NOT NULL, destination_ref TEXT NOT NULL, canonical_tuple TEXT NOT NULL UNIQUE); | 58 | + CREATE TABLE services(service_id TEXT PRIMARY KEY, place_id TEXT NOT NULL REFERENCES places(place_id), stop_label TEXT NOT NULL, line_label TEXT NOT NULL, destination_label TEXT NOT NULL, line_color TEXT NOT NULL, line_text_color TEXT NOT NULL, monitoring_ref TEXT NOT NULL, line_ref TEXT NOT NULL, direction_id TEXT NOT NULL, destination_ref TEXT NOT NULL, canonical_tuple TEXT NOT NULL UNIQUE); |
| 59 | CREATE VIRTUAL TABLE place_search USING fts5(search_text, place_id UNINDEXED); | 59 | CREATE VIRTUAL TABLE place_search USING fts5(search_text, place_id UNINDEXED); |
| 60 | `); | 60 | `); |
| 61 | const metadata = database.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)"); | 61 | const metadata = database.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)"); |
| 62 | - metadata.run("schema_version", "1"); metadata.run("catalog_version", "1"); | 62 | + metadata.run("schema_version", "1"); metadata.run("catalog_version", "2"); |
| 63 | metadata.run("source_revision", "http-fixture-revision"); | 63 | metadata.run("source_revision", "http-fixture-revision"); |
| 64 | metadata.run("created_at", "2026-08-30T12:00:00.000Z"); | 64 | metadata.run("created_at", "2026-08-30T12:00:00.000Z"); |
| 65 | const place = database.prepare("INSERT INTO places VALUES (?, ?, ?, ?, ?)"); | 65 | const place = database.prepare("INSERT INTO places VALUES (?, ?, ?, ?, ?)"); |
| 66 | const search = database.prepare("INSERT INTO place_search(search_text, place_id) VALUES (?, ?)"); | 66 | const search = database.prepare("INSERT INTO place_search(search_text, place_id) VALUES (?, ?)"); |
| 67 | - const service = database.prepare("INSERT INTO services VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); | 67 | + const service = database.prepare("INSERT INTO services VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); |
| 68 | FIXTURE.forEach((entry, index) => { | 68 | FIXTURE.forEach((entry, index) => { |
| 69 | place.run(entry.id, entry.label, entry.locality, entry.mode, `place:v1|${index}:${entry.id}`); | 69 | place.run(entry.id, entry.label, entry.locality, entry.mode, `place:v1|${index}:${entry.id}`); |
| 70 | search.run(normalizedSearchText(entry), entry.id); | 70 | search.run(normalizedSearchText(entry), entry.id); |
| 71 | - service.run(entry.serviceId, entry.id, entry.label, entry.line, entry.destination, entry.monitoringRef, entry.lineRef, `direction-${index}`, `raw-terminal-${index}`, `service:v1|${index}:${entry.serviceId}`); | 71 | + service.run(entry.serviceId, entry.id, entry.label, entry.line, entry.destination, "#123456", "#ffffff", entry.monitoringRef, entry.lineRef, `direction-${index}`, `raw-terminal-${index}`, `service:v1|${index}:${entry.serviceId}`); |
| 72 | }); | 72 | }); |
| 73 | database.close(); | 73 | database.close(); |
| 74 | const manager = new CatalogManager(); | 74 | const manager = new CatalogManager(); |
| @@ -100,7 +100,7 @@ test("GET routes return exact public response bodies without source identifiers | |||
| 100 | assert.equal(services.status, 200); | 100 | assert.equal(services.status, 200); |
| 101 | assert.ok(isServiceOptionsResult(services.body)); | 101 | assert.ok(isServiceOptionsResult(services.body)); |
| 102 | assert.deepEqual(services.body, { schemaVersion: SCHEMA_VERSION, placeId: PLACE_ID, services: [ | 102 | assert.deepEqual(services.body, { schemaVersion: SCHEMA_VERSION, placeId: PLACE_ID, services: [ |
| 103 | - { serviceId: SERVICE_ID, stopLabel: "Châtelet", lineLabel: "4", destinationLabel: "Bagneux – Lucie Aubrac" }, | 103 | + { serviceId: SERVICE_ID, stopLabel: "Châtelet", lineLabel: "4", destinationLabel: "Bagneux – Lucie Aubrac", lineMode: "METRO", lineColor: "#123456", lineTextColor: "#ffffff" }, |
| 104 | ] }); | 104 | ] }); |
| 105 | const publicJson = JSON.stringify([places.body, services.body]); | 105 | const publicJson = JSON.stringify([places.body, services.body]); |
| 106 | for (const forbidden of [PERSONAL_KEY, "raw-monitoring-metro-chatelet", "raw-line-metro-4", "raw-terminal-0", "direction-0"]) { | 106 | for (const forbidden of [PERSONAL_KEY, "raw-monitoring-metro-chatelet", "raw-line-metro-4", "raw-terminal-0", "direction-0"]) { |
modified
packages/backend/test/catalog-import.test.ts +47 -0 | @@ -107,6 +107,13 @@ test("import covers five modes, gates perimeter and unsupported modes, preserves | ||
| 107 | 107 | const metroServices = manager.listServices(metro.placeId) ?? []; |
| 108 | 108 | assert.equal(metroServices.length, 2); |
| 109 | 109 | assert.notEqual(metroServices[0]?.serviceId, metroServices[1]?.serviceId); |
| 110 | + assert.deepEqual( | |
| 111 | + metroServices.map(({ lineMode, lineColor, lineTextColor }) => ({ lineMode, lineColor, lineTextColor })), | |
| 112 | + [ | |
| 113 | + { lineMode: "METRO", lineColor: "#be418d", lineTextColor: "#ffffff" }, | |
| 114 | + { lineMode: "METRO", lineColor: "#be418d", lineTextColor: "#ffffff" }, | |
| 115 | + ], | |
| 116 | + ); | |
| 110 | 117 | const rer = place(manager, "chatelet les halles", "RER"); |
| 111 | 118 | const east = service(manager, rer.placeId, "Marne-la-Vallée Chessy"); |
| 112 | 119 | const west = service(manager, rer.placeId, "Saint-Germain-en-Laye"); |
| @@ -129,6 +136,46 @@ test("import covers five modes, gates perimeter and unsupported modes, preserves | ||
| 129 | 136 | assert.doesNotMatch(JSON.stringify([...manager.searchPlaces("chatelet"), ...metroServices, ...busServices]), /STIF:|IDFM:|DEST:/u); |
| 130 | 137 | }); |
| 131 | 138 | |
| 139 | +test("import normalizes valid colors and rejects invalid or missing authoritative colors", async (t) => { | |
| 140 | + const directory = workspace(t); | |
| 141 | + const validSources = sourcesFor("fixture-2026-08-a"); | |
| 142 | + const invalidSources: CatalogRecordSourceSet = { | |
| 143 | + ...validSources, | |
| 144 | + lines: validSources.lines.map((line, index) => index === 0 | |
| 145 | + ? { ...line, colourweb_hexa: "not-a-color" } | |
| 146 | + : line), | |
| 147 | + }; | |
| 148 | + const invalidCandidate = join(directory, "invalid-color.sqlite"); | |
| 149 | + await assert.rejects( | |
| 150 | + buildCatalogCandidateFromRecords({ | |
| 151 | + candidatePath: invalidCandidate, | |
| 152 | + sourceRevision: "fixture-invalid-color", | |
| 153 | + sources: invalidSources, | |
| 154 | + }), | |
| 155 | + /six-digit hexadecimal color/u, | |
| 156 | + ); | |
| 157 | + assert.equal(existsSync(invalidCandidate), false); | |
| 158 | + | |
| 159 | + const missingSources: CatalogRecordSourceSet = { | |
| 160 | + ...validSources, | |
| 161 | + lines: validSources.lines.map((line, index) => { | |
| 162 | + if (index !== 0) return line; | |
| 163 | + const { textcolourweb_hexa: _missing, ...withoutTextColor } = line; | |
| 164 | + return withoutTextColor; | |
| 165 | + }), | |
| 166 | + }; | |
| 167 | + const missingCandidate = join(directory, "missing-color.sqlite"); | |
| 168 | + await assert.rejects( | |
| 169 | + buildCatalogCandidateFromRecords({ | |
| 170 | + candidatePath: missingCandidate, | |
| 171 | + sourceRevision: "fixture-missing-color", | |
| 172 | + sources: missingSources, | |
| 173 | + }), | |
| 174 | + /missing textcolourweb_hexa/u, | |
| 175 | + ); | |
| 176 | + assert.equal(existsSync(missingCandidate), false); | |
| 177 | +}); | |
| 178 | + | |
| 132 | 179 | test("activation preserves stable services, removes stale IDs, and failed replacement leaves the active file untouched", async (t) => { |
| 133 | 180 | const directory = workspace(t); |
| 134 | 181 | const active = join(directory, "catalog.sqlite"); |
| @@ -107,6 +107,13 @@ test("import covers five modes, gates perimeter and unsupported modes, preserves | |||
| 107 | const metroServices = manager.listServices(metro.placeId) ?? []; | 107 | const metroServices = manager.listServices(metro.placeId) ?? []; |
| 108 | assert.equal(metroServices.length, 2); | 108 | assert.equal(metroServices.length, 2); |
| 109 | assert.notEqual(metroServices[0]?.serviceId, metroServices[1]?.serviceId); | 109 | assert.notEqual(metroServices[0]?.serviceId, metroServices[1]?.serviceId); |
| 110 | + assert.deepEqual( | ||
| 111 | + metroServices.map(({ lineMode, lineColor, lineTextColor }) => ({ lineMode, lineColor, lineTextColor })), | ||
| 112 | + [ | ||
| 113 | + { lineMode: "METRO", lineColor: "#be418d", lineTextColor: "#ffffff" }, | ||
| 114 | + { lineMode: "METRO", lineColor: "#be418d", lineTextColor: "#ffffff" }, | ||
| 115 | + ], | ||
| 116 | + ); | ||
| 110 | const rer = place(manager, "chatelet les halles", "RER"); | 117 | const rer = place(manager, "chatelet les halles", "RER"); |
| 111 | const east = service(manager, rer.placeId, "Marne-la-Vallée Chessy"); | 118 | const east = service(manager, rer.placeId, "Marne-la-Vallée Chessy"); |
| 112 | const west = service(manager, rer.placeId, "Saint-Germain-en-Laye"); | 119 | const west = service(manager, rer.placeId, "Saint-Germain-en-Laye"); |
| @@ -129,6 +136,46 @@ test("import covers five modes, gates perimeter and unsupported modes, preserves | |||
| 129 | assert.doesNotMatch(JSON.stringify([...manager.searchPlaces("chatelet"), ...metroServices, ...busServices]), /STIF:|IDFM:|DEST:/u); | 136 | assert.doesNotMatch(JSON.stringify([...manager.searchPlaces("chatelet"), ...metroServices, ...busServices]), /STIF:|IDFM:|DEST:/u); |
| 130 | }); | 137 | }); |
| 131 | 138 | ||
| 139 | +test("import normalizes valid colors and rejects invalid or missing authoritative colors", async (t) => { | ||
| 140 | + const directory = workspace(t); | ||
| 141 | + const validSources = sourcesFor("fixture-2026-08-a"); | ||
| 142 | + const invalidSources: CatalogRecordSourceSet = { | ||
| 143 | + ...validSources, | ||
| 144 | + lines: validSources.lines.map((line, index) => index === 0 | ||
| 145 | + ? { ...line, colourweb_hexa: "not-a-color" } | ||
| 146 | + : line), | ||
| 147 | + }; | ||
| 148 | + const invalidCandidate = join(directory, "invalid-color.sqlite"); | ||
| 149 | + await assert.rejects( | ||
| 150 | + buildCatalogCandidateFromRecords({ | ||
| 151 | + candidatePath: invalidCandidate, | ||
| 152 | + sourceRevision: "fixture-invalid-color", | ||
| 153 | + sources: invalidSources, | ||
| 154 | + }), | ||
| 155 | + /six-digit hexadecimal color/u, | ||
| 156 | + ); | ||
| 157 | + assert.equal(existsSync(invalidCandidate), false); | ||
| 158 | + | ||
| 159 | + const missingSources: CatalogRecordSourceSet = { | ||
| 160 | + ...validSources, | ||
| 161 | + lines: validSources.lines.map((line, index) => { | ||
| 162 | + if (index !== 0) return line; | ||
| 163 | + const { textcolourweb_hexa: _missing, ...withoutTextColor } = line; | ||
| 164 | + return withoutTextColor; | ||
| 165 | + }), | ||
| 166 | + }; | ||
| 167 | + const missingCandidate = join(directory, "missing-color.sqlite"); | ||
| 168 | + await assert.rejects( | ||
| 169 | + buildCatalogCandidateFromRecords({ | ||
| 170 | + candidatePath: missingCandidate, | ||
| 171 | + sourceRevision: "fixture-missing-color", | ||
| 172 | + sources: missingSources, | ||
| 173 | + }), | ||
| 174 | + /missing textcolourweb_hexa/u, | ||
| 175 | + ); | ||
| 176 | + assert.equal(existsSync(missingCandidate), false); | ||
| 177 | +}); | ||
| 178 | + | ||
| 132 | test("activation preserves stable services, removes stale IDs, and failed replacement leaves the active file untouched", async (t) => { | 179 | test("activation preserves stable services, removes stale IDs, and failed replacement leaves the active file untouched", async (t) => { |
| 133 | const directory = workspace(t); | 180 | const directory = workspace(t); |
| 134 | const active = join(directory, "catalog.sqlite"); | 181 | const active = join(directory, "catalog.sqlite"); |
modified
packages/backend/test/catalog.test.ts +31 -3 | @@ -23,6 +23,8 @@ interface ServiceSeed { | ||
| 23 | 23 | readonly stopLabel: string; |
| 24 | 24 | readonly lineLabel: string; |
| 25 | 25 | readonly destinationLabel: string; |
| 26 | + readonly lineColor?: string; | |
| 27 | + readonly lineTextColor?: string; | |
| 26 | 28 | readonly monitoringRef: string; |
| 27 | 29 | readonly lineRef: string; |
| 28 | 30 | readonly directionId: string; |
| @@ -208,6 +210,8 @@ function buildCatalog(seed: CatalogSeed = {}): BuiltCatalog { | ||
| 208 | 210 | stop_label TEXT NOT NULL, |
| 209 | 211 | line_label TEXT NOT NULL, |
| 210 | 212 | destination_label TEXT NOT NULL, |
| 213 | + line_color TEXT NOT NULL, | |
| 214 | + line_text_color TEXT NOT NULL, | |
| 211 | 215 | monitoring_ref TEXT NOT NULL, |
| 212 | 216 | line_ref TEXT NOT NULL, |
| 213 | 217 | direction_id TEXT NOT NULL, |
| @@ -219,7 +223,7 @@ function buildCatalog(seed: CatalogSeed = {}): BuiltCatalog { | ||
| 219 | 223 | |
| 220 | 224 | const metadata = database.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)"); |
| 221 | 225 | metadata.run("schema_version", "1"); |
| 222 | - metadata.run("catalog_version", seed.catalogVersion ?? "1"); | |
| 226 | + metadata.run("catalog_version", seed.catalogVersion ?? "2"); | |
| 223 | 227 | metadata.run("source_revision", seed.revision ?? "fixture-revision-a"); |
| 224 | 228 | metadata.run("created_at", "2026-08-30T12:00:00.000Z"); |
| 225 | 229 | |
| @@ -258,12 +262,14 @@ function buildCatalog(seed: CatalogSeed = {}): BuiltCatalog { | ||
| 258 | 262 | stop_label, |
| 259 | 263 | line_label, |
| 260 | 264 | destination_label, |
| 265 | + line_color, | |
| 266 | + line_text_color, | |
| 261 | 267 | monitoring_ref, |
| 262 | 268 | line_ref, |
| 263 | 269 | direction_id, |
| 264 | 270 | destination_ref, |
| 265 | 271 | canonical_tuple |
| 266 | - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 272 | + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 267 | 273 | `); |
| 268 | 274 | for (const service of services) { |
| 269 | 275 | const identity = serviceIdentity(service); |
| @@ -274,6 +280,8 @@ function buildCatalog(seed: CatalogSeed = {}): BuiltCatalog { | ||
| 274 | 280 | service.stopLabel, |
| 275 | 281 | service.lineLabel, |
| 276 | 282 | service.destinationLabel, |
| 283 | + service.lineColor ?? "#123456", | |
| 284 | + service.lineTextColor ?? "#ffffff", | |
| 277 | 285 | service.monitoringRef, |
| 278 | 286 | service.lineRef, |
| 279 | 287 | service.directionId, |
| @@ -424,7 +432,7 @@ test("reload preserves stable IDs, distinguishes directions, rejects invalid can | ||
| 424 | 432 | destinationLabel: "Saint-Rémy-lès-Chevreuse", |
| 425 | 433 | }); |
| 426 | 434 | |
| 427 | - const invalid = buildCatalog({ catalogVersion: "2" }); | |
| 435 | + const invalid = buildCatalog({ catalogVersion: "1" }); | |
| 428 | 436 | assert.throws(() => manager.reload(invalid.path), /Invalid catalog candidate/u); |
| 429 | 437 | assert.equal(manager.resolveService(southId).status, "RESOLVED"); |
| 430 | 438 | |
| @@ -455,13 +463,33 @@ test("reload preserves stable IDs, distinguishes directions, rejects invalid can | ||
| 455 | 463 | assert.equal(manager.listServices("plc_missing"), undefined); |
| 456 | 464 | assert.deepEqual(Object.keys(manager.listServices(replacement.placeIds.metro)![0]!).sort(), [ |
| 457 | 465 | "destinationLabel", |
| 466 | + "lineColor", | |
| 458 | 467 | "lineLabel", |
| 468 | + "lineMode", | |
| 469 | + "lineTextColor", | |
| 459 | 470 | "serviceId", |
| 460 | 471 | "stopLabel", |
| 461 | 472 | ]); |
| 473 | + assert.deepEqual(manager.listServices(replacement.placeIds.metro)![0], { | |
| 474 | + serviceId: replacement.serviceIds["metro-south"], | |
| 475 | + stopLabel: "Châtelet", | |
| 476 | + lineLabel: "4", | |
| 477 | + destinationLabel: "Bagneux – Lucie Aubrac", | |
| 478 | + lineMode: "METRO", | |
| 479 | + lineColor: "#123456", | |
| 480 | + lineTextColor: "#ffffff", | |
| 481 | + }); | |
| 462 | 482 | }); |
| 463 | 483 | |
| 464 | 484 | test("candidate validation rejects incomplete FTS coverage", () => { |
| 465 | 485 | const invalid = buildCatalog({ omitSearchFor: "metro" }); |
| 466 | 486 | assert.throws(() => SqliteCatalogReader.open(invalid.path), /search consistency/u); |
| 467 | 487 | }); |
| 488 | + | |
| 489 | +test("candidate validation rejects non-normalized presentation colors", () => { | |
| 490 | + const invalid = buildCatalog({ | |
| 491 | + omitServiceKeys: ["metro-south"], | |
| 492 | + services: [{ ...BASE_SERVICES.find((service) => service.key === "metro-south")!, lineColor: "#ABCDEF" }], | |
| 493 | + }); | |
| 494 | + assert.throws(() => SqliteCatalogReader.open(invalid.path), /service data/u); | |
| 495 | +}); | |
| @@ -23,6 +23,8 @@ interface ServiceSeed { | |||
| 23 | readonly stopLabel: string; | 23 | readonly stopLabel: string; |
| 24 | readonly lineLabel: string; | 24 | readonly lineLabel: string; |
| 25 | readonly destinationLabel: string; | 25 | readonly destinationLabel: string; |
| 26 | + readonly lineColor?: string; | ||
| 27 | + readonly lineTextColor?: string; | ||
| 26 | readonly monitoringRef: string; | 28 | readonly monitoringRef: string; |
| 27 | readonly lineRef: string; | 29 | readonly lineRef: string; |
| 28 | readonly directionId: string; | 30 | readonly directionId: string; |
| @@ -208,6 +210,8 @@ function buildCatalog(seed: CatalogSeed = {}): BuiltCatalog { | |||
| 208 | stop_label TEXT NOT NULL, | 210 | stop_label TEXT NOT NULL, |
| 209 | line_label TEXT NOT NULL, | 211 | line_label TEXT NOT NULL, |
| 210 | destination_label TEXT NOT NULL, | 212 | destination_label TEXT NOT NULL, |
| 213 | + line_color TEXT NOT NULL, | ||
| 214 | + line_text_color TEXT NOT NULL, | ||
| 211 | monitoring_ref TEXT NOT NULL, | 215 | monitoring_ref TEXT NOT NULL, |
| 212 | line_ref TEXT NOT NULL, | 216 | line_ref TEXT NOT NULL, |
| 213 | direction_id TEXT NOT NULL, | 217 | direction_id TEXT NOT NULL, |
| @@ -219,7 +223,7 @@ function buildCatalog(seed: CatalogSeed = {}): BuiltCatalog { | |||
| 219 | 223 | ||
| 220 | const metadata = database.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)"); | 224 | const metadata = database.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)"); |
| 221 | metadata.run("schema_version", "1"); | 225 | metadata.run("schema_version", "1"); |
| 222 | - metadata.run("catalog_version", seed.catalogVersion ?? "1"); | 226 | + metadata.run("catalog_version", seed.catalogVersion ?? "2"); |
| 223 | metadata.run("source_revision", seed.revision ?? "fixture-revision-a"); | 227 | metadata.run("source_revision", seed.revision ?? "fixture-revision-a"); |
| 224 | metadata.run("created_at", "2026-08-30T12:00:00.000Z"); | 228 | metadata.run("created_at", "2026-08-30T12:00:00.000Z"); |
| 225 | 229 | ||
| @@ -258,12 +262,14 @@ function buildCatalog(seed: CatalogSeed = {}): BuiltCatalog { | |||
| 258 | stop_label, | 262 | stop_label, |
| 259 | line_label, | 263 | line_label, |
| 260 | destination_label, | 264 | destination_label, |
| 265 | + line_color, | ||
| 266 | + line_text_color, | ||
| 261 | monitoring_ref, | 267 | monitoring_ref, |
| 262 | line_ref, | 268 | line_ref, |
| 263 | direction_id, | 269 | direction_id, |
| 264 | destination_ref, | 270 | destination_ref, |
| 265 | canonical_tuple | 271 | canonical_tuple |
| 266 | - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | 272 | + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
| 267 | `); | 273 | `); |
| 268 | for (const service of services) { | 274 | for (const service of services) { |
| 269 | const identity = serviceIdentity(service); | 275 | const identity = serviceIdentity(service); |
| @@ -274,6 +280,8 @@ function buildCatalog(seed: CatalogSeed = {}): BuiltCatalog { | |||
| 274 | service.stopLabel, | 280 | service.stopLabel, |
| 275 | service.lineLabel, | 281 | service.lineLabel, |
| 276 | service.destinationLabel, | 282 | service.destinationLabel, |
| 283 | + service.lineColor ?? "#123456", | ||
| 284 | + service.lineTextColor ?? "#ffffff", | ||
| 277 | service.monitoringRef, | 285 | service.monitoringRef, |
| 278 | service.lineRef, | 286 | service.lineRef, |
| 279 | service.directionId, | 287 | service.directionId, |
| @@ -424,7 +432,7 @@ test("reload preserves stable IDs, distinguishes directions, rejects invalid can | |||
| 424 | destinationLabel: "Saint-Rémy-lès-Chevreuse", | 432 | destinationLabel: "Saint-Rémy-lès-Chevreuse", |
| 425 | }); | 433 | }); |
| 426 | 434 | ||
| 427 | - const invalid = buildCatalog({ catalogVersion: "2" }); | 435 | + const invalid = buildCatalog({ catalogVersion: "1" }); |
| 428 | assert.throws(() => manager.reload(invalid.path), /Invalid catalog candidate/u); | 436 | assert.throws(() => manager.reload(invalid.path), /Invalid catalog candidate/u); |
| 429 | assert.equal(manager.resolveService(southId).status, "RESOLVED"); | 437 | assert.equal(manager.resolveService(southId).status, "RESOLVED"); |
| 430 | 438 | ||
| @@ -455,13 +463,33 @@ test("reload preserves stable IDs, distinguishes directions, rejects invalid can | |||
| 455 | assert.equal(manager.listServices("plc_missing"), undefined); | 463 | assert.equal(manager.listServices("plc_missing"), undefined); |
| 456 | assert.deepEqual(Object.keys(manager.listServices(replacement.placeIds.metro)![0]!).sort(), [ | 464 | assert.deepEqual(Object.keys(manager.listServices(replacement.placeIds.metro)![0]!).sort(), [ |
| 457 | "destinationLabel", | 465 | "destinationLabel", |
| 466 | + "lineColor", | ||
| 458 | "lineLabel", | 467 | "lineLabel", |
| 468 | + "lineMode", | ||
| 469 | + "lineTextColor", | ||
| 459 | "serviceId", | 470 | "serviceId", |
| 460 | "stopLabel", | 471 | "stopLabel", |
| 461 | ]); | 472 | ]); |
| 473 | + assert.deepEqual(manager.listServices(replacement.placeIds.metro)![0], { | ||
| 474 | + serviceId: replacement.serviceIds["metro-south"], | ||
| 475 | + stopLabel: "Châtelet", | ||
| 476 | + lineLabel: "4", | ||
| 477 | + destinationLabel: "Bagneux – Lucie Aubrac", | ||
| 478 | + lineMode: "METRO", | ||
| 479 | + lineColor: "#123456", | ||
| 480 | + lineTextColor: "#ffffff", | ||
| 481 | + }); | ||
| 462 | }); | 482 | }); |
| 463 | 483 | ||
| 464 | test("candidate validation rejects incomplete FTS coverage", () => { | 484 | test("candidate validation rejects incomplete FTS coverage", () => { |
| 465 | const invalid = buildCatalog({ omitSearchFor: "metro" }); | 485 | const invalid = buildCatalog({ omitSearchFor: "metro" }); |
| 466 | assert.throws(() => SqliteCatalogReader.open(invalid.path), /search consistency/u); | 486 | assert.throws(() => SqliteCatalogReader.open(invalid.path), /search consistency/u); |
| 467 | }); | 487 | }); |
| 488 | + | ||
| 489 | +test("candidate validation rejects non-normalized presentation colors", () => { | ||
| 490 | + const invalid = buildCatalog({ | ||
| 491 | + omitServiceKeys: ["metro-south"], | ||
| 492 | + services: [{ ...BASE_SERVICES.find((service) => service.key === "metro-south")!, lineColor: "#ABCDEF" }], | ||
| 493 | + }); | ||
| 494 | + assert.throws(() => SqliteCatalogReader.open(invalid.path), /service data/u); | ||
| 495 | +}); | ||
modified
packages/companion/src/configuration.js +4 -1 | @@ -11,7 +11,10 @@ var FAVORITE_STRING_KEYS = [ | ||
| 11 | 11 | "displayName", |
| 12 | 12 | "stopLabel", |
| 13 | 13 | "lineLabel", |
| 14 | - "destinationLabel" | |
| 14 | + "destinationLabel", | |
| 15 | + "lineMode", | |
| 16 | + "lineColor", | |
| 17 | + "lineTextColor" | |
| 15 | 18 | ]; |
| 16 | 19 | |
| 17 | 20 | function emptyConfiguration() { |
| @@ -11,7 +11,10 @@ var FAVORITE_STRING_KEYS = [ | |||
| 11 | "displayName", | 11 | "displayName", |
| 12 | "stopLabel", | 12 | "stopLabel", |
| 13 | "lineLabel", | 13 | "lineLabel", |
| 14 | - "destinationLabel" | 14 | + "destinationLabel", |
| 15 | + "lineMode", | ||
| 16 | + "lineColor", | ||
| 17 | + "lineTextColor" | ||
| 15 | ]; | 18 | ]; |
| 16 | 19 | ||
| 17 | function emptyConfiguration() { | 20 | function emptyConfiguration() { |
modified
packages/companion/src/contracts.js +31 -5 | @@ -25,6 +25,7 @@ var ERROR_CODE = Object.freeze([ | ||
| 25 | 25 | "RATE_LIMITED", |
| 26 | 26 | "INVALID_RESPONSE" |
| 27 | 27 | ]); |
| 28 | +var TRANSPORT_MODE = Object.freeze(["BUS", "METRO", "TRAM", "RER", "TRANSILIEN"]); | |
| 28 | 29 | var API_KEY_ACTION = Object.freeze(["KEEP", "REPLACE", "REMOVE"]); |
| 29 | 30 | var KEY_STATUS = Object.freeze({ MISSING: 0, CONFIGURED: 1, INVALID: 2 }); |
| 30 | 31 | var REQUEST_TRIGGER = Object.freeze({ APP_OPEN: 0, FAVORITE_SELECTION: 1, MANUAL_SELECT: 2 }); |
| @@ -103,6 +104,9 @@ var FAVORITE_KEYS = [ | ||
| 103 | 104 | "stopLabel", |
| 104 | 105 | "lineLabel", |
| 105 | 106 | "destinationLabel", |
| 107 | + "lineMode", | |
| 108 | + "lineColor", | |
| 109 | + "lineTextColor", | |
| 106 | 110 | "sortOrder" |
| 107 | 111 | ]; |
| 108 | 112 | var DEPARTURE_KEYS = ["expectedAt", "aimedAt", "minutes", "status", "nextIntervalMinutes"]; |
| @@ -169,6 +173,10 @@ function boundedString(value, maximum) { | ||
| 169 | 173 | return size >= 1 && size <= maximum; |
| 170 | 174 | } |
| 171 | 175 | |
| 176 | +function isLineColor(value) { | |
| 177 | + return typeof value === "string" && /^#[0-9a-f]{6}$/.test(value); | |
| 178 | +} | |
| 179 | + | |
| 172 | 180 | function uint32(value) { |
| 173 | 181 | return typeof value === "number" && isFinite(value) && Math.floor(value) === value && value >= 0 && value <= UINT32_MAX; |
| 174 | 182 | } |
| @@ -178,15 +186,26 @@ function optionalUint32(value) { | ||
| 178 | 186 | } |
| 179 | 187 | |
| 180 | 188 | function isFavorite(value) { |
| 181 | - return isObject(value) | |
| 182 | - && hasOnlyKeys(value, FAVORITE_KEYS) | |
| 183 | - && value.schemaVersion === SCHEMA_VERSION | |
| 189 | + var hasLineColor; | |
| 190 | + var hasLineMode; | |
| 191 | + var hasLineTextColor; | |
| 192 | + if (!isObject(value) || !hasOnlyKeys(value, FAVORITE_KEYS)) return false; | |
| 193 | + hasLineMode = Object.prototype.hasOwnProperty.call(value, "lineMode"); | |
| 194 | + hasLineColor = Object.prototype.hasOwnProperty.call(value, "lineColor"); | |
| 195 | + hasLineTextColor = Object.prototype.hasOwnProperty.call(value, "lineTextColor"); | |
| 196 | + if (hasLineMode !== hasLineColor || hasLineMode !== hasLineTextColor) return false; | |
| 197 | + return value.schemaVersion === SCHEMA_VERSION | |
| 184 | 198 | && boundedString(value.id, LIMITS.idUtf8Bytes) |
| 185 | 199 | && boundedString(value.serviceId, LIMITS.idUtf8Bytes) |
| 186 | 200 | && (typeof value.displayName === "undefined" || boundedString(value.displayName, LIMITS.labelUtf8Bytes)) |
| 187 | 201 | && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes) |
| 188 | 202 | && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes) |
| 189 | 203 | && boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes) |
| 204 | + && (!hasLineMode || ( | |
| 205 | + TRANSPORT_MODE.indexOf(value.lineMode) !== -1 | |
| 206 | + && isLineColor(value.lineColor) | |
| 207 | + && isLineColor(value.lineTextColor) | |
| 208 | + )) | |
| 190 | 209 | && typeof value.sortOrder === "number" |
| 191 | 210 | && Math.floor(value.sortOrder) === value.sortOrder |
| 192 | 211 | && value.sortOrder >= 0 |
| @@ -262,9 +281,14 @@ function copyFavorite(favorite) { | ||
| 262 | 281 | serviceId: favorite.serviceId, |
| 263 | 282 | stopLabel: favorite.stopLabel, |
| 264 | 283 | lineLabel: favorite.lineLabel, |
| 265 | - destinationLabel: favorite.destinationLabel, | |
| 266 | - sortOrder: favorite.sortOrder | |
| 284 | + destinationLabel: favorite.destinationLabel | |
| 267 | 285 | }; |
| 286 | + if (Object.prototype.hasOwnProperty.call(favorite, "lineMode")) { | |
| 287 | + copy.lineMode = favorite.lineMode; | |
| 288 | + copy.lineColor = favorite.lineColor; | |
| 289 | + copy.lineTextColor = favorite.lineTextColor; | |
| 290 | + } | |
| 291 | + copy.sortOrder = favorite.sortOrder; | |
| 268 | 292 | if (typeof favorite.displayName !== "undefined") copy.displayName = favorite.displayName; |
| 269 | 293 | return copy; |
| 270 | 294 | } |
| @@ -301,6 +325,7 @@ module.exports = { | ||
| 301 | 325 | FRESHNESS: FRESHNESS, |
| 302 | 326 | DEPARTURE_STATUS: DEPARTURE_STATUS, |
| 303 | 327 | ERROR_CODE: ERROR_CODE, |
| 328 | + TRANSPORT_MODE: TRANSPORT_MODE, | |
| 304 | 329 | API_KEY_ACTION: API_KEY_ACTION, |
| 305 | 330 | KEY_STATUS: KEY_STATUS, |
| 306 | 331 | REQUEST_TRIGGER: REQUEST_TRIGGER, |
| @@ -313,6 +338,7 @@ module.exports = { | ||
| 313 | 338 | isObject: isObject, |
| 314 | 339 | hasOnlyKeys: hasOnlyKeys, |
| 315 | 340 | boundedString: boundedString, |
| 341 | + isLineColor: isLineColor, | |
| 316 | 342 | isFavorite: isFavorite, |
| 317 | 343 | isFavoriteList: isFavoriteList, |
| 318 | 344 | isDeparture: isDeparture, |
| @@ -25,6 +25,7 @@ var ERROR_CODE = Object.freeze([ | |||
| 25 | "RATE_LIMITED", | 25 | "RATE_LIMITED", |
| 26 | "INVALID_RESPONSE" | 26 | "INVALID_RESPONSE" |
| 27 | ]); | 27 | ]); |
| 28 | +var TRANSPORT_MODE = Object.freeze(["BUS", "METRO", "TRAM", "RER", "TRANSILIEN"]); | ||
| 28 | var API_KEY_ACTION = Object.freeze(["KEEP", "REPLACE", "REMOVE"]); | 29 | var API_KEY_ACTION = Object.freeze(["KEEP", "REPLACE", "REMOVE"]); |
| 29 | var KEY_STATUS = Object.freeze({ MISSING: 0, CONFIGURED: 1, INVALID: 2 }); | 30 | var KEY_STATUS = Object.freeze({ MISSING: 0, CONFIGURED: 1, INVALID: 2 }); |
| 30 | var REQUEST_TRIGGER = Object.freeze({ APP_OPEN: 0, FAVORITE_SELECTION: 1, MANUAL_SELECT: 2 }); | 31 | var REQUEST_TRIGGER = Object.freeze({ APP_OPEN: 0, FAVORITE_SELECTION: 1, MANUAL_SELECT: 2 }); |
| @@ -103,6 +104,9 @@ var FAVORITE_KEYS = [ | |||
| 103 | "stopLabel", | 104 | "stopLabel", |
| 104 | "lineLabel", | 105 | "lineLabel", |
| 105 | "destinationLabel", | 106 | "destinationLabel", |
| 107 | + "lineMode", | ||
| 108 | + "lineColor", | ||
| 109 | + "lineTextColor", | ||
| 106 | "sortOrder" | 110 | "sortOrder" |
| 107 | ]; | 111 | ]; |
| 108 | var DEPARTURE_KEYS = ["expectedAt", "aimedAt", "minutes", "status", "nextIntervalMinutes"]; | 112 | var DEPARTURE_KEYS = ["expectedAt", "aimedAt", "minutes", "status", "nextIntervalMinutes"]; |
| @@ -169,6 +173,10 @@ function boundedString(value, maximum) { | |||
| 169 | return size >= 1 && size <= maximum; | 173 | return size >= 1 && size <= maximum; |
| 170 | } | 174 | } |
| 171 | 175 | ||
| 176 | +function isLineColor(value) { | ||
| 177 | + return typeof value === "string" && /^#[0-9a-f]{6}$/.test(value); | ||
| 178 | +} | ||
| 179 | + | ||
| 172 | function uint32(value) { | 180 | function uint32(value) { |
| 173 | return typeof value === "number" && isFinite(value) && Math.floor(value) === value && value >= 0 && value <= UINT32_MAX; | 181 | return typeof value === "number" && isFinite(value) && Math.floor(value) === value && value >= 0 && value <= UINT32_MAX; |
| 174 | } | 182 | } |
| @@ -178,15 +186,26 @@ function optionalUint32(value) { | |||
| 178 | } | 186 | } |
| 179 | 187 | ||
| 180 | function isFavorite(value) { | 188 | function isFavorite(value) { |
| 181 | - return isObject(value) | 189 | + var hasLineColor; |
| 182 | - && hasOnlyKeys(value, FAVORITE_KEYS) | 190 | + var hasLineMode; |
| 183 | - && value.schemaVersion === SCHEMA_VERSION | 191 | + var hasLineTextColor; |
| 192 | + if (!isObject(value) || !hasOnlyKeys(value, FAVORITE_KEYS)) return false; | ||
| 193 | + hasLineMode = Object.prototype.hasOwnProperty.call(value, "lineMode"); | ||
| 194 | + hasLineColor = Object.prototype.hasOwnProperty.call(value, "lineColor"); | ||
| 195 | + hasLineTextColor = Object.prototype.hasOwnProperty.call(value, "lineTextColor"); | ||
| 196 | + if (hasLineMode !== hasLineColor || hasLineMode !== hasLineTextColor) return false; | ||
| 197 | + return value.schemaVersion === SCHEMA_VERSION | ||
| 184 | && boundedString(value.id, LIMITS.idUtf8Bytes) | 198 | && boundedString(value.id, LIMITS.idUtf8Bytes) |
| 185 | && boundedString(value.serviceId, LIMITS.idUtf8Bytes) | 199 | && boundedString(value.serviceId, LIMITS.idUtf8Bytes) |
| 186 | && (typeof value.displayName === "undefined" || boundedString(value.displayName, LIMITS.labelUtf8Bytes)) | 200 | && (typeof value.displayName === "undefined" || boundedString(value.displayName, LIMITS.labelUtf8Bytes)) |
| 187 | && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes) | 201 | && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes) |
| 188 | && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes) | 202 | && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes) |
| 189 | && boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes) | 203 | && boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes) |
| 204 | + && (!hasLineMode || ( | ||
| 205 | + TRANSPORT_MODE.indexOf(value.lineMode) !== -1 | ||
| 206 | + && isLineColor(value.lineColor) | ||
| 207 | + && isLineColor(value.lineTextColor) | ||
| 208 | + )) | ||
| 190 | && typeof value.sortOrder === "number" | 209 | && typeof value.sortOrder === "number" |
| 191 | && Math.floor(value.sortOrder) === value.sortOrder | 210 | && Math.floor(value.sortOrder) === value.sortOrder |
| 192 | && value.sortOrder >= 0 | 211 | && value.sortOrder >= 0 |
| @@ -262,9 +281,14 @@ function copyFavorite(favorite) { | |||
| 262 | serviceId: favorite.serviceId, | 281 | serviceId: favorite.serviceId, |
| 263 | stopLabel: favorite.stopLabel, | 282 | stopLabel: favorite.stopLabel, |
| 264 | lineLabel: favorite.lineLabel, | 283 | lineLabel: favorite.lineLabel, |
| 265 | - destinationLabel: favorite.destinationLabel, | 284 | + destinationLabel: favorite.destinationLabel |
| 266 | - sortOrder: favorite.sortOrder | ||
| 267 | }; | 285 | }; |
| 286 | + if (Object.prototype.hasOwnProperty.call(favorite, "lineMode")) { | ||
| 287 | + copy.lineMode = favorite.lineMode; | ||
| 288 | + copy.lineColor = favorite.lineColor; | ||
| 289 | + copy.lineTextColor = favorite.lineTextColor; | ||
| 290 | + } | ||
| 291 | + copy.sortOrder = favorite.sortOrder; | ||
| 268 | if (typeof favorite.displayName !== "undefined") copy.displayName = favorite.displayName; | 292 | if (typeof favorite.displayName !== "undefined") copy.displayName = favorite.displayName; |
| 269 | return copy; | 293 | return copy; |
| 270 | } | 294 | } |
| @@ -301,6 +325,7 @@ module.exports = { | |||
| 301 | FRESHNESS: FRESHNESS, | 325 | FRESHNESS: FRESHNESS, |
| 302 | DEPARTURE_STATUS: DEPARTURE_STATUS, | 326 | DEPARTURE_STATUS: DEPARTURE_STATUS, |
| 303 | ERROR_CODE: ERROR_CODE, | 327 | ERROR_CODE: ERROR_CODE, |
| 328 | + TRANSPORT_MODE: TRANSPORT_MODE, | ||
| 304 | API_KEY_ACTION: API_KEY_ACTION, | 329 | API_KEY_ACTION: API_KEY_ACTION, |
| 305 | KEY_STATUS: KEY_STATUS, | 330 | KEY_STATUS: KEY_STATUS, |
| 306 | REQUEST_TRIGGER: REQUEST_TRIGGER, | 331 | REQUEST_TRIGGER: REQUEST_TRIGGER, |
| @@ -313,6 +338,7 @@ module.exports = { | |||
| 313 | isObject: isObject, | 338 | isObject: isObject, |
| 314 | hasOnlyKeys: hasOnlyKeys, | 339 | hasOnlyKeys: hasOnlyKeys, |
| 315 | boundedString: boundedString, | 340 | boundedString: boundedString, |
| 341 | + isLineColor: isLineColor, | ||
| 316 | isFavorite: isFavorite, | 342 | isFavorite: isFavorite, |
| 317 | isFavoriteList: isFavoriteList, | 343 | isFavoriteList: isFavoriteList, |
| 318 | isDeparture: isDeparture, | 344 | isDeparture: isDeparture, |
modified
packages/companion/test/companion.test.js +28 -0 | @@ -126,6 +126,34 @@ test("close fragment atomically stores one favorites-and-key record and exposes | ||
| 126 | 126 | target.xhr.instances[0].respond(200, responseFor("secret-boundary")); |
| 127 | 127 | assert.equal(JSON.stringify(target.Pebble.sent).includes(TEST_KEY), false); |
| 128 | 128 | }); |
| 129 | +test("a pre-presentation v1 record keeps its API key and legacy favorite on load", function () { | |
| 130 | + var storage = new fakes.FakeStorage(); | |
| 131 | + var legacyRecord = { | |
| 132 | + schemaVersion: contracts.SCHEMA_VERSION, | |
| 133 | + favorites: [{ | |
| 134 | + schemaVersion: contracts.SCHEMA_VERSION, | |
| 135 | + id: "legacy-home", | |
| 136 | + serviceId: "opaque:legacy-service", | |
| 137 | + displayName: "Maison", | |
| 138 | + stopLabel: "Châtelet", | |
| 139 | + lineLabel: "1", | |
| 140 | + destinationLabel: "La Défense", | |
| 141 | + sortOrder: 0 | |
| 142 | + }], | |
| 143 | + apiKey: TEST_KEY | |
| 144 | + }; | |
| 145 | + var loaded; | |
| 146 | + | |
| 147 | + storage.setItem(configuration.STORAGE_KEY, JSON.stringify(legacyRecord)); | |
| 148 | + loaded = configuration.loadConfiguration(storage); | |
| 149 | + | |
| 150 | + assert.deepEqual(loaded, legacyRecord); | |
| 151 | + assert.equal(loaded.apiKey, TEST_KEY); | |
| 152 | + assert.equal(loaded.favorites.length, 1); | |
| 153 | + ["lineMode", "lineColor", "lineTextColor"].forEach(function (field) { | |
| 154 | + assert.equal(Object.prototype.hasOwnProperty.call(loaded.favorites[0], field), false); | |
| 155 | + }); | |
| 156 | +}); | |
| 129 | 157 | test("configuration opening uses the active watch language and safely falls back to English", function () { |
| 130 | 158 | var target = harness(); |
| 131 | 159 | var state; |
| @@ -126,6 +126,34 @@ test("close fragment atomically stores one favorites-and-key record and exposes | |||
| 126 | target.xhr.instances[0].respond(200, responseFor("secret-boundary")); | 126 | target.xhr.instances[0].respond(200, responseFor("secret-boundary")); |
| 127 | assert.equal(JSON.stringify(target.Pebble.sent).includes(TEST_KEY), false); | 127 | assert.equal(JSON.stringify(target.Pebble.sent).includes(TEST_KEY), false); |
| 128 | }); | 128 | }); |
| 129 | +test("a pre-presentation v1 record keeps its API key and legacy favorite on load", function () { | ||
| 130 | + var storage = new fakes.FakeStorage(); | ||
| 131 | + var legacyRecord = { | ||
| 132 | + schemaVersion: contracts.SCHEMA_VERSION, | ||
| 133 | + favorites: [{ | ||
| 134 | + schemaVersion: contracts.SCHEMA_VERSION, | ||
| 135 | + id: "legacy-home", | ||
| 136 | + serviceId: "opaque:legacy-service", | ||
| 137 | + displayName: "Maison", | ||
| 138 | + stopLabel: "Châtelet", | ||
| 139 | + lineLabel: "1", | ||
| 140 | + destinationLabel: "La Défense", | ||
| 141 | + sortOrder: 0 | ||
| 142 | + }], | ||
| 143 | + apiKey: TEST_KEY | ||
| 144 | + }; | ||
| 145 | + var loaded; | ||
| 146 | + | ||
| 147 | + storage.setItem(configuration.STORAGE_KEY, JSON.stringify(legacyRecord)); | ||
| 148 | + loaded = configuration.loadConfiguration(storage); | ||
| 149 | + | ||
| 150 | + assert.deepEqual(loaded, legacyRecord); | ||
| 151 | + assert.equal(loaded.apiKey, TEST_KEY); | ||
| 152 | + assert.equal(loaded.favorites.length, 1); | ||
| 153 | + ["lineMode", "lineColor", "lineTextColor"].forEach(function (field) { | ||
| 154 | + assert.equal(Object.prototype.hasOwnProperty.call(loaded.favorites[0], field), false); | ||
| 155 | + }); | ||
| 156 | +}); | ||
| 129 | test("configuration opening uses the active watch language and safely falls back to English", function () { | 157 | test("configuration opening uses the active watch language and safely falls back to English", function () { |
| 130 | var target = harness(); | 158 | var target = harness(); |
| 131 | var state; | 159 | var state; |
modified
packages/companion/test/protocol.test.js +39 -0 | @@ -23,6 +23,7 @@ test("PebbleKit constants exactly mirror the canonical alias and numeric contrac | ||
| 23 | 23 | assert.deepEqual(contracts.MESSAGE_TYPE, shared.MESSAGE_TYPE); |
| 24 | 24 | assert.deepEqual(contracts.KEY_STATUS, shared.KEY_STATUS); |
| 25 | 25 | assert.deepEqual(contracts.REQUEST_TRIGGER, shared.REQUEST_TRIGGER); |
| 26 | + assert.deepEqual(contracts.TRANSPORT_MODE, shared.TRANSPORT_MODE); | |
| 26 | 27 | assert.deepEqual(contracts.WIRE_LANGUAGE, shared.WIRE_LANGUAGE); |
| 27 | 28 | assert.deepEqual(contracts.LIMITS, shared.LIMITS); |
| 28 | 29 | assert.equal(contracts.SCHEMA_VERSION, shared.SCHEMA_VERSION); |
| @@ -30,6 +31,44 @@ test("PebbleKit constants exactly mirror the canonical alias and numeric contrac | ||
| 30 | 31 | assert.equal(contracts.FAVORITE_SETTLE_MS, shared.FAVORITE_SETTLE_MS); |
| 31 | 32 | }); |
| 32 | 33 | |
| 34 | +test("presentation fields form an optional complete group and remain outside AppMessage", function () { | |
| 35 | + var copied = contracts.copyFavorite(fixture.favorite); | |
| 36 | + var legacy; | |
| 37 | + var legacyCopy; | |
| 38 | + var presentationFields = ["lineMode", "lineColor", "lineTextColor"]; | |
| 39 | + assert.deepEqual(copied, fixture.favorite); | |
| 40 | + assert.equal(contracts.isFavorite(copied), true); | |
| 41 | + assert.equal(contracts.isFavorite(Object.assign({}, copied, { lineColor: "#FFBE00" })), false); | |
| 42 | + assert.equal(contracts.isFavorite(Object.assign({}, copied, { lineMode: "metro" })), false); | |
| 43 | + delete copied.lineTextColor; | |
| 44 | + assert.equal(contracts.isFavorite(copied), false); | |
| 45 | + | |
| 46 | + legacy = contracts.copyFavorite(fixture.favorite); | |
| 47 | + presentationFields.forEach(function (field) { delete legacy[field]; }); | |
| 48 | + assert.equal(contracts.isFavorite(legacy), true); | |
| 49 | + assert.equal(contracts.isFavorite(Object.assign({}, legacy, { lineMode: "METRO" })), false); | |
| 50 | + assert.equal(contracts.isFavorite(Object.assign({}, legacy, { | |
| 51 | + lineMode: "METRO", | |
| 52 | + lineColor: "#ffbe00" | |
| 53 | + })), false); | |
| 54 | + legacyCopy = contracts.copyFavorite(legacy); | |
| 55 | + assert.deepEqual(legacyCopy, legacy); | |
| 56 | + presentationFields.forEach(function (field) { | |
| 57 | + assert.equal(Object.prototype.hasOwnProperty.call(legacyCopy, field), false); | |
| 58 | + }); | |
| 59 | + | |
| 60 | + assert.equal(codec.encodeConfiguration( | |
| 61 | + "presentation-wire", | |
| 62 | + [fixture.favorite], | |
| 63 | + contracts.KEY_STATUS.CONFIGURED, | |
| 64 | + "en" | |
| 65 | + ).some(function (message) { | |
| 66 | + return Object.prototype.hasOwnProperty.call(message, "lineMode") | |
| 67 | + || Object.prototype.hasOwnProperty.call(message, "lineColor") | |
| 68 | + || Object.prototype.hasOwnProperty.call(message, "lineTextColor"); | |
| 69 | + }), false); | |
| 70 | +}); | |
| 71 | + | |
| 33 | 72 | test("recorded fixture round trips through symbolic configuration and result payloads", async function () { |
| 34 | 73 | var shared = await sharedContracts(); |
| 35 | 74 | var receiver = new shared.ProtocolReceiver(); |
| @@ -23,6 +23,7 @@ test("PebbleKit constants exactly mirror the canonical alias and numeric contrac | |||
| 23 | assert.deepEqual(contracts.MESSAGE_TYPE, shared.MESSAGE_TYPE); | 23 | assert.deepEqual(contracts.MESSAGE_TYPE, shared.MESSAGE_TYPE); |
| 24 | assert.deepEqual(contracts.KEY_STATUS, shared.KEY_STATUS); | 24 | assert.deepEqual(contracts.KEY_STATUS, shared.KEY_STATUS); |
| 25 | assert.deepEqual(contracts.REQUEST_TRIGGER, shared.REQUEST_TRIGGER); | 25 | assert.deepEqual(contracts.REQUEST_TRIGGER, shared.REQUEST_TRIGGER); |
| 26 | + assert.deepEqual(contracts.TRANSPORT_MODE, shared.TRANSPORT_MODE); | ||
| 26 | assert.deepEqual(contracts.WIRE_LANGUAGE, shared.WIRE_LANGUAGE); | 27 | assert.deepEqual(contracts.WIRE_LANGUAGE, shared.WIRE_LANGUAGE); |
| 27 | assert.deepEqual(contracts.LIMITS, shared.LIMITS); | 28 | assert.deepEqual(contracts.LIMITS, shared.LIMITS); |
| 28 | assert.equal(contracts.SCHEMA_VERSION, shared.SCHEMA_VERSION); | 29 | assert.equal(contracts.SCHEMA_VERSION, shared.SCHEMA_VERSION); |
| @@ -30,6 +31,44 @@ test("PebbleKit constants exactly mirror the canonical alias and numeric contrac | |||
| 30 | assert.equal(contracts.FAVORITE_SETTLE_MS, shared.FAVORITE_SETTLE_MS); | 31 | assert.equal(contracts.FAVORITE_SETTLE_MS, shared.FAVORITE_SETTLE_MS); |
| 31 | }); | 32 | }); |
| 32 | 33 | ||
| 34 | +test("presentation fields form an optional complete group and remain outside AppMessage", function () { | ||
| 35 | + var copied = contracts.copyFavorite(fixture.favorite); | ||
| 36 | + var legacy; | ||
| 37 | + var legacyCopy; | ||
| 38 | + var presentationFields = ["lineMode", "lineColor", "lineTextColor"]; | ||
| 39 | + assert.deepEqual(copied, fixture.favorite); | ||
| 40 | + assert.equal(contracts.isFavorite(copied), true); | ||
| 41 | + assert.equal(contracts.isFavorite(Object.assign({}, copied, { lineColor: "#FFBE00" })), false); | ||
| 42 | + assert.equal(contracts.isFavorite(Object.assign({}, copied, { lineMode: "metro" })), false); | ||
| 43 | + delete copied.lineTextColor; | ||
| 44 | + assert.equal(contracts.isFavorite(copied), false); | ||
| 45 | + | ||
| 46 | + legacy = contracts.copyFavorite(fixture.favorite); | ||
| 47 | + presentationFields.forEach(function (field) { delete legacy[field]; }); | ||
| 48 | + assert.equal(contracts.isFavorite(legacy), true); | ||
| 49 | + assert.equal(contracts.isFavorite(Object.assign({}, legacy, { lineMode: "METRO" })), false); | ||
| 50 | + assert.equal(contracts.isFavorite(Object.assign({}, legacy, { | ||
| 51 | + lineMode: "METRO", | ||
| 52 | + lineColor: "#ffbe00" | ||
| 53 | + })), false); | ||
| 54 | + legacyCopy = contracts.copyFavorite(legacy); | ||
| 55 | + assert.deepEqual(legacyCopy, legacy); | ||
| 56 | + presentationFields.forEach(function (field) { | ||
| 57 | + assert.equal(Object.prototype.hasOwnProperty.call(legacyCopy, field), false); | ||
| 58 | + }); | ||
| 59 | + | ||
| 60 | + assert.equal(codec.encodeConfiguration( | ||
| 61 | + "presentation-wire", | ||
| 62 | + [fixture.favorite], | ||
| 63 | + contracts.KEY_STATUS.CONFIGURED, | ||
| 64 | + "en" | ||
| 65 | + ).some(function (message) { | ||
| 66 | + return Object.prototype.hasOwnProperty.call(message, "lineMode") | ||
| 67 | + || Object.prototype.hasOwnProperty.call(message, "lineColor") | ||
| 68 | + || Object.prototype.hasOwnProperty.call(message, "lineTextColor"); | ||
| 69 | + }), false); | ||
| 70 | +}); | ||
| 71 | + | ||
| 33 | test("recorded fixture round trips through symbolic configuration and result payloads", async function () { | 72 | test("recorded fixture round trips through symbolic configuration and result payloads", async function () { |
| 34 | var shared = await sharedContracts(); | 73 | var shared = await sharedContracts(); |
| 35 | var receiver = new shared.ProtocolReceiver(); | 74 | var receiver = new shared.ProtocolReceiver(); |
added
packages/config-page/assets/line-badges/attribution.json +371 -0 | new file mode 100644 | ||
| @@ -0,0 +1,371 @@ | ||
| 1 | +{ | |
| 2 | + "publisher": "Île-de-France Mobilités", | |
| 3 | + "dataset": "referentiel-des-lignes", | |
| 4 | + "datasetUrl": "https://data.iledefrance-mobilites.fr/explore/dataset/referentiel-des-lignes/", | |
| 5 | + "datasetLicense": "ODbL 1.0", | |
| 6 | + "datasetLicenseUrl": "https://opendatacommons.org/licenses/odbl/1-0/", | |
| 7 | + "pictogramReuseTermsUrl": "https://prim.iledefrance-mobilites.fr/fr/conditions-utilisation", | |
| 8 | + "retrievedAt": "2026-09-01T18:23:34.819Z", | |
| 9 | + "assets": [ | |
| 10 | + { | |
| 11 | + "key": "METRO:1", | |
| 12 | + "file": "metro-1.png", | |
| 13 | + "mediaType": "image/png", | |
| 14 | + "bytes": 33966, | |
| 15 | + "sha256": "1e0240ea3bf8ab22b1a53eb3a783c09893f2d39e309406338ded0ca0c0d90cdc", | |
| 16 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/8e63c8180c0378991029ec5141e4c6a5" | |
| 17 | + }, | |
| 18 | + { | |
| 19 | + "key": "METRO:10", | |
| 20 | + "file": "metro-10.png", | |
| 21 | + "mediaType": "image/png", | |
| 22 | + "bytes": 51782, | |
| 23 | + "sha256": "c23ab314b5e9f1c82e63def23e15f1997e381152d2a886ee787aa139f555c726", | |
| 24 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/4adaa198c6e4212d9f8bfe0e744ba857" | |
| 25 | + }, | |
| 26 | + { | |
| 27 | + "key": "METRO:11", | |
| 28 | + "file": "metro-11.png", | |
| 29 | + "mediaType": "image/png", | |
| 30 | + "bytes": 38077, | |
| 31 | + "sha256": "7c83c903307ee1dc61c09567ac435336b72bb88a1466d5d2338839bb66d744c4", | |
| 32 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/091275f9a999a893de5eb4703e1a74af" | |
| 33 | + }, | |
| 34 | + { | |
| 35 | + "key": "METRO:12", | |
| 36 | + "file": "metro-12.png", | |
| 37 | + "mediaType": "image/png", | |
| 38 | + "bytes": 48009, | |
| 39 | + "sha256": "4172b39cb56654de68d9022127432f4bc336d7df953a448a6a034c0fc823570a", | |
| 40 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/68749e9f1ce05a0a4cae6610f95eecc0" | |
| 41 | + }, | |
| 42 | + { | |
| 43 | + "key": "METRO:13", | |
| 44 | + "file": "metro-13.png", | |
| 45 | + "mediaType": "image/png", | |
| 46 | + "bytes": 52718, | |
| 47 | + "sha256": "3645c8b3265ebb6c62ba17938a336793d9835f768b7441a6e7cd9dc9805a298d", | |
| 48 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/0b8a36241880cd49a78f24dbaca0433b" | |
| 49 | + }, | |
| 50 | + { | |
| 51 | + "key": "METRO:14", | |
| 52 | + "file": "metro-14.png", | |
| 53 | + "mediaType": "image/png", | |
| 54 | + "bytes": 41282, | |
| 55 | + "sha256": "87aba0a0f3f0771e03fdf4e4f1bf1ba94a0dbc9fb52e8f18834993020b293aba", | |
| 56 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/7b99df056fae694cd8d96c160696d757" | |
| 57 | + }, | |
| 58 | + { | |
| 59 | + "key": "METRO:2", | |
| 60 | + "file": "metro-2.png", | |
| 61 | + "mediaType": "image/png", | |
| 62 | + "bytes": 43580, | |
| 63 | + "sha256": "65502e82afc684865cf37ac99d3915c04ea392d93e9d6f680a442a7a5e67e58a", | |
| 64 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/cdaa0aa338d1acbd2e9d31fe21fc6eb7" | |
| 65 | + }, | |
| 66 | + { | |
| 67 | + "key": "METRO:3", | |
| 68 | + "file": "metro-3.png", | |
| 69 | + "mediaType": "image/png", | |
| 70 | + "bytes": 48270, | |
| 71 | + "sha256": "2c9ab4b4ffbafff9ac835495dc39d4cc60095523399c78d42e298717cdf05065", | |
| 72 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/418ea5fcc3bf22ac253c1aafcd531684" | |
| 73 | + }, | |
| 74 | + { | |
| 75 | + "key": "METRO:3B", | |
| 76 | + "file": "metro-3b.png", | |
| 77 | + "mediaType": "image/png", | |
| 78 | + "bytes": 59992, | |
| 79 | + "sha256": "139529c4437e29869b6769ac4ea90f9c2a9599f4ca51f63a9ff9f52fcc477b6c", | |
| 80 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/157da4ee0a222feb976fc9acc40c568b" | |
| 81 | + }, | |
| 82 | + { | |
| 83 | + "key": "METRO:4", | |
| 84 | + "file": "metro-4.png", | |
| 85 | + "mediaType": "image/png", | |
| 86 | + "bytes": 36581, | |
| 87 | + "sha256": "d0fa50af73e9d73c93d61c97c69b5416db588e25bd5f85a80479c88c55e8587c", | |
| 88 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/827ff49af57d362f6a7c5f2d1022528c" | |
| 89 | + }, | |
| 90 | + { | |
| 91 | + "key": "METRO:5", | |
| 92 | + "file": "metro-5.png", | |
| 93 | + "mediaType": "image/png", | |
| 94 | + "bytes": 43267, | |
| 95 | + "sha256": "73fa33f11b95cdd931f7061e848b3e72d6a0f66efe065f438266666497ae5a73", | |
| 96 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/e1babbb11951e80ae32d076528ef2ad1" | |
| 97 | + }, | |
| 98 | + { | |
| 99 | + "key": "METRO:6", | |
| 100 | + "file": "metro-6.png", | |
| 101 | + "mediaType": "image/png", | |
| 102 | + "bytes": 49686, | |
| 103 | + "sha256": "1f48b1c52a376919ec8cfc1e16dcf28aa4c49bdac013d3e52864f8eb97c62df5", | |
| 104 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/11ba6d8a602ec0780fe8c01aa58b542d" | |
| 105 | + }, | |
| 106 | + { | |
| 107 | + "key": "METRO:7", | |
| 108 | + "file": "metro-7.png", | |
| 109 | + "mediaType": "image/png", | |
| 110 | + "bytes": 39420, | |
| 111 | + "sha256": "3134cefd49203d075280f9abbf6fdb980b15c0852aadbbfe64ce780d7a81922b", | |
| 112 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/b29f81837830b3a14fb876aaecc20e9c" | |
| 113 | + }, | |
| 114 | + { | |
| 115 | + "key": "METRO:7B", | |
| 116 | + "file": "metro-7b.png", | |
| 117 | + "mediaType": "image/png", | |
| 118 | + "bytes": 50468, | |
| 119 | + "sha256": "8b07f29257e0ad136d9f72fac7f1cb0c8a240127f554badee5ce24b34727da1c", | |
| 120 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/e08d6e9d99727bb7d55ffc5c44801f72" | |
| 121 | + }, | |
| 122 | + { | |
| 123 | + "key": "METRO:8", | |
| 124 | + "file": "metro-8.png", | |
| 125 | + "mediaType": "image/png", | |
| 126 | + "bytes": 53640, | |
| 127 | + "sha256": "ae9a294865058c409ec163677c1d2948b53d43220670238f3a2e692d99dfcfdc", | |
| 128 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/6dc2bda2dcd7cffbde8cf5a6c194ba59" | |
| 129 | + }, | |
| 130 | + { | |
| 131 | + "key": "METRO:9", | |
| 132 | + "file": "metro-9.png", | |
| 133 | + "mediaType": "image/png", | |
| 134 | + "bytes": 49024, | |
| 135 | + "sha256": "00a5b3708bede481c1c52f07d2cee56936da7f9cd5df5aeec4abe402433281f0", | |
| 136 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/5fe858163d92581d16bcab8aba61f26a" | |
| 137 | + }, | |
| 138 | + { | |
| 139 | + "key": "RER:A", | |
| 140 | + "file": "rer-a.png", | |
| 141 | + "mediaType": "image/png", | |
| 142 | + "bytes": 31606, | |
| 143 | + "sha256": "e85f106d7abf214124472f77b4dbb4114cbb8f827b6920f103d26b4054c339bf", | |
| 144 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/4049307fabd1b0db3fc129d52fbf0b76" | |
| 145 | + }, | |
| 146 | + { | |
| 147 | + "key": "RER:B", | |
| 148 | + "file": "rer-b.png", | |
| 149 | + "mediaType": "image/png", | |
| 150 | + "bytes": 30315, | |
| 151 | + "sha256": "b0e4cc26bba92f7acaf82cabf4392b3d7f12a6196adeb9b38f19ee4093e64d3d", | |
| 152 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/09417158011567b7a8165357e51e565d" | |
| 153 | + }, | |
| 154 | + { | |
| 155 | + "key": "RER:C", | |
| 156 | + "file": "rer-c.png", | |
| 157 | + "mediaType": "image/png", | |
| 158 | + "bytes": 34780, | |
| 159 | + "sha256": "f054f2a23204f2b44e5d263eb99987eaa6a922e1c4002240646f7edd18d86b24", | |
| 160 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/099e5a6dd7b15cfbd61d1e11c3d18fb6" | |
| 161 | + }, | |
| 162 | + { | |
| 163 | + "key": "RER:D", | |
| 164 | + "file": "rer-d.png", | |
| 165 | + "mediaType": "image/png", | |
| 166 | + "bytes": 30691, | |
| 167 | + "sha256": "acee82a2e5d4967cb2ecf91ed7f55ba928158524a737834b8cef9915430bb3ae", | |
| 168 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/b843a92141919dab06ea02ceddfbc9c3" | |
| 169 | + }, | |
| 170 | + { | |
| 171 | + "key": "RER:E", | |
| 172 | + "file": "rer-e.png", | |
| 173 | + "mediaType": "image/png", | |
| 174 | + "bytes": 18443, | |
| 175 | + "sha256": "2b67ceb254808ddb6d80e381f0761ac8fa250dcb08dc2b68a8e481ff602e6978", | |
| 176 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/dc5bd03a80dd0c1637c29dd4a38b14bf" | |
| 177 | + }, | |
| 178 | + { | |
| 179 | + "key": "TRAM:T1", | |
| 180 | + "file": "tram-t1.png", | |
| 181 | + "mediaType": "image/png", | |
| 182 | + "bytes": 15353, | |
| 183 | + "sha256": "77b24092bf61b86ba53c21ae2982ef2791d6dd25bb5c7fbb2ed8823525f01524", | |
| 184 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/f08f0e7ac27b864d4b38160465794b0f" | |
| 185 | + }, | |
| 186 | + { | |
| 187 | + "key": "TRAM:T10", | |
| 188 | + "file": "tram-t10.png", | |
| 189 | + "mediaType": "image/png", | |
| 190 | + "bytes": 25236, | |
| 191 | + "sha256": "5924c617177fe6dcbed626f301aeaccbc0e77f940477b4e2915d6df8902730e8", | |
| 192 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/0e6d747212fe8125acea04824c9de849" | |
| 193 | + }, | |
| 194 | + { | |
| 195 | + "key": "TRAM:T11", | |
| 196 | + "file": "tram-t11.png", | |
| 197 | + "mediaType": "image/png", | |
| 198 | + "bytes": 17305, | |
| 199 | + "sha256": "482f9cf8c1bab3dec0952cc7fbdf602a16a8677c2f913cdd8fa3e1764070633c", | |
| 200 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/b8957b0e036e5aa94716ecb7285bea30" | |
| 201 | + }, | |
| 202 | + { | |
| 203 | + "key": "TRAM:T12", | |
| 204 | + "file": "tram-t12.png", | |
| 205 | + "mediaType": "image/png", | |
| 206 | + "bytes": 22923, | |
| 207 | + "sha256": "5a0d884b7b18483967d24f4309d0bdab72006807b30bec18b293a2ca5d6d13fa", | |
| 208 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/9582f3df81d6e524d3a9065224cb75ca" | |
| 209 | + }, | |
| 210 | + { | |
| 211 | + "key": "TRAM:T13", | |
| 212 | + "file": "tram-t13.png", | |
| 213 | + "mediaType": "image/png", | |
| 214 | + "bytes": 24950, | |
| 215 | + "sha256": "8d6a3079095b36265c836d4022732dfc989f375ca874a57fd7177050ef49c5ae", | |
| 216 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/07d202b2a699910502ae61197977502d" | |
| 217 | + }, | |
| 218 | + { | |
| 219 | + "key": "TRAM:T14", | |
| 220 | + "file": "tram-t14.svg", | |
| 221 | + "mediaType": "image/svg+xml", | |
| 222 | + "bytes": 1183, | |
| 223 | + "sha256": "7c79d62ae30950fe638667f63b0ec62bec9bc18a171b1309ca996e670d3d9e25", | |
| 224 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/bfc31e2c9d3d13004836e5d5962c10e6" | |
| 225 | + }, | |
| 226 | + { | |
| 227 | + "key": "TRAM:T2", | |
| 228 | + "file": "tram-t2.png", | |
| 229 | + "mediaType": "image/png", | |
| 230 | + "bytes": 21271, | |
| 231 | + "sha256": "97ccd49e16fc98fae820ecdfb01005f196622c60c2988f74a37b706c6b00beaf", | |
| 232 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/e32a597dbda794251fe47f69308c01b9" | |
| 233 | + }, | |
| 234 | + { | |
| 235 | + "key": "TRAM:T3a", | |
| 236 | + "file": "tram-t3a.png", | |
| 237 | + "mediaType": "image/png", | |
| 238 | + "bytes": 28668, | |
| 239 | + "sha256": "c686053f63ec69698fb7b0bef9a61324d49d493a0050cbf6cd48978776f513dc", | |
| 240 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/9c354f0b1b03a22f88e6890606c961df" | |
| 241 | + }, | |
| 242 | + { | |
| 243 | + "key": "TRAM:T3b", | |
| 244 | + "file": "tram-t3b.png", | |
| 245 | + "mediaType": "image/png", | |
| 246 | + "bytes": 27564, | |
| 247 | + "sha256": "370599296cf062c8c04cb55eea1edf73021ba31bddb70ade0bbcc5dacd27f096", | |
| 248 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/919aee035abf4591de29f13988d63bc9" | |
| 249 | + }, | |
| 250 | + { | |
| 251 | + "key": "TRAM:T4", | |
| 252 | + "file": "tram-t4.png", | |
| 253 | + "mediaType": "image/png", | |
| 254 | + "bytes": 17145, | |
| 255 | + "sha256": "d9487770d9d2a9419963bd8b48c721ed3fc9c6e39db4432d11fadce73931c7b6", | |
| 256 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/c370af8b5511d975ebb6d62b5dc1b10f" | |
| 257 | + }, | |
| 258 | + { | |
| 259 | + "key": "TRAM:T5", | |
| 260 | + "file": "tram-t5.png", | |
| 261 | + "mediaType": "image/png", | |
| 262 | + "bytes": 20583, | |
| 263 | + "sha256": "64665e11b712aa5384fb4cc662093adf8e08c2266c3287bbe56dc80781864096", | |
| 264 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/96aee20f2627ccacff25a9bc23b96cbc" | |
| 265 | + }, | |
| 266 | + { | |
| 267 | + "key": "TRAM:T6", | |
| 268 | + "file": "tram-t6.png", | |
| 269 | + "mediaType": "image/png", | |
| 270 | + "bytes": 24061, | |
| 271 | + "sha256": "af50b1a10d0af1da7a54d439d781feceebbd1fcbfcac691197acc3d34b165cf1", | |
| 272 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/db63e7a26e27fc64926c696fe5d5a950" | |
| 273 | + }, | |
| 274 | + { | |
| 275 | + "key": "TRAM:T7", | |
| 276 | + "file": "tram-t7.png", | |
| 277 | + "mediaType": "image/png", | |
| 278 | + "bytes": 18211, | |
| 279 | + "sha256": "8266449656ade20af5d56d379a35ffe6e8247af46ff8f2aec5670daba152d21a", | |
| 280 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/5568fae6d1eb504a556118a6e8bd681a" | |
| 281 | + }, | |
| 282 | + { | |
| 283 | + "key": "TRAM:T8", | |
| 284 | + "file": "tram-t8.png", | |
| 285 | + "mediaType": "image/png", | |
| 286 | + "bytes": 25991, | |
| 287 | + "sha256": "83c23f278d3e342e5955e1c3ee51bad3aa8824bfa2ed7702b5c34adab65e893e", | |
| 288 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/abdb3df6ce90364cb3a504268a29d0ba" | |
| 289 | + }, | |
| 290 | + { | |
| 291 | + "key": "TRAM:T9", | |
| 292 | + "file": "tram-t9.png", | |
| 293 | + "mediaType": "image/png", | |
| 294 | + "bytes": 24134, | |
| 295 | + "sha256": "dbc1cca5a06dec194d648705e4557133eac7dcda1993b57cda6126553eb4cfd0", | |
| 296 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/ff7ac6917c6c0ef54bec9d3628571089" | |
| 297 | + }, | |
| 298 | + { | |
| 299 | + "key": "TRANSILIEN:H", | |
| 300 | + "file": "transilien-h.png", | |
| 301 | + "mediaType": "image/png", | |
| 302 | + "bytes": 18391, | |
| 303 | + "sha256": "2c7a89150337ec76aeb32c5811550045ca3251aabbd1267c22db90bcb28c3cbe", | |
| 304 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/b874ac42d638fc887ea98f56789a036d" | |
| 305 | + }, | |
| 306 | + { | |
| 307 | + "key": "TRANSILIEN:J", | |
| 308 | + "file": "transilien-j.png", | |
| 309 | + "mediaType": "image/png", | |
| 310 | + "bytes": 22673, | |
| 311 | + "sha256": "a4b5bca4eca6806d70235b3ef759c3577d4fc59023cad950219ef0961a06dce5", | |
| 312 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/62e9b7ac99c792f4b7ec85b226ad008e" | |
| 313 | + }, | |
| 314 | + { | |
| 315 | + "key": "TRANSILIEN:K", | |
| 316 | + "file": "transilien-k.png", | |
| 317 | + "mediaType": "image/png", | |
| 318 | + "bytes": 32390, | |
| 319 | + "sha256": "119970ae6b664529a45ad7bd2b6e01867644dd0065b22d714be832b333a84db7", | |
| 320 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/9ad21084ef4c6dce87364c6452422ef5" | |
| 321 | + }, | |
| 322 | + { | |
| 323 | + "key": "TRANSILIEN:L", | |
| 324 | + "file": "transilien-l.png", | |
| 325 | + "mediaType": "image/png", | |
| 326 | + "bytes": 18332, | |
| 327 | + "sha256": "eb7099756e43f4edb95a20e0c7fced32c50d052daef1eb6321850b8a92ce7885", | |
| 328 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/ae6ca077709c1256ae09d9954aa8ece8" | |
| 329 | + }, | |
| 330 | + { | |
| 331 | + "key": "TRANSILIEN:N", | |
| 332 | + "file": "transilien-n.png", | |
| 333 | + "mediaType": "image/png", | |
| 334 | + "bytes": 27252, | |
| 335 | + "sha256": "c897905fc430689b777e9382e9f1ff7ebadb13b55107581827aabca12fe619f6", | |
| 336 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/78201d213a8fc22a54bd4bb3e26cc339" | |
| 337 | + }, | |
| 338 | + { | |
| 339 | + "key": "TRANSILIEN:P", | |
| 340 | + "file": "transilien-p.png", | |
| 341 | + "mediaType": "image/png", | |
| 342 | + "bytes": 25981, | |
| 343 | + "sha256": "2f046dff23249f4b3f69496a7381bfdcf92ed2c9b76947b99552afb30ea40de6", | |
| 344 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/144bda900e4a01a6d3eb26d8e2d9803d" | |
| 345 | + }, | |
| 346 | + { | |
| 347 | + "key": "TRANSILIEN:R", | |
| 348 | + "file": "transilien-r.png", | |
| 349 | + "mediaType": "image/png", | |
| 350 | + "bytes": 31292, | |
| 351 | + "sha256": "1a00bbe0b53e8ea8abfffa5cf34d555142b8e148a781810b3548cd5a2dfeb0d3", | |
| 352 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/04ca3195d7cae3272e376492c8152900" | |
| 353 | + }, | |
| 354 | + { | |
| 355 | + "key": "TRANSILIEN:U", | |
| 356 | + "file": "transilien-u.png", | |
| 357 | + "mediaType": "image/png", | |
| 358 | + "bytes": 26980, | |
| 359 | + "sha256": "4c9cffcf66d23f710a86aa12cd2c569ffc4d7831f96475e4db4b188c8fece356", | |
| 360 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/04762929c9df4a9232485fc301b1fc40" | |
| 361 | + }, | |
| 362 | + { | |
| 363 | + "key": "TRANSILIEN:V", | |
| 364 | + "file": "transilien-v.png", | |
| 365 | + "mediaType": "image/png", | |
| 366 | + "bytes": 23917, | |
| 367 | + "sha256": "b10d5a61b68fe3651affd308920088dbcfe789181f88e14f4d32e881b9477900", | |
| 368 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/2f6517d91c45d6e415c7e624abaa8cc2" | |
| 369 | + } | |
| 370 | + ] | |
| 371 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,371 @@ | |||
| 1 | +{ | ||
| 2 | + "publisher": "Île-de-France Mobilités", | ||
| 3 | + "dataset": "referentiel-des-lignes", | ||
| 4 | + "datasetUrl": "https://data.iledefrance-mobilites.fr/explore/dataset/referentiel-des-lignes/", | ||
| 5 | + "datasetLicense": "ODbL 1.0", | ||
| 6 | + "datasetLicenseUrl": "https://opendatacommons.org/licenses/odbl/1-0/", | ||
| 7 | + "pictogramReuseTermsUrl": "https://prim.iledefrance-mobilites.fr/fr/conditions-utilisation", | ||
| 8 | + "retrievedAt": "2026-09-01T18:23:34.819Z", | ||
| 9 | + "assets": [ | ||
| 10 | + { | ||
| 11 | + "key": "METRO:1", | ||
| 12 | + "file": "metro-1.png", | ||
| 13 | + "mediaType": "image/png", | ||
| 14 | + "bytes": 33966, | ||
| 15 | + "sha256": "1e0240ea3bf8ab22b1a53eb3a783c09893f2d39e309406338ded0ca0c0d90cdc", | ||
| 16 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/8e63c8180c0378991029ec5141e4c6a5" | ||
| 17 | + }, | ||
| 18 | + { | ||
| 19 | + "key": "METRO:10", | ||
| 20 | + "file": "metro-10.png", | ||
| 21 | + "mediaType": "image/png", | ||
| 22 | + "bytes": 51782, | ||
| 23 | + "sha256": "c23ab314b5e9f1c82e63def23e15f1997e381152d2a886ee787aa139f555c726", | ||
| 24 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/4adaa198c6e4212d9f8bfe0e744ba857" | ||
| 25 | + }, | ||
| 26 | + { | ||
| 27 | + "key": "METRO:11", | ||
| 28 | + "file": "metro-11.png", | ||
| 29 | + "mediaType": "image/png", | ||
| 30 | + "bytes": 38077, | ||
| 31 | + "sha256": "7c83c903307ee1dc61c09567ac435336b72bb88a1466d5d2338839bb66d744c4", | ||
| 32 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/091275f9a999a893de5eb4703e1a74af" | ||
| 33 | + }, | ||
| 34 | + { | ||
| 35 | + "key": "METRO:12", | ||
| 36 | + "file": "metro-12.png", | ||
| 37 | + "mediaType": "image/png", | ||
| 38 | + "bytes": 48009, | ||
| 39 | + "sha256": "4172b39cb56654de68d9022127432f4bc336d7df953a448a6a034c0fc823570a", | ||
| 40 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/68749e9f1ce05a0a4cae6610f95eecc0" | ||
| 41 | + }, | ||
| 42 | + { | ||
| 43 | + "key": "METRO:13", | ||
| 44 | + "file": "metro-13.png", | ||
| 45 | + "mediaType": "image/png", | ||
| 46 | + "bytes": 52718, | ||
| 47 | + "sha256": "3645c8b3265ebb6c62ba17938a336793d9835f768b7441a6e7cd9dc9805a298d", | ||
| 48 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/0b8a36241880cd49a78f24dbaca0433b" | ||
| 49 | + }, | ||
| 50 | + { | ||
| 51 | + "key": "METRO:14", | ||
| 52 | + "file": "metro-14.png", | ||
| 53 | + "mediaType": "image/png", | ||
| 54 | + "bytes": 41282, | ||
| 55 | + "sha256": "87aba0a0f3f0771e03fdf4e4f1bf1ba94a0dbc9fb52e8f18834993020b293aba", | ||
| 56 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/7b99df056fae694cd8d96c160696d757" | ||
| 57 | + }, | ||
| 58 | + { | ||
| 59 | + "key": "METRO:2", | ||
| 60 | + "file": "metro-2.png", | ||
| 61 | + "mediaType": "image/png", | ||
| 62 | + "bytes": 43580, | ||
| 63 | + "sha256": "65502e82afc684865cf37ac99d3915c04ea392d93e9d6f680a442a7a5e67e58a", | ||
| 64 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/cdaa0aa338d1acbd2e9d31fe21fc6eb7" | ||
| 65 | + }, | ||
| 66 | + { | ||
| 67 | + "key": "METRO:3", | ||
| 68 | + "file": "metro-3.png", | ||
| 69 | + "mediaType": "image/png", | ||
| 70 | + "bytes": 48270, | ||
| 71 | + "sha256": "2c9ab4b4ffbafff9ac835495dc39d4cc60095523399c78d42e298717cdf05065", | ||
| 72 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/418ea5fcc3bf22ac253c1aafcd531684" | ||
| 73 | + }, | ||
| 74 | + { | ||
| 75 | + "key": "METRO:3B", | ||
| 76 | + "file": "metro-3b.png", | ||
| 77 | + "mediaType": "image/png", | ||
| 78 | + "bytes": 59992, | ||
| 79 | + "sha256": "139529c4437e29869b6769ac4ea90f9c2a9599f4ca51f63a9ff9f52fcc477b6c", | ||
| 80 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/157da4ee0a222feb976fc9acc40c568b" | ||
| 81 | + }, | ||
| 82 | + { | ||
| 83 | + "key": "METRO:4", | ||
| 84 | + "file": "metro-4.png", | ||
| 85 | + "mediaType": "image/png", | ||
| 86 | + "bytes": 36581, | ||
| 87 | + "sha256": "d0fa50af73e9d73c93d61c97c69b5416db588e25bd5f85a80479c88c55e8587c", | ||
| 88 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/827ff49af57d362f6a7c5f2d1022528c" | ||
| 89 | + }, | ||
| 90 | + { | ||
| 91 | + "key": "METRO:5", | ||
| 92 | + "file": "metro-5.png", | ||
| 93 | + "mediaType": "image/png", | ||
| 94 | + "bytes": 43267, | ||
| 95 | + "sha256": "73fa33f11b95cdd931f7061e848b3e72d6a0f66efe065f438266666497ae5a73", | ||
| 96 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/e1babbb11951e80ae32d076528ef2ad1" | ||
| 97 | + }, | ||
| 98 | + { | ||
| 99 | + "key": "METRO:6", | ||
| 100 | + "file": "metro-6.png", | ||
| 101 | + "mediaType": "image/png", | ||
| 102 | + "bytes": 49686, | ||
| 103 | + "sha256": "1f48b1c52a376919ec8cfc1e16dcf28aa4c49bdac013d3e52864f8eb97c62df5", | ||
| 104 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/11ba6d8a602ec0780fe8c01aa58b542d" | ||
| 105 | + }, | ||
| 106 | + { | ||
| 107 | + "key": "METRO:7", | ||
| 108 | + "file": "metro-7.png", | ||
| 109 | + "mediaType": "image/png", | ||
| 110 | + "bytes": 39420, | ||
| 111 | + "sha256": "3134cefd49203d075280f9abbf6fdb980b15c0852aadbbfe64ce780d7a81922b", | ||
| 112 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/b29f81837830b3a14fb876aaecc20e9c" | ||
| 113 | + }, | ||
| 114 | + { | ||
| 115 | + "key": "METRO:7B", | ||
| 116 | + "file": "metro-7b.png", | ||
| 117 | + "mediaType": "image/png", | ||
| 118 | + "bytes": 50468, | ||
| 119 | + "sha256": "8b07f29257e0ad136d9f72fac7f1cb0c8a240127f554badee5ce24b34727da1c", | ||
| 120 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/e08d6e9d99727bb7d55ffc5c44801f72" | ||
| 121 | + }, | ||
| 122 | + { | ||
| 123 | + "key": "METRO:8", | ||
| 124 | + "file": "metro-8.png", | ||
| 125 | + "mediaType": "image/png", | ||
| 126 | + "bytes": 53640, | ||
| 127 | + "sha256": "ae9a294865058c409ec163677c1d2948b53d43220670238f3a2e692d99dfcfdc", | ||
| 128 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/6dc2bda2dcd7cffbde8cf5a6c194ba59" | ||
| 129 | + }, | ||
| 130 | + { | ||
| 131 | + "key": "METRO:9", | ||
| 132 | + "file": "metro-9.png", | ||
| 133 | + "mediaType": "image/png", | ||
| 134 | + "bytes": 49024, | ||
| 135 | + "sha256": "00a5b3708bede481c1c52f07d2cee56936da7f9cd5df5aeec4abe402433281f0", | ||
| 136 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/5fe858163d92581d16bcab8aba61f26a" | ||
| 137 | + }, | ||
| 138 | + { | ||
| 139 | + "key": "RER:A", | ||
| 140 | + "file": "rer-a.png", | ||
| 141 | + "mediaType": "image/png", | ||
| 142 | + "bytes": 31606, | ||
| 143 | + "sha256": "e85f106d7abf214124472f77b4dbb4114cbb8f827b6920f103d26b4054c339bf", | ||
| 144 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/4049307fabd1b0db3fc129d52fbf0b76" | ||
| 145 | + }, | ||
| 146 | + { | ||
| 147 | + "key": "RER:B", | ||
| 148 | + "file": "rer-b.png", | ||
| 149 | + "mediaType": "image/png", | ||
| 150 | + "bytes": 30315, | ||
| 151 | + "sha256": "b0e4cc26bba92f7acaf82cabf4392b3d7f12a6196adeb9b38f19ee4093e64d3d", | ||
| 152 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/09417158011567b7a8165357e51e565d" | ||
| 153 | + }, | ||
| 154 | + { | ||
| 155 | + "key": "RER:C", | ||
| 156 | + "file": "rer-c.png", | ||
| 157 | + "mediaType": "image/png", | ||
| 158 | + "bytes": 34780, | ||
| 159 | + "sha256": "f054f2a23204f2b44e5d263eb99987eaa6a922e1c4002240646f7edd18d86b24", | ||
| 160 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/099e5a6dd7b15cfbd61d1e11c3d18fb6" | ||
| 161 | + }, | ||
| 162 | + { | ||
| 163 | + "key": "RER:D", | ||
| 164 | + "file": "rer-d.png", | ||
| 165 | + "mediaType": "image/png", | ||
| 166 | + "bytes": 30691, | ||
| 167 | + "sha256": "acee82a2e5d4967cb2ecf91ed7f55ba928158524a737834b8cef9915430bb3ae", | ||
| 168 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/b843a92141919dab06ea02ceddfbc9c3" | ||
| 169 | + }, | ||
| 170 | + { | ||
| 171 | + "key": "RER:E", | ||
| 172 | + "file": "rer-e.png", | ||
| 173 | + "mediaType": "image/png", | ||
| 174 | + "bytes": 18443, | ||
| 175 | + "sha256": "2b67ceb254808ddb6d80e381f0761ac8fa250dcb08dc2b68a8e481ff602e6978", | ||
| 176 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/dc5bd03a80dd0c1637c29dd4a38b14bf" | ||
| 177 | + }, | ||
| 178 | + { | ||
| 179 | + "key": "TRAM:T1", | ||
| 180 | + "file": "tram-t1.png", | ||
| 181 | + "mediaType": "image/png", | ||
| 182 | + "bytes": 15353, | ||
| 183 | + "sha256": "77b24092bf61b86ba53c21ae2982ef2791d6dd25bb5c7fbb2ed8823525f01524", | ||
| 184 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/f08f0e7ac27b864d4b38160465794b0f" | ||
| 185 | + }, | ||
| 186 | + { | ||
| 187 | + "key": "TRAM:T10", | ||
| 188 | + "file": "tram-t10.png", | ||
| 189 | + "mediaType": "image/png", | ||
| 190 | + "bytes": 25236, | ||
| 191 | + "sha256": "5924c617177fe6dcbed626f301aeaccbc0e77f940477b4e2915d6df8902730e8", | ||
| 192 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/0e6d747212fe8125acea04824c9de849" | ||
| 193 | + }, | ||
| 194 | + { | ||
| 195 | + "key": "TRAM:T11", | ||
| 196 | + "file": "tram-t11.png", | ||
| 197 | + "mediaType": "image/png", | ||
| 198 | + "bytes": 17305, | ||
| 199 | + "sha256": "482f9cf8c1bab3dec0952cc7fbdf602a16a8677c2f913cdd8fa3e1764070633c", | ||
| 200 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/b8957b0e036e5aa94716ecb7285bea30" | ||
| 201 | + }, | ||
| 202 | + { | ||
| 203 | + "key": "TRAM:T12", | ||
| 204 | + "file": "tram-t12.png", | ||
| 205 | + "mediaType": "image/png", | ||
| 206 | + "bytes": 22923, | ||
| 207 | + "sha256": "5a0d884b7b18483967d24f4309d0bdab72006807b30bec18b293a2ca5d6d13fa", | ||
| 208 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/9582f3df81d6e524d3a9065224cb75ca" | ||
| 209 | + }, | ||
| 210 | + { | ||
| 211 | + "key": "TRAM:T13", | ||
| 212 | + "file": "tram-t13.png", | ||
| 213 | + "mediaType": "image/png", | ||
| 214 | + "bytes": 24950, | ||
| 215 | + "sha256": "8d6a3079095b36265c836d4022732dfc989f375ca874a57fd7177050ef49c5ae", | ||
| 216 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/07d202b2a699910502ae61197977502d" | ||
| 217 | + }, | ||
| 218 | + { | ||
| 219 | + "key": "TRAM:T14", | ||
| 220 | + "file": "tram-t14.svg", | ||
| 221 | + "mediaType": "image/svg+xml", | ||
| 222 | + "bytes": 1183, | ||
| 223 | + "sha256": "7c79d62ae30950fe638667f63b0ec62bec9bc18a171b1309ca996e670d3d9e25", | ||
| 224 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/bfc31e2c9d3d13004836e5d5962c10e6" | ||
| 225 | + }, | ||
| 226 | + { | ||
| 227 | + "key": "TRAM:T2", | ||
| 228 | + "file": "tram-t2.png", | ||
| 229 | + "mediaType": "image/png", | ||
| 230 | + "bytes": 21271, | ||
| 231 | + "sha256": "97ccd49e16fc98fae820ecdfb01005f196622c60c2988f74a37b706c6b00beaf", | ||
| 232 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/e32a597dbda794251fe47f69308c01b9" | ||
| 233 | + }, | ||
| 234 | + { | ||
| 235 | + "key": "TRAM:T3a", | ||
| 236 | + "file": "tram-t3a.png", | ||
| 237 | + "mediaType": "image/png", | ||
| 238 | + "bytes": 28668, | ||
| 239 | + "sha256": "c686053f63ec69698fb7b0bef9a61324d49d493a0050cbf6cd48978776f513dc", | ||
| 240 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/9c354f0b1b03a22f88e6890606c961df" | ||
| 241 | + }, | ||
| 242 | + { | ||
| 243 | + "key": "TRAM:T3b", | ||
| 244 | + "file": "tram-t3b.png", | ||
| 245 | + "mediaType": "image/png", | ||
| 246 | + "bytes": 27564, | ||
| 247 | + "sha256": "370599296cf062c8c04cb55eea1edf73021ba31bddb70ade0bbcc5dacd27f096", | ||
| 248 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/919aee035abf4591de29f13988d63bc9" | ||
| 249 | + }, | ||
| 250 | + { | ||
| 251 | + "key": "TRAM:T4", | ||
| 252 | + "file": "tram-t4.png", | ||
| 253 | + "mediaType": "image/png", | ||
| 254 | + "bytes": 17145, | ||
| 255 | + "sha256": "d9487770d9d2a9419963bd8b48c721ed3fc9c6e39db4432d11fadce73931c7b6", | ||
| 256 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/c370af8b5511d975ebb6d62b5dc1b10f" | ||
| 257 | + }, | ||
| 258 | + { | ||
| 259 | + "key": "TRAM:T5", | ||
| 260 | + "file": "tram-t5.png", | ||
| 261 | + "mediaType": "image/png", | ||
| 262 | + "bytes": 20583, | ||
| 263 | + "sha256": "64665e11b712aa5384fb4cc662093adf8e08c2266c3287bbe56dc80781864096", | ||
| 264 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/96aee20f2627ccacff25a9bc23b96cbc" | ||
| 265 | + }, | ||
| 266 | + { | ||
| 267 | + "key": "TRAM:T6", | ||
| 268 | + "file": "tram-t6.png", | ||
| 269 | + "mediaType": "image/png", | ||
| 270 | + "bytes": 24061, | ||
| 271 | + "sha256": "af50b1a10d0af1da7a54d439d781feceebbd1fcbfcac691197acc3d34b165cf1", | ||
| 272 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/db63e7a26e27fc64926c696fe5d5a950" | ||
| 273 | + }, | ||
| 274 | + { | ||
| 275 | + "key": "TRAM:T7", | ||
| 276 | + "file": "tram-t7.png", | ||
| 277 | + "mediaType": "image/png", | ||
| 278 | + "bytes": 18211, | ||
| 279 | + "sha256": "8266449656ade20af5d56d379a35ffe6e8247af46ff8f2aec5670daba152d21a", | ||
| 280 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/5568fae6d1eb504a556118a6e8bd681a" | ||
| 281 | + }, | ||
| 282 | + { | ||
| 283 | + "key": "TRAM:T8", | ||
| 284 | + "file": "tram-t8.png", | ||
| 285 | + "mediaType": "image/png", | ||
| 286 | + "bytes": 25991, | ||
| 287 | + "sha256": "83c23f278d3e342e5955e1c3ee51bad3aa8824bfa2ed7702b5c34adab65e893e", | ||
| 288 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/abdb3df6ce90364cb3a504268a29d0ba" | ||
| 289 | + }, | ||
| 290 | + { | ||
| 291 | + "key": "TRAM:T9", | ||
| 292 | + "file": "tram-t9.png", | ||
| 293 | + "mediaType": "image/png", | ||
| 294 | + "bytes": 24134, | ||
| 295 | + "sha256": "dbc1cca5a06dec194d648705e4557133eac7dcda1993b57cda6126553eb4cfd0", | ||
| 296 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/ff7ac6917c6c0ef54bec9d3628571089" | ||
| 297 | + }, | ||
| 298 | + { | ||
| 299 | + "key": "TRANSILIEN:H", | ||
| 300 | + "file": "transilien-h.png", | ||
| 301 | + "mediaType": "image/png", | ||
| 302 | + "bytes": 18391, | ||
| 303 | + "sha256": "2c7a89150337ec76aeb32c5811550045ca3251aabbd1267c22db90bcb28c3cbe", | ||
| 304 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/b874ac42d638fc887ea98f56789a036d" | ||
| 305 | + }, | ||
| 306 | + { | ||
| 307 | + "key": "TRANSILIEN:J", | ||
| 308 | + "file": "transilien-j.png", | ||
| 309 | + "mediaType": "image/png", | ||
| 310 | + "bytes": 22673, | ||
| 311 | + "sha256": "a4b5bca4eca6806d70235b3ef759c3577d4fc59023cad950219ef0961a06dce5", | ||
| 312 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/62e9b7ac99c792f4b7ec85b226ad008e" | ||
| 313 | + }, | ||
| 314 | + { | ||
| 315 | + "key": "TRANSILIEN:K", | ||
| 316 | + "file": "transilien-k.png", | ||
| 317 | + "mediaType": "image/png", | ||
| 318 | + "bytes": 32390, | ||
| 319 | + "sha256": "119970ae6b664529a45ad7bd2b6e01867644dd0065b22d714be832b333a84db7", | ||
| 320 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/9ad21084ef4c6dce87364c6452422ef5" | ||
| 321 | + }, | ||
| 322 | + { | ||
| 323 | + "key": "TRANSILIEN:L", | ||
| 324 | + "file": "transilien-l.png", | ||
| 325 | + "mediaType": "image/png", | ||
| 326 | + "bytes": 18332, | ||
| 327 | + "sha256": "eb7099756e43f4edb95a20e0c7fced32c50d052daef1eb6321850b8a92ce7885", | ||
| 328 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/ae6ca077709c1256ae09d9954aa8ece8" | ||
| 329 | + }, | ||
| 330 | + { | ||
| 331 | + "key": "TRANSILIEN:N", | ||
| 332 | + "file": "transilien-n.png", | ||
| 333 | + "mediaType": "image/png", | ||
| 334 | + "bytes": 27252, | ||
| 335 | + "sha256": "c897905fc430689b777e9382e9f1ff7ebadb13b55107581827aabca12fe619f6", | ||
| 336 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/78201d213a8fc22a54bd4bb3e26cc339" | ||
| 337 | + }, | ||
| 338 | + { | ||
| 339 | + "key": "TRANSILIEN:P", | ||
| 340 | + "file": "transilien-p.png", | ||
| 341 | + "mediaType": "image/png", | ||
| 342 | + "bytes": 25981, | ||
| 343 | + "sha256": "2f046dff23249f4b3f69496a7381bfdcf92ed2c9b76947b99552afb30ea40de6", | ||
| 344 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/144bda900e4a01a6d3eb26d8e2d9803d" | ||
| 345 | + }, | ||
| 346 | + { | ||
| 347 | + "key": "TRANSILIEN:R", | ||
| 348 | + "file": "transilien-r.png", | ||
| 349 | + "mediaType": "image/png", | ||
| 350 | + "bytes": 31292, | ||
| 351 | + "sha256": "1a00bbe0b53e8ea8abfffa5cf34d555142b8e148a781810b3548cd5a2dfeb0d3", | ||
| 352 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/04ca3195d7cae3272e376492c8152900" | ||
| 353 | + }, | ||
| 354 | + { | ||
| 355 | + "key": "TRANSILIEN:U", | ||
| 356 | + "file": "transilien-u.png", | ||
| 357 | + "mediaType": "image/png", | ||
| 358 | + "bytes": 26980, | ||
| 359 | + "sha256": "4c9cffcf66d23f710a86aa12cd2c569ffc4d7831f96475e4db4b188c8fece356", | ||
| 360 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/04762929c9df4a9232485fc301b1fc40" | ||
| 361 | + }, | ||
| 362 | + { | ||
| 363 | + "key": "TRANSILIEN:V", | ||
| 364 | + "file": "transilien-v.png", | ||
| 365 | + "mediaType": "image/png", | ||
| 366 | + "bytes": 23917, | ||
| 367 | + "sha256": "b10d5a61b68fe3651affd308920088dbcfe789181f88e14f4d32e881b9477900", | ||
| 368 | + "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/2f6517d91c45d6e415c7e624abaa8cc2" | ||
| 369 | + } | ||
| 370 | + ] | ||
| 371 | +} | ||
added
packages/config-page/assets/line-badges/metro-1.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-1.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-1.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-1.png differ | ||
added
packages/config-page/assets/line-badges/metro-10.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-10.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-10.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-10.png differ | ||
added
packages/config-page/assets/line-badges/metro-11.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-11.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-11.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-11.png differ | ||
added
packages/config-page/assets/line-badges/metro-12.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-12.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-12.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-12.png differ | ||
added
packages/config-page/assets/line-badges/metro-13.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-13.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-13.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-13.png differ | ||
added
packages/config-page/assets/line-badges/metro-14.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-14.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-14.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-14.png differ | ||
added
packages/config-page/assets/line-badges/metro-2.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-2.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-2.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-2.png differ | ||
added
packages/config-page/assets/line-badges/metro-3.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-3.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-3.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-3.png differ | ||
added
packages/config-page/assets/line-badges/metro-3b.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-3b.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-3b.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-3b.png differ | ||
added
packages/config-page/assets/line-badges/metro-4.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-4.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-4.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-4.png differ | ||
added
packages/config-page/assets/line-badges/metro-5.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-5.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-5.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-5.png differ | ||
added
packages/config-page/assets/line-badges/metro-6.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-6.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-6.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-6.png differ | ||
added
packages/config-page/assets/line-badges/metro-7.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-7.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-7.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-7.png differ | ||
added
packages/config-page/assets/line-badges/metro-7b.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-7b.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-7b.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-7b.png differ | ||
added
packages/config-page/assets/line-badges/metro-8.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-8.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-8.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-8.png differ | ||
added
packages/config-page/assets/line-badges/metro-9.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-9.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-9.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-9.png differ | ||
added
packages/config-page/assets/line-badges/rer-a.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/rer-a.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/rer-a.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/rer-a.png differ | ||
added
packages/config-page/assets/line-badges/rer-b.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/rer-b.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/rer-b.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/rer-b.png differ | ||
added
packages/config-page/assets/line-badges/rer-c.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/rer-c.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/rer-c.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/rer-c.png differ | ||
added
packages/config-page/assets/line-badges/rer-d.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/rer-d.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/rer-d.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/rer-d.png differ | ||
added
packages/config-page/assets/line-badges/rer-e.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/rer-e.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/rer-e.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/rer-e.png differ | ||
added
packages/config-page/assets/line-badges/tram-t1.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t1.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t1.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t1.png differ | ||
added
packages/config-page/assets/line-badges/tram-t10.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t10.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t10.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t10.png differ | ||
added
packages/config-page/assets/line-badges/tram-t11.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t11.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t11.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t11.png differ | ||
added
packages/config-page/assets/line-badges/tram-t12.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t12.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t12.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t12.png differ | ||
added
packages/config-page/assets/line-badges/tram-t13.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t13.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t13.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t13.png differ | ||
added
packages/config-page/assets/line-badges/tram-t14.svg +24 -0 | new file mode 100644 | ||
| @@ -0,0 +1,24 @@ | ||
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | |
| 2 | +<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24.01"> | |
| 3 | + <defs> | |
| 4 | + <style> | |
| 5 | + .cls-1 { | |
| 6 | + fill: #00a092; | |
| 7 | + } | |
| 8 | + | |
| 9 | + .cls-2 { | |
| 10 | + fill: #fff; | |
| 11 | + } | |
| 12 | + </style> | |
| 13 | + </defs> | |
| 14 | + <path class="cls-2" d="M2.18,24.01c-1.2,0-2.18-.98-2.18-2.18V2.18C0,.98.98,0,2.18,0h19.64c1.2,0,2.18.98,2.18,2.18v19.64c0,1.2-.98,2.18-2.18,2.18H2.18Z"/> | |
| 15 | + <g id="Layer_1-2" data-name="Layer 1"> | |
| 16 | + <g> | |
| 17 | + <path class="cls-1" d="M21.83,4.34H2.19c-.6,0-1.09-.49-1.09-1.09v-1.09c0-.6.49-1.09,1.09-1.09h19.64c.6,0,1.09.49,1.09,1.09v1.09c0,.6-.49,1.09-1.09,1.09M22.91,21.81v-1.09c0-.6-.49-1.09-1.09-1.09H2.19c-.6,0-1.09.49-1.09,1.09v1.09c0,.6.49,1.09,1.09,1.09h19.64c.6,0,1.09-.49,1.09-1.09"/> | |
| 18 | + <g> | |
| 19 | + <polygon points="6.25 9.34 6.25 16.24 4.4 16.24 4.4 9.34 1.81 9.34 1.81 7.83 8.84 7.83 8.84 9.34 6.25 9.34"/> | |
| 20 | + <path d="M13.03,16.24v-6.61c-.51.41-1.32.77-1.88,1.05l-.57-1.29c.88-.37,1.94-.93,2.77-1.56h1.46v8.41h-1.79ZM21.21,14.48v1.75h-1.67v-1.75h-3.79v-1.56l3.26-5.1h2.19v5.34h.99v1.31h-.99ZM19.54,9.31h-.02c-.04.14-.28.65-.61,1.18l-1.72,2.69h2.35v-3.87Z"/> | |
| 21 | + </g> | |
| 22 | + </g> | |
| 23 | + </g> | |
| 24 | +</svg> | |
| \ No newline at end of file | ||
| new file mode 100644 | |||
| @@ -0,0 +1,24 @@ | |||
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | ||
| 2 | +<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24.01"> | ||
| 3 | + <defs> | ||
| 4 | + <style> | ||
| 5 | + .cls-1 { | ||
| 6 | + fill: #00a092; | ||
| 7 | + } | ||
| 8 | + | ||
| 9 | + .cls-2 { | ||
| 10 | + fill: #fff; | ||
| 11 | + } | ||
| 12 | + </style> | ||
| 13 | + </defs> | ||
| 14 | + <path class="cls-2" d="M2.18,24.01c-1.2,0-2.18-.98-2.18-2.18V2.18C0,.98.98,0,2.18,0h19.64c1.2,0,2.18.98,2.18,2.18v19.64c0,1.2-.98,2.18-2.18,2.18H2.18Z"/> | ||
| 15 | + <g id="Layer_1-2" data-name="Layer 1"> | ||
| 16 | + <g> | ||
| 17 | + <path class="cls-1" d="M21.83,4.34H2.19c-.6,0-1.09-.49-1.09-1.09v-1.09c0-.6.49-1.09,1.09-1.09h19.64c.6,0,1.09.49,1.09,1.09v1.09c0,.6-.49,1.09-1.09,1.09M22.91,21.81v-1.09c0-.6-.49-1.09-1.09-1.09H2.19c-.6,0-1.09.49-1.09,1.09v1.09c0,.6.49,1.09,1.09,1.09h19.64c.6,0,1.09-.49,1.09-1.09"/> | ||
| 18 | + <g> | ||
| 19 | + <polygon points="6.25 9.34 6.25 16.24 4.4 16.24 4.4 9.34 1.81 9.34 1.81 7.83 8.84 7.83 8.84 9.34 6.25 9.34"/> | ||
| 20 | + <path d="M13.03,16.24v-6.61c-.51.41-1.32.77-1.88,1.05l-.57-1.29c.88-.37,1.94-.93,2.77-1.56h1.46v8.41h-1.79ZM21.21,14.48v1.75h-1.67v-1.75h-3.79v-1.56l3.26-5.1h2.19v5.34h.99v1.31h-.99ZM19.54,9.31h-.02c-.04.14-.28.65-.61,1.18l-1.72,2.69h2.35v-3.87Z"/> | ||
| 21 | + </g> | ||
| 22 | + </g> | ||
| 23 | + </g> | ||
| 24 | +</svg> | ||
| \ No newline at end of file | \ No newline at end of file | ||
added
packages/config-page/assets/line-badges/tram-t2.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t2.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t2.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t2.png differ | ||
added
packages/config-page/assets/line-badges/tram-t3a.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t3a.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t3a.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t3a.png differ | ||
added
packages/config-page/assets/line-badges/tram-t3b.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t3b.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t3b.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t3b.png differ | ||
added
packages/config-page/assets/line-badges/tram-t4.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t4.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t4.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t4.png differ | ||
added
packages/config-page/assets/line-badges/tram-t5.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t5.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t5.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t5.png differ | ||
added
packages/config-page/assets/line-badges/tram-t6.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t6.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t6.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t6.png differ | ||
added
packages/config-page/assets/line-badges/tram-t7.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t7.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t7.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t7.png differ | ||
added
packages/config-page/assets/line-badges/tram-t8.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t8.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t8.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t8.png differ | ||
added
packages/config-page/assets/line-badges/tram-t9.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t9.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t9.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t9.png differ | ||
added
packages/config-page/assets/line-badges/transilien-h.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-h.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-h.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-h.png differ | ||
added
packages/config-page/assets/line-badges/transilien-j.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-j.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-j.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-j.png differ | ||
added
packages/config-page/assets/line-badges/transilien-k.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-k.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-k.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-k.png differ | ||
added
packages/config-page/assets/line-badges/transilien-l.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-l.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-l.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-l.png differ | ||
added
packages/config-page/assets/line-badges/transilien-n.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-n.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-n.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-n.png differ | ||
added
packages/config-page/assets/line-badges/transilien-p.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-p.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-p.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-p.png differ | ||
added
packages/config-page/assets/line-badges/transilien-r.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-r.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-r.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-r.png differ | ||
added
packages/config-page/assets/line-badges/transilien-u.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-u.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-u.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-u.png differ | ||
added
packages/config-page/assets/line-badges/transilien-v.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-v.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-v.png differ | Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-v.png differ | ||
modified
packages/config-page/index.html +109 -12 | @@ -2,37 +2,134 @@ | ||
| 2 | 2 | <html lang="en"> |
| 3 | 3 | <head> |
| 4 | 4 | <meta charset="utf-8"> |
| 5 | - <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> | |
| 6 | 6 | <meta name="referrer" content="no-referrer"> |
| 7 | - <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'none'; base-uri 'none'; form-action 'none'"> | |
| 7 | + <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; form-action 'none'; object-src 'none'"> | |
| 8 | 8 | <title>Lapin Futé</title> |
| 9 | 9 | <link rel="stylesheet" href="./styles/config-page.css"> |
| 10 | 10 | </head> |
| 11 | 11 | <body> |
| 12 | - <main> | |
| 13 | - <h1 id="page-title">Lapin Futé</h1> | |
| 12 | + <header class="masthead"> | |
| 13 | + <div class="route-mark" aria-hidden="true">LF</div> | |
| 14 | + <div> | |
| 15 | + <h1 id="page-title">Lapin Futé</h1> | |
| 16 | + <p id="intro"></p> | |
| 17 | + </div> | |
| 18 | + </header> | |
| 14 | 19 | |
| 20 | + <main> | |
| 21 | + <div id="config-view"> | |
| 15 | 22 | <section aria-labelledby="key-title"> |
| 16 | - <h2 id="key-title">API key</h2> | |
| 23 | + <div class="section-heading"> | |
| 24 | + <span class="step" aria-hidden="true">01</span> | |
| 25 | + <h2 id="key-title">PRIM access</h2> | |
| 26 | + </div> | |
| 17 | 27 | <p class="status" id="key-status"></p> |
| 28 | + <p class="support"><span id="key-explanation"></span> <a id="key-link" href="https://prim.iledefrance-mobilites.fr/en/aide-et-contact/documentation/donnees-disponibles/api/generer-un-jeton-pour-acceder-aux-api" target="_blank" rel="noopener noreferrer"></a></p> | |
| 18 | 29 | <label class="field-label" for="key-input" id="key-label"></label> |
| 19 | 30 | <div class="key-row"> |
| 20 | - <input id="key-input" type="password" autocomplete="new-password" autocapitalize="off" autocorrect="off" spellcheck="false"> | |
| 21 | - <button type="button" id="key-toggle" aria-pressed="false">Show</button> | |
| 31 | + <input id="key-input" type="password" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"> | |
| 32 | + <button type="button" id="key-toggle" class="quiet" aria-pressed="false">Show</button> | |
| 22 | 33 | </div> |
| 23 | 34 | <p class="error" id="key-error" role="alert" hidden></p> |
| 24 | - <p class="pending" id="key-pending" hidden></p> | |
| 25 | - <button type="button" id="key-remove" class="danger" hidden>Remove key</button> | |
| 35 | + <p class="pending" id="key-pending" role="status" hidden></p> | |
| 36 | + <button type="button" id="key-remove" class="danger quiet" hidden>Remove key</button> | |
| 26 | 37 | </section> |
| 27 | 38 | |
| 28 | 39 | <section aria-labelledby="favorites-title"> |
| 29 | - <h2 id="favorites-title">Favorites</h2> | |
| 40 | + <div class="section-heading"> | |
| 41 | + <span class="step" aria-hidden="true">02</span> | |
| 42 | + <h2 id="favorites-title">Favorite departures</h2> | |
| 43 | + </div> | |
| 30 | 44 | <p class="status" id="favorites-empty" hidden></p> |
| 31 | - <ul id="favorites-list"></ul> | |
| 45 | + <ol id="favorites-list" class="favorites"></ol> | |
| 46 | + </section> | |
| 47 | + | |
| 48 | + <section aria-labelledby="add-title" id="add-section"> | |
| 49 | + <div class="section-heading"> | |
| 50 | + <span class="step" aria-hidden="true">03</span> | |
| 51 | + <h2 id="add-title">Add a favorite</h2> | |
| 52 | + </div> | |
| 53 | + <label class="field-label" for="place-search" id="search-label"></label> | |
| 54 | + <input id="place-search" type="search" inputmode="search" autocomplete="off" autocapitalize="sentences" spellcheck="false"> | |
| 55 | + <p class="support" id="search-hint"></p> | |
| 56 | + <p id="catalog-status" class="status" role="status" aria-live="polite"></p> | |
| 57 | + <ul id="place-results" class="choice-list"></ul> | |
| 58 | + | |
| 59 | + <div id="service-step" hidden> | |
| 60 | + <label class="field-label" for="service-select" id="services-label"></label> | |
| 61 | + <select id="service-select"></select> | |
| 62 | + </div> | |
| 63 | + | |
| 64 | + <div id="preview" class="preview" hidden> | |
| 65 | + <p class="eyebrow" id="preview-title"></p> | |
| 66 | + <p><span id="preview-line" class="line-chip"></span> <strong id="preview-stop"></strong></p> | |
| 67 | + <p id="preview-destination" class="destination"></p> | |
| 68 | + <p id="preview-departures" class="recorded-preview"></p> | |
| 69 | + <label class="field-label" for="favorite-name" id="favorite-name-label"></label> | |
| 70 | + <input id="favorite-name" type="text" autocomplete="off" maxlength="96"> | |
| 71 | + <button type="button" id="favorite-add" class="secondary"></button> | |
| 72 | + </div> | |
| 32 | 73 | </section> |
| 74 | + </div> | |
| 33 | 75 | |
| 34 | - <button type="button" id="save" class="primary">Save</button> | |
| 76 | + <section id="about-view" class="about-view" aria-labelledby="about-title" hidden> | |
| 77 | + <button type="button" id="about-back" class="quiet"></button> | |
| 78 | + <div class="section-heading about-heading"> | |
| 79 | + <span class="step" aria-hidden="true">LF</span> | |
| 80 | + <h2 id="about-title"></h2> | |
| 81 | + </div> | |
| 82 | + | |
| 83 | + <div id="about-en" lang="en"> | |
| 84 | + <p class="about-version">Version 0.1.0</p> | |
| 85 | + <h3>Publisher and contact</h3> | |
| 86 | + <p><strong>Author and publisher:</strong> Anthodev, a private individual acting on a non-professional basis.</p> | |
| 87 | + <p><strong>Publication director:</strong> Anthodev.</p> | |
| 88 | + <p>Public contact: <a href="https://github.com/Anthodev/lapin-fute/issues" target="_blank" rel="noopener noreferrer">GitHub Issues</a>.</p> | |
| 89 | + <h3>Hosting</h3> | |
| 90 | + <p>GitHub, Inc., 88 Colin P. Kelly Jr. St., San Francisco, CA 94107, USA. Electronic support: <a href="https://support.github.com/contact" target="_blank" rel="noopener noreferrer">GitHub Support</a>.</p> | |
| 91 | + <h3>Privacy and data flow</h3> | |
| 92 | + <p>This app page sets no cookies and uses no analytics or telemetry.</p> | |
| 93 | + <p>Your saved personal PRIM key stays in your phone’s localStorage. When you request departures, the phone sends the key over HTTPS to the Lapin Futé backend. The backend relays it transiently to PRIM and does not store it.</p> | |
| 94 | + <p>When you search for a stop, the search terms are sent to the same-origin Lapin Futé catalog endpoint solely to return matching stops. The app does not persist them; hosting infrastructure may log requested URLs and other connection metadata.</p> | |
| 95 | + <p>GitHub Pages hosts this page. GitHub may process connection data and requested URLs under its <a href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener noreferrer">General Privacy Statement</a>.</p> | |
| 96 | + <h3>Data and credits</h3> | |
| 97 | + <p>Transport line names and colors come from the Île-de-France Mobilités <a href="https://data.iledefrance-mobilites.fr/explore/dataset/referentiel-des-lignes/" target="_blank" rel="noopener noreferrer">Référentiel des lignes</a> database, licensed under <a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener noreferrer">ODbL 1.0</a>. Official line pictograms are © Île-de-France Mobilités and are reused subject to the <a href="https://prim.iledefrance-mobilites.fr/fr/conditions-utilisation" target="_blank" rel="noopener noreferrer">IDFM/PRIM terms of use</a>.</p> | |
| 98 | + <p>Real-time departures come from the Île-de-France Mobilités <a href="https://prim.iledefrance-mobilites.fr/" target="_blank" rel="noopener noreferrer">PRIM platform</a> using your personal key and remain subject to the terms and licences published by PRIM.</p> | |
| 99 | + <h3>Independence and source</h3> | |
| 100 | + <p>Lapin Futé is an independent personal project. It is not affiliated with, endorsed by or sponsored by Île-de-France Mobilités, PRIM, ILICO, Pebble or their respective owners. Cited names and trademarks belong to their owners.</p> | |
| 101 | + <p>Source code: <a href="https://github.com/Anthodev/lapin-fute" target="_blank" rel="noopener noreferrer">github.com/Anthodev/lapin-fute</a>.</p> | |
| 102 | + </div> | |
| 103 | + | |
| 104 | + <div id="about-fr" lang="fr" hidden> | |
| 105 | + <p class="about-version">Version 0.1.0</p> | |
| 106 | + <h3>Édition et contact</h3> | |
| 107 | + <p><strong>Auteur et éditeur :</strong> Anthodev, personne physique agissant à titre privé et non professionnel.</p> | |
| 108 | + <p><strong>Directeur de la publication :</strong> Anthodev.</p> | |
| 109 | + <p>Contact public : <a href="https://github.com/Anthodev/lapin-fute/issues" target="_blank" rel="noopener noreferrer">GitHub Issues</a>.</p> | |
| 110 | + <h3>Hébergement</h3> | |
| 111 | + <p>GitHub, Inc., 88 Colin P. Kelly Jr. St., San Francisco, CA 94107, États-Unis. Assistance électronique : <a href="https://support.github.com/contact" target="_blank" rel="noopener noreferrer">support GitHub</a>.</p> | |
| 112 | + <h3>Vie privée et circulation des données</h3> | |
| 113 | + <p>Cette page de l’application ne dépose aucun cookie et n’utilise ni mesure d’audience, ni analyse comportementale, ni télémétrie.</p> | |
| 114 | + <p>Votre clé PRIM personnelle enregistrée reste dans le localStorage de votre téléphone. Lors d’une demande de départs, le téléphone transmet la clé en HTTPS au serveur Lapin Futé. Le serveur la relaie temporairement vers PRIM sans la conserver.</p> | |
| 115 | + <p>Lors d’une recherche d’arrêt, les termes saisis sont transmis au point d’accès catalogue de Lapin Futé sur la même origine, uniquement pour retourner les arrêts correspondants. L’application ne les conserve pas ; l’infrastructure d’hébergement peut journaliser les URL demandées et d’autres métadonnées de connexion.</p> | |
| 116 | + <p>Cette page est hébergée par GitHub Pages. GitHub peut traiter des données de connexion et les URL demandées selon sa <a href="https://docs.github.com/fr/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener noreferrer">déclaration de confidentialité générale</a>.</p> | |
| 117 | + <h3>Données et crédits</h3> | |
| 118 | + <p>Les noms et couleurs des lignes proviennent de la base <a href="https://data.iledefrance-mobilites.fr/explore/dataset/referentiel-des-lignes/" target="_blank" rel="noopener noreferrer">Référentiel des lignes</a> d’Île-de-France Mobilités, sous licence <a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener noreferrer">ODbL 1.0</a>. Les pictogrammes officiels des lignes sont © Île-de-France Mobilités et réutilisés selon les <a href="https://prim.iledefrance-mobilites.fr/fr/conditions-utilisation" target="_blank" rel="noopener noreferrer">conditions d’utilisation IDFM/PRIM</a>.</p> | |
| 119 | + <p>Les départs en temps réel proviennent de la plateforme <a href="https://prim.iledefrance-mobilites.fr/" target="_blank" rel="noopener noreferrer">PRIM</a> d’Île-de-France Mobilités au moyen de votre clé personnelle. Ils restent soumis aux conditions et licences publiées par PRIM.</p> | |
| 120 | + <h3>Indépendance et code source</h3> | |
| 121 | + <p>Lapin Futé est un projet personnel indépendant, sans affiliation, approbation ni parrainage d’Île-de-France Mobilités, de PRIM, d’ILICO, de Pebble ou de leurs ayants droit. Les noms et marques cités appartiennent à leurs titulaires.</p> | |
| 122 | + <p>Code source : <a href="https://github.com/Anthodev/lapin-fute" target="_blank" rel="noopener noreferrer">github.com/Anthodev/lapin-fute</a>.</p> | |
| 123 | + </div> | |
| 124 | + </section> | |
| 35 | 125 | </main> |
| 126 | + | |
| 127 | + <footer id="config-footer"> | |
| 128 | + <div class="footer-actions"> | |
| 129 | + <button type="button" id="about-open" class="quiet"></button> | |
| 130 | + <button type="button" id="save" class="primary">Save settings</button> | |
| 131 | + </div> | |
| 132 | + </footer> | |
| 36 | 133 | <noscript><p class="status">This page requires JavaScript.</p></noscript> |
| 37 | 134 | <script type="module" src="./src/config-page.js"></script> |
| 38 | 135 | </body> |
| @@ -2,37 +2,134 @@ | |||
| 2 | <html lang="en"> | 2 | <html lang="en"> |
| 3 | <head> | 3 | <head> |
| 4 | <meta charset="utf-8"> | 4 | <meta charset="utf-8"> |
| 5 | - <meta name="viewport" content="width=device-width, initial-scale=1"> | 5 | + <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> |
| 6 | <meta name="referrer" content="no-referrer"> | 6 | <meta name="referrer" content="no-referrer"> |
| 7 | - <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'none'; base-uri 'none'; form-action 'none'"> | 7 | + <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; form-action 'none'; object-src 'none'"> |
| 8 | <title>Lapin Futé</title> | 8 | <title>Lapin Futé</title> |
| 9 | <link rel="stylesheet" href="./styles/config-page.css"> | 9 | <link rel="stylesheet" href="./styles/config-page.css"> |
| 10 | </head> | 10 | </head> |
| 11 | <body> | 11 | <body> |
| 12 | - <main> | 12 | + <header class="masthead"> |
| 13 | - <h1 id="page-title">Lapin Futé</h1> | 13 | + <div class="route-mark" aria-hidden="true">LF</div> |
| 14 | + <div> | ||
| 15 | + <h1 id="page-title">Lapin Futé</h1> | ||
| 16 | + <p id="intro"></p> | ||
| 17 | + </div> | ||
| 18 | + </header> | ||
| 14 | 19 | ||
| 20 | + <main> | ||
| 21 | + <div id="config-view"> | ||
| 15 | <section aria-labelledby="key-title"> | 22 | <section aria-labelledby="key-title"> |
| 16 | - <h2 id="key-title">API key</h2> | 23 | + <div class="section-heading"> |
| 24 | + <span class="step" aria-hidden="true">01</span> | ||
| 25 | + <h2 id="key-title">PRIM access</h2> | ||
| 26 | + </div> | ||
| 17 | <p class="status" id="key-status"></p> | 27 | <p class="status" id="key-status"></p> |
| 28 | + <p class="support"><span id="key-explanation"></span> <a id="key-link" href="https://prim.iledefrance-mobilites.fr/en/aide-et-contact/documentation/donnees-disponibles/api/generer-un-jeton-pour-acceder-aux-api" target="_blank" rel="noopener noreferrer"></a></p> | ||
| 18 | <label class="field-label" for="key-input" id="key-label"></label> | 29 | <label class="field-label" for="key-input" id="key-label"></label> |
| 19 | <div class="key-row"> | 30 | <div class="key-row"> |
| 20 | - <input id="key-input" type="password" autocomplete="new-password" autocapitalize="off" autocorrect="off" spellcheck="false"> | 31 | + <input id="key-input" type="password" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"> |
| 21 | - <button type="button" id="key-toggle" aria-pressed="false">Show</button> | 32 | + <button type="button" id="key-toggle" class="quiet" aria-pressed="false">Show</button> |
| 22 | </div> | 33 | </div> |
| 23 | <p class="error" id="key-error" role="alert" hidden></p> | 34 | <p class="error" id="key-error" role="alert" hidden></p> |
| 24 | - <p class="pending" id="key-pending" hidden></p> | 35 | + <p class="pending" id="key-pending" role="status" hidden></p> |
| 25 | - <button type="button" id="key-remove" class="danger" hidden>Remove key</button> | 36 | + <button type="button" id="key-remove" class="danger quiet" hidden>Remove key</button> |
| 26 | </section> | 37 | </section> |
| 27 | 38 | ||
| 28 | <section aria-labelledby="favorites-title"> | 39 | <section aria-labelledby="favorites-title"> |
| 29 | - <h2 id="favorites-title">Favorites</h2> | 40 | + <div class="section-heading"> |
| 41 | + <span class="step" aria-hidden="true">02</span> | ||
| 42 | + <h2 id="favorites-title">Favorite departures</h2> | ||
| 43 | + </div> | ||
| 30 | <p class="status" id="favorites-empty" hidden></p> | 44 | <p class="status" id="favorites-empty" hidden></p> |
| 31 | - <ul id="favorites-list"></ul> | 45 | + <ol id="favorites-list" class="favorites"></ol> |
| 46 | + </section> | ||
| 47 | + | ||
| 48 | + <section aria-labelledby="add-title" id="add-section"> | ||
| 49 | + <div class="section-heading"> | ||
| 50 | + <span class="step" aria-hidden="true">03</span> | ||
| 51 | + <h2 id="add-title">Add a favorite</h2> | ||
| 52 | + </div> | ||
| 53 | + <label class="field-label" for="place-search" id="search-label"></label> | ||
| 54 | + <input id="place-search" type="search" inputmode="search" autocomplete="off" autocapitalize="sentences" spellcheck="false"> | ||
| 55 | + <p class="support" id="search-hint"></p> | ||
| 56 | + <p id="catalog-status" class="status" role="status" aria-live="polite"></p> | ||
| 57 | + <ul id="place-results" class="choice-list"></ul> | ||
| 58 | + | ||
| 59 | + <div id="service-step" hidden> | ||
| 60 | + <label class="field-label" for="service-select" id="services-label"></label> | ||
| 61 | + <select id="service-select"></select> | ||
| 62 | + </div> | ||
| 63 | + | ||
| 64 | + <div id="preview" class="preview" hidden> | ||
| 65 | + <p class="eyebrow" id="preview-title"></p> | ||
| 66 | + <p><span id="preview-line" class="line-chip"></span> <strong id="preview-stop"></strong></p> | ||
| 67 | + <p id="preview-destination" class="destination"></p> | ||
| 68 | + <p id="preview-departures" class="recorded-preview"></p> | ||
| 69 | + <label class="field-label" for="favorite-name" id="favorite-name-label"></label> | ||
| 70 | + <input id="favorite-name" type="text" autocomplete="off" maxlength="96"> | ||
| 71 | + <button type="button" id="favorite-add" class="secondary"></button> | ||
| 72 | + </div> | ||
| 32 | </section> | 73 | </section> |
| 74 | + </div> | ||
| 33 | 75 | ||
| 34 | - <button type="button" id="save" class="primary">Save</button> | 76 | + <section id="about-view" class="about-view" aria-labelledby="about-title" hidden> |
| 77 | + <button type="button" id="about-back" class="quiet"></button> | ||
| 78 | + <div class="section-heading about-heading"> | ||
| 79 | + <span class="step" aria-hidden="true">LF</span> | ||
| 80 | + <h2 id="about-title"></h2> | ||
| 81 | + </div> | ||
| 82 | + | ||
| 83 | + <div id="about-en" lang="en"> | ||
| 84 | + <p class="about-version">Version 0.1.0</p> | ||
| 85 | + <h3>Publisher and contact</h3> | ||
| 86 | + <p><strong>Author and publisher:</strong> Anthodev, a private individual acting on a non-professional basis.</p> | ||
| 87 | + <p><strong>Publication director:</strong> Anthodev.</p> | ||
| 88 | + <p>Public contact: <a href="https://github.com/Anthodev/lapin-fute/issues" target="_blank" rel="noopener noreferrer">GitHub Issues</a>.</p> | ||
| 89 | + <h3>Hosting</h3> | ||
| 90 | + <p>GitHub, Inc., 88 Colin P. Kelly Jr. St., San Francisco, CA 94107, USA. Electronic support: <a href="https://support.github.com/contact" target="_blank" rel="noopener noreferrer">GitHub Support</a>.</p> | ||
| 91 | + <h3>Privacy and data flow</h3> | ||
| 92 | + <p>This app page sets no cookies and uses no analytics or telemetry.</p> | ||
| 93 | + <p>Your saved personal PRIM key stays in your phone’s localStorage. When you request departures, the phone sends the key over HTTPS to the Lapin Futé backend. The backend relays it transiently to PRIM and does not store it.</p> | ||
| 94 | + <p>When you search for a stop, the search terms are sent to the same-origin Lapin Futé catalog endpoint solely to return matching stops. The app does not persist them; hosting infrastructure may log requested URLs and other connection metadata.</p> | ||
| 95 | + <p>GitHub Pages hosts this page. GitHub may process connection data and requested URLs under its <a href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener noreferrer">General Privacy Statement</a>.</p> | ||
| 96 | + <h3>Data and credits</h3> | ||
| 97 | + <p>Transport line names and colors come from the Île-de-France Mobilités <a href="https://data.iledefrance-mobilites.fr/explore/dataset/referentiel-des-lignes/" target="_blank" rel="noopener noreferrer">Référentiel des lignes</a> database, licensed under <a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener noreferrer">ODbL 1.0</a>. Official line pictograms are © Île-de-France Mobilités and are reused subject to the <a href="https://prim.iledefrance-mobilites.fr/fr/conditions-utilisation" target="_blank" rel="noopener noreferrer">IDFM/PRIM terms of use</a>.</p> | ||
| 98 | + <p>Real-time departures come from the Île-de-France Mobilités <a href="https://prim.iledefrance-mobilites.fr/" target="_blank" rel="noopener noreferrer">PRIM platform</a> using your personal key and remain subject to the terms and licences published by PRIM.</p> | ||
| 99 | + <h3>Independence and source</h3> | ||
| 100 | + <p>Lapin Futé is an independent personal project. It is not affiliated with, endorsed by or sponsored by Île-de-France Mobilités, PRIM, ILICO, Pebble or their respective owners. Cited names and trademarks belong to their owners.</p> | ||
| 101 | + <p>Source code: <a href="https://github.com/Anthodev/lapin-fute" target="_blank" rel="noopener noreferrer">github.com/Anthodev/lapin-fute</a>.</p> | ||
| 102 | + </div> | ||
| 103 | + | ||
| 104 | + <div id="about-fr" lang="fr" hidden> | ||
| 105 | + <p class="about-version">Version 0.1.0</p> | ||
| 106 | + <h3>Édition et contact</h3> | ||
| 107 | + <p><strong>Auteur et éditeur :</strong> Anthodev, personne physique agissant à titre privé et non professionnel.</p> | ||
| 108 | + <p><strong>Directeur de la publication :</strong> Anthodev.</p> | ||
| 109 | + <p>Contact public : <a href="https://github.com/Anthodev/lapin-fute/issues" target="_blank" rel="noopener noreferrer">GitHub Issues</a>.</p> | ||
| 110 | + <h3>Hébergement</h3> | ||
| 111 | + <p>GitHub, Inc., 88 Colin P. Kelly Jr. St., San Francisco, CA 94107, États-Unis. Assistance électronique : <a href="https://support.github.com/contact" target="_blank" rel="noopener noreferrer">support GitHub</a>.</p> | ||
| 112 | + <h3>Vie privée et circulation des données</h3> | ||
| 113 | + <p>Cette page de l’application ne dépose aucun cookie et n’utilise ni mesure d’audience, ni analyse comportementale, ni télémétrie.</p> | ||
| 114 | + <p>Votre clé PRIM personnelle enregistrée reste dans le localStorage de votre téléphone. Lors d’une demande de départs, le téléphone transmet la clé en HTTPS au serveur Lapin Futé. Le serveur la relaie temporairement vers PRIM sans la conserver.</p> | ||
| 115 | + <p>Lors d’une recherche d’arrêt, les termes saisis sont transmis au point d’accès catalogue de Lapin Futé sur la même origine, uniquement pour retourner les arrêts correspondants. L’application ne les conserve pas ; l’infrastructure d’hébergement peut journaliser les URL demandées et d’autres métadonnées de connexion.</p> | ||
| 116 | + <p>Cette page est hébergée par GitHub Pages. GitHub peut traiter des données de connexion et les URL demandées selon sa <a href="https://docs.github.com/fr/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener noreferrer">déclaration de confidentialité générale</a>.</p> | ||
| 117 | + <h3>Données et crédits</h3> | ||
| 118 | + <p>Les noms et couleurs des lignes proviennent de la base <a href="https://data.iledefrance-mobilites.fr/explore/dataset/referentiel-des-lignes/" target="_blank" rel="noopener noreferrer">Référentiel des lignes</a> d’Île-de-France Mobilités, sous licence <a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener noreferrer">ODbL 1.0</a>. Les pictogrammes officiels des lignes sont © Île-de-France Mobilités et réutilisés selon les <a href="https://prim.iledefrance-mobilites.fr/fr/conditions-utilisation" target="_blank" rel="noopener noreferrer">conditions d’utilisation IDFM/PRIM</a>.</p> | ||
| 119 | + <p>Les départs en temps réel proviennent de la plateforme <a href="https://prim.iledefrance-mobilites.fr/" target="_blank" rel="noopener noreferrer">PRIM</a> d’Île-de-France Mobilités au moyen de votre clé personnelle. Ils restent soumis aux conditions et licences publiées par PRIM.</p> | ||
| 120 | + <h3>Indépendance et code source</h3> | ||
| 121 | + <p>Lapin Futé est un projet personnel indépendant, sans affiliation, approbation ni parrainage d’Île-de-France Mobilités, de PRIM, d’ILICO, de Pebble ou de leurs ayants droit. Les noms et marques cités appartiennent à leurs titulaires.</p> | ||
| 122 | + <p>Code source : <a href="https://github.com/Anthodev/lapin-fute" target="_blank" rel="noopener noreferrer">github.com/Anthodev/lapin-fute</a>.</p> | ||
| 123 | + </div> | ||
| 124 | + </section> | ||
| 35 | </main> | 125 | </main> |
| 126 | + | ||
| 127 | + <footer id="config-footer"> | ||
| 128 | + <div class="footer-actions"> | ||
| 129 | + <button type="button" id="about-open" class="quiet"></button> | ||
| 130 | + <button type="button" id="save" class="primary">Save settings</button> | ||
| 131 | + </div> | ||
| 132 | + </footer> | ||
| 36 | <noscript><p class="status">This page requires JavaScript.</p></noscript> | 133 | <noscript><p class="status">This page requires JavaScript.</p></noscript> |
| 37 | <script type="module" src="./src/config-page.js"></script> | 134 | <script type="module" src="./src/config-page.js"></script> |
| 38 | </body> | 135 | </body> |
added
packages/config-page/src/catalog-client.js +98 -0 | new file mode 100644 | ||
| @@ -0,0 +1,98 @@ | ||
| 1 | +import { | |
| 2 | + LIMITS, | |
| 3 | + isPlaceSearchResult, | |
| 4 | + isServiceOptionsResult, | |
| 5 | +} from "./config-core.js"; | |
| 6 | + | |
| 7 | +export const SEARCH_DEBOUNCE_MS = 300; | |
| 8 | + | |
| 9 | +export class CatalogClientError extends Error { | |
| 10 | + constructor(code) { | |
| 11 | + super(code); | |
| 12 | + this.name = "CatalogClientError"; | |
| 13 | + this.code = code; | |
| 14 | + } | |
| 15 | +} | |
| 16 | + | |
| 17 | +function abortError() { | |
| 18 | + return new DOMException("Superseded", "AbortError"); | |
| 19 | +} | |
| 20 | + | |
| 21 | +async function responseJson(response) { | |
| 22 | + if (!response.ok) { | |
| 23 | + throw new CatalogClientError(response.status === 404 ? "INVALID_SERVICE" : "BACKEND_UNAVAILABLE"); | |
| 24 | + } | |
| 25 | + try { | |
| 26 | + return await response.json(); | |
| 27 | + } catch { | |
| 28 | + throw new CatalogClientError("BACKEND_UNAVAILABLE"); | |
| 29 | + } | |
| 30 | +} | |
| 31 | + | |
| 32 | +export function createCatalogClient({ | |
| 33 | + fetchImpl = globalThis.fetch, | |
| 34 | + setTimer = globalThis.setTimeout, | |
| 35 | + clearTimer = globalThis.clearTimeout, | |
| 36 | +} = {}) { | |
| 37 | + let timer = null; | |
| 38 | + let searchController = null; | |
| 39 | + | |
| 40 | + function cancelSearch() { | |
| 41 | + if (timer !== null) { | |
| 42 | + clearTimer(timer); | |
| 43 | + timer = null; | |
| 44 | + } | |
| 45 | + if (searchController !== null) { | |
| 46 | + searchController.abort(); | |
| 47 | + searchController = null; | |
| 48 | + } | |
| 49 | + } | |
| 50 | + | |
| 51 | + function searchPlaces(query) { | |
| 52 | + cancelSearch(); | |
| 53 | + const normalized = typeof query === "string" ? query.trim() : ""; | |
| 54 | + if (Array.from(normalized).length < LIMITS.catalogQueryMinCharacters) { | |
| 55 | + return Promise.resolve([]); | |
| 56 | + } | |
| 57 | + if (Array.from(normalized).length > LIMITS.catalogQueryMaxCharacters) { | |
| 58 | + return Promise.reject(new CatalogClientError("INVALID_QUERY")); | |
| 59 | + } | |
| 60 | + | |
| 61 | + const controller = new AbortController(); | |
| 62 | + searchController = controller; | |
| 63 | + return new Promise((resolve, reject) => { | |
| 64 | + controller.signal.addEventListener("abort", () => reject(abortError()), { once: true }); | |
| 65 | + timer = setTimer(async () => { | |
| 66 | + timer = null; | |
| 67 | + if (controller.signal.aborted) return; | |
| 68 | + try { | |
| 69 | + const response = await fetchImpl( | |
| 70 | + `/api/catalog/places?q=${encodeURIComponent(normalized)}`, | |
| 71 | + { signal: controller.signal, credentials: "omit", headers: { accept: "application/json" } }, | |
| 72 | + ); | |
| 73 | + const body = await responseJson(response); | |
| 74 | + if (!isPlaceSearchResult(body)) throw new CatalogClientError("BACKEND_UNAVAILABLE"); | |
| 75 | + if (!controller.signal.aborted) resolve(body.places); | |
| 76 | + } catch (error) { | |
| 77 | + if (!controller.signal.aborted) reject(error instanceof CatalogClientError | |
| 78 | + ? error | |
| 79 | + : new CatalogClientError("BACKEND_UNAVAILABLE")); | |
| 80 | + } finally { | |
| 81 | + if (searchController === controller) searchController = null; | |
| 82 | + } | |
| 83 | + }, SEARCH_DEBOUNCE_MS); | |
| 84 | + }); | |
| 85 | + } | |
| 86 | + | |
| 87 | + async function listServices(placeId, signal) { | |
| 88 | + const response = await fetchImpl( | |
| 89 | + `/api/catalog/places/${encodeURIComponent(placeId)}/services`, | |
| 90 | + { signal, credentials: "omit", headers: { accept: "application/json" } }, | |
| 91 | + ); | |
| 92 | + const body = await responseJson(response); | |
| 93 | + if (!isServiceOptionsResult(body, placeId)) throw new CatalogClientError("INVALID_SERVICE"); | |
| 94 | + return body.services; | |
| 95 | + } | |
| 96 | + | |
| 97 | + return { cancelSearch, searchPlaces, listServices }; | |
| 98 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,98 @@ | |||
| 1 | +import { | ||
| 2 | + LIMITS, | ||
| 3 | + isPlaceSearchResult, | ||
| 4 | + isServiceOptionsResult, | ||
| 5 | +} from "./config-core.js"; | ||
| 6 | + | ||
| 7 | +export const SEARCH_DEBOUNCE_MS = 300; | ||
| 8 | + | ||
| 9 | +export class CatalogClientError extends Error { | ||
| 10 | + constructor(code) { | ||
| 11 | + super(code); | ||
| 12 | + this.name = "CatalogClientError"; | ||
| 13 | + this.code = code; | ||
| 14 | + } | ||
| 15 | +} | ||
| 16 | + | ||
| 17 | +function abortError() { | ||
| 18 | + return new DOMException("Superseded", "AbortError"); | ||
| 19 | +} | ||
| 20 | + | ||
| 21 | +async function responseJson(response) { | ||
| 22 | + if (!response.ok) { | ||
| 23 | + throw new CatalogClientError(response.status === 404 ? "INVALID_SERVICE" : "BACKEND_UNAVAILABLE"); | ||
| 24 | + } | ||
| 25 | + try { | ||
| 26 | + return await response.json(); | ||
| 27 | + } catch { | ||
| 28 | + throw new CatalogClientError("BACKEND_UNAVAILABLE"); | ||
| 29 | + } | ||
| 30 | +} | ||
| 31 | + | ||
| 32 | +export function createCatalogClient({ | ||
| 33 | + fetchImpl = globalThis.fetch, | ||
| 34 | + setTimer = globalThis.setTimeout, | ||
| 35 | + clearTimer = globalThis.clearTimeout, | ||
| 36 | +} = {}) { | ||
| 37 | + let timer = null; | ||
| 38 | + let searchController = null; | ||
| 39 | + | ||
| 40 | + function cancelSearch() { | ||
| 41 | + if (timer !== null) { | ||
| 42 | + clearTimer(timer); | ||
| 43 | + timer = null; | ||
| 44 | + } | ||
| 45 | + if (searchController !== null) { | ||
| 46 | + searchController.abort(); | ||
| 47 | + searchController = null; | ||
| 48 | + } | ||
| 49 | + } | ||
| 50 | + | ||
| 51 | + function searchPlaces(query) { | ||
| 52 | + cancelSearch(); | ||
| 53 | + const normalized = typeof query === "string" ? query.trim() : ""; | ||
| 54 | + if (Array.from(normalized).length < LIMITS.catalogQueryMinCharacters) { | ||
| 55 | + return Promise.resolve([]); | ||
| 56 | + } | ||
| 57 | + if (Array.from(normalized).length > LIMITS.catalogQueryMaxCharacters) { | ||
| 58 | + return Promise.reject(new CatalogClientError("INVALID_QUERY")); | ||
| 59 | + } | ||
| 60 | + | ||
| 61 | + const controller = new AbortController(); | ||
| 62 | + searchController = controller; | ||
| 63 | + return new Promise((resolve, reject) => { | ||
| 64 | + controller.signal.addEventListener("abort", () => reject(abortError()), { once: true }); | ||
| 65 | + timer = setTimer(async () => { | ||
| 66 | + timer = null; | ||
| 67 | + if (controller.signal.aborted) return; | ||
| 68 | + try { | ||
| 69 | + const response = await fetchImpl( | ||
| 70 | + `/api/catalog/places?q=${encodeURIComponent(normalized)}`, | ||
| 71 | + { signal: controller.signal, credentials: "omit", headers: { accept: "application/json" } }, | ||
| 72 | + ); | ||
| 73 | + const body = await responseJson(response); | ||
| 74 | + if (!isPlaceSearchResult(body)) throw new CatalogClientError("BACKEND_UNAVAILABLE"); | ||
| 75 | + if (!controller.signal.aborted) resolve(body.places); | ||
| 76 | + } catch (error) { | ||
| 77 | + if (!controller.signal.aborted) reject(error instanceof CatalogClientError | ||
| 78 | + ? error | ||
| 79 | + : new CatalogClientError("BACKEND_UNAVAILABLE")); | ||
| 80 | + } finally { | ||
| 81 | + if (searchController === controller) searchController = null; | ||
| 82 | + } | ||
| 83 | + }, SEARCH_DEBOUNCE_MS); | ||
| 84 | + }); | ||
| 85 | + } | ||
| 86 | + | ||
| 87 | + async function listServices(placeId, signal) { | ||
| 88 | + const response = await fetchImpl( | ||
| 89 | + `/api/catalog/places/${encodeURIComponent(placeId)}/services`, | ||
| 90 | + { signal, credentials: "omit", headers: { accept: "application/json" } }, | ||
| 91 | + ); | ||
| 92 | + const body = await responseJson(response); | ||
| 93 | + if (!isServiceOptionsResult(body, placeId)) throw new CatalogClientError("INVALID_SERVICE"); | ||
| 94 | + return body.services; | ||
| 95 | + } | ||
| 96 | + | ||
| 97 | + return { cancelSearch, searchPlaces, listServices }; | ||
| 98 | +} | ||
modified
packages/config-page/src/config-core.js +193 -27 | @@ -20,6 +20,9 @@ export const LIMITS = { | ||
| 20 | 20 | idUtf8Bytes: 64, |
| 21 | 21 | labelUtf8Bytes: 96, |
| 22 | 22 | favorites: 8, |
| 23 | + catalogQueryMinCharacters: 2, | |
| 24 | + catalogQueryMaxCharacters: 100, | |
| 25 | + catalogSearchResults: 20, | |
| 23 | 26 | }; |
| 24 | 27 | |
| 25 | 28 | const FAVORITE_FIELDS = [ |
| @@ -30,6 +33,9 @@ const FAVORITE_FIELDS = [ | ||
| 30 | 33 | "stopLabel", |
| 31 | 34 | "lineLabel", |
| 32 | 35 | "destinationLabel", |
| 36 | + "lineMode", | |
| 37 | + "lineColor", | |
| 38 | + "lineTextColor", | |
| 33 | 39 | "sortOrder", |
| 34 | 40 | ]; |
| 35 | 41 | |
| @@ -43,6 +49,10 @@ function boundedString(value, maximum) { | ||
| 43 | 49 | return typeof value === "string" && utf8Bytes(value) >= 1 && utf8Bytes(value) <= maximum; |
| 44 | 50 | } |
| 45 | 51 | |
| 52 | +export function isLineColor(value) { | |
| 53 | + return typeof value === "string" && /^#[0-9a-f]{6}$/u.test(value); | |
| 54 | +} | |
| 55 | + | |
| 46 | 56 | // --- Locale ----------------------------------------------------------------- |
| 47 | 57 | // French authored product copy is selected by any language tag beginning |
| 48 | 58 | // "fr"; every other (or unknown) value falls back to English. User-authored |
| @@ -55,46 +65,100 @@ export function selectLocale(language) { | ||
| 55 | 65 | |
| 56 | 66 | export const COPY = { |
| 57 | 67 | en: { |
| 58 | - pageTitle: "Lapin Futé — Settings", | |
| 59 | - keyTitle: "PRIM API key", | |
| 60 | - keyStatusConfigured: "A key is configured on your phone.", | |
| 61 | - keyStatusMissing: "No API key is configured.", | |
| 62 | - keyLabel: "API key", | |
| 63 | - keyPlaceholder: "Enter a new key", | |
| 68 | + pageTitle: "Lapin Futé settings", | |
| 69 | + intro: "Choose the departures you want at hand on your Pebble.", | |
| 70 | + keyTitle: "PRIM access", | |
| 71 | + keyStatusConfigured: "A key is configured on this phone.", | |
| 72 | + keyStatusMissing: "No key is configured.", | |
| 73 | + keyExplanation: "Your personal key stays in plaintext on your phone. This page never receives the saved key.", | |
| 74 | + keyLink: "Create a PRIM token", | |
| 75 | + keyLabel: "Replace with a new key", | |
| 76 | + keyPlaceholder: "Paste a new key", | |
| 64 | 77 | keyShow: "Show", |
| 65 | 78 | keyHide: "Hide", |
| 66 | - keyRemove: "Remove key", | |
| 67 | - keyUndoRemove: "Keep key", | |
| 68 | - keyRemovePending: "The key will be removed when you save.", | |
| 69 | - keyErrorTooLong: "The key must be at most 512 bytes.", | |
| 79 | + keyRemove: "Remove saved key", | |
| 80 | + keyUndoRemove: "Keep saved key", | |
| 81 | + keyRemovePending: "The saved key will be removed.", | |
| 82 | + keyReplacementPending: "A replacement key is ready to save.", | |
| 83 | + keyErrorTooLong: "The key must be at most 512 UTF-8 bytes.", | |
| 70 | 84 | keyErrorNewline: "The key must not contain line breaks.", |
| 71 | - favoritesTitle: "Favorites", | |
| 72 | - favoritesEmpty: "No favorites yet.", | |
| 85 | + favoritesTitle: "Favorite departures", | |
| 86 | + favoritesEmpty: "No favorites yet. Search for a stop to add one.", | |
| 87 | + favoriteAddTitle: "Add a favorite", | |
| 88 | + searchLabel: "Stop or station", | |
| 89 | + searchPlaceholder: "Search by name", | |
| 90 | + searchHint: "Enter at least 2 characters.", | |
| 91 | + searchLoading: "Searching…", | |
| 92 | + searchNoResults: "No matching stop or station.", | |
| 93 | + backendUnavailable: "The service catalog is unavailable. Your edits are safe; try again.", | |
| 94 | + invalidService: "This service is no longer available. Search again.", | |
| 95 | + servicesLoading: "Loading lines and directions…", | |
| 96 | + servicesLabel: "Line and direction", | |
| 97 | + servicesEmpty: "No service is available for this stop.", | |
| 98 | + previewRecorded: "Recorded example", | |
| 99 | + minutesShort: "min", | |
| 100 | + previewTitle: "Preview", | |
| 101 | + favoriteNameLabel: "Favorite name (optional)", | |
| 102 | + favoriteNamePlaceholder: "For example, Home", | |
| 103 | + favoriteAdd: "Add favorite", | |
| 104 | + favoriteLimit: "You have reached the 8-favorite limit.", | |
| 73 | 105 | favoriteMoveUp: "Move up", |
| 74 | 106 | favoriteMoveDown: "Move down", |
| 107 | + favoriteRename: "Rename", | |
| 75 | 108 | favoriteRemove: "Remove", |
| 76 | - save: "Save", | |
| 109 | + renamePrompt: "Favorite name", | |
| 110 | + save: "Save settings", | |
| 111 | + aboutOpen: "About", | |
| 112 | + aboutBack: "Back to settings", | |
| 113 | + aboutTitle: "About Lapin Futé", | |
| 77 | 114 | }, |
| 78 | 115 | fr: { |
| 79 | - pageTitle: "Lapin Futé — Réglages", | |
| 80 | - keyTitle: "Clé API PRIM", | |
| 81 | - keyStatusConfigured: "Une clé est configurée sur votre téléphone.", | |
| 82 | - keyStatusMissing: "Aucune clé API n’est configurée.", | |
| 83 | - keyLabel: "Clé API", | |
| 84 | - keyPlaceholder: "Saisir une nouvelle clé", | |
| 116 | + pageTitle: "Réglages Lapin Futé", | |
| 117 | + intro: "Choisissez les prochains départs à garder sous la main sur votre Pebble.", | |
| 118 | + keyTitle: "Accès PRIM", | |
| 119 | + keyStatusConfigured: "Une clé est configurée sur ce téléphone.", | |
| 120 | + keyStatusMissing: "Aucune clé n’est configurée.", | |
| 121 | + keyExplanation: "Votre clé personnelle reste en clair sur votre téléphone. Cette page ne reçoit jamais la clé enregistrée.", | |
| 122 | + keyLink: "Créer un jeton PRIM", | |
| 123 | + keyLabel: "Remplacer par une nouvelle clé", | |
| 124 | + keyPlaceholder: "Coller une clé", | |
| 85 | 125 | keyShow: "Afficher", |
| 86 | 126 | keyHide: "Masquer", |
| 87 | - keyRemove: "Supprimer la clé", | |
| 88 | - keyUndoRemove: "Conserver la clé", | |
| 89 | - keyRemovePending: "La clé sera supprimée à l’enregistrement.", | |
| 90 | - keyErrorTooLong: "La clé doit contenir au maximum 512 octets.", | |
| 127 | + keyRemove: "Supprimer la clé enregistrée", | |
| 128 | + keyUndoRemove: "Conserver la clé enregistrée", | |
| 129 | + keyRemovePending: "La clé enregistrée sera supprimée.", | |
| 130 | + keyReplacementPending: "Une nouvelle clé est prête à être enregistrée.", | |
| 131 | + keyErrorTooLong: "La clé doit contenir au maximum 512 octets UTF-8.", | |
| 91 | 132 | keyErrorNewline: "La clé ne doit pas contenir de retour à la ligne.", |
| 92 | - favoritesTitle: "Favoris", | |
| 93 | - favoritesEmpty: "Aucun favori pour le moment.", | |
| 133 | + favoritesTitle: "Départs favoris", | |
| 134 | + favoritesEmpty: "Aucun favori. Recherchez un arrêt pour en ajouter un.", | |
| 135 | + favoriteAddTitle: "Ajouter un favori", | |
| 136 | + searchLabel: "Arrêt ou gare", | |
| 137 | + searchPlaceholder: "Rechercher par nom", | |
| 138 | + searchHint: "Saisissez au moins 2 caractères.", | |
| 139 | + searchLoading: "Recherche…", | |
| 140 | + searchNoResults: "Aucun arrêt ni gare ne correspond.", | |
| 141 | + backendUnavailable: "Le catalogue est indisponible. Vos modifications sont conservées ; réessayez.", | |
| 142 | + invalidService: "Ce service n’est plus disponible. Relancez la recherche.", | |
| 143 | + servicesLoading: "Chargement des lignes et directions…", | |
| 144 | + servicesLabel: "Ligne et direction", | |
| 145 | + servicesEmpty: "Aucun service n’est disponible pour cet arrêt.", | |
| 146 | + previewRecorded: "Exemple enregistré", | |
| 147 | + minutesShort: "min", | |
| 148 | + previewTitle: "Aperçu", | |
| 149 | + favoriteNameLabel: "Nom du favori (facultatif)", | |
| 150 | + favoriteNamePlaceholder: "Par exemple, Maison", | |
| 151 | + favoriteAdd: "Ajouter le favori", | |
| 152 | + favoriteLimit: "Vous avez atteint la limite de 8 favoris.", | |
| 94 | 153 | favoriteMoveUp: "Monter", |
| 95 | 154 | favoriteMoveDown: "Descendre", |
| 155 | + favoriteRename: "Renommer", | |
| 96 | 156 | favoriteRemove: "Supprimer", |
| 97 | - save: "Enregistrer", | |
| 157 | + renamePrompt: "Nom du favori", | |
| 158 | + save: "Enregistrer les réglages", | |
| 159 | + aboutOpen: "À propos", | |
| 160 | + aboutBack: "Retour aux réglages", | |
| 161 | + aboutTitle: "À propos de Lapin Futé", | |
| 98 | 162 | }, |
| 99 | 163 | }; |
| 100 | 164 | |
| @@ -107,6 +171,10 @@ export function copyFor(language) { | ||
| 107 | 171 | export function isFavoriteShape(value) { |
| 108 | 172 | if (typeof value !== "object" || value === null || Array.isArray(value)) return false; |
| 109 | 173 | if (!Object.keys(value).every((field) => FAVORITE_FIELDS.includes(field))) return false; |
| 174 | + const hasLineMode = Object.hasOwn(value, "lineMode"); | |
| 175 | + const hasLineColor = Object.hasOwn(value, "lineColor"); | |
| 176 | + const hasLineTextColor = Object.hasOwn(value, "lineTextColor"); | |
| 177 | + if (hasLineMode !== hasLineColor || hasLineMode !== hasLineTextColor) return false; | |
| 110 | 178 | return ( |
| 111 | 179 | value.schemaVersion === SCHEMA_VERSION && |
| 112 | 180 | boundedString(value.id, LIMITS.idUtf8Bytes) && |
| @@ -115,10 +183,81 @@ export function isFavoriteShape(value) { | ||
| 115 | 183 | boundedString(value.stopLabel, LIMITS.labelUtf8Bytes) && |
| 116 | 184 | boundedString(value.lineLabel, LIMITS.labelUtf8Bytes) && |
| 117 | 185 | boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes) && |
| 186 | + (!hasLineMode || ( | |
| 187 | + TRANSPORT_MODES.includes(value.lineMode) && | |
| 188 | + isLineColor(value.lineColor) && | |
| 189 | + isLineColor(value.lineTextColor) | |
| 190 | + )) && | |
| 118 | 191 | Number.isInteger(value.sortOrder) |
| 119 | 192 | ); |
| 120 | 193 | } |
| 121 | 194 | |
| 195 | +const PLACE_FIELDS = ["placeId", "stopLabel", "localityLabel", "mode"]; | |
| 196 | +const SERVICE_FIELDS = ["serviceId", "stopLabel", "lineLabel", "destinationLabel", "lineMode", "lineColor", "lineTextColor"]; | |
| 197 | +const TRANSPORT_MODES = ["BUS", "METRO", "TRAM", "RER", "TRANSILIEN"]; | |
| 198 | + | |
| 199 | +function exactFields(value, allowed, required) { | |
| 200 | + return typeof value === "object" && value !== null && !Array.isArray(value) | |
| 201 | + && Object.keys(value).every((field) => allowed.includes(field)) | |
| 202 | + && required.every((field) => Object.hasOwn(value, field)); | |
| 203 | +} | |
| 204 | + | |
| 205 | +export function isPlaceSearchItem(value) { | |
| 206 | + return exactFields(value, PLACE_FIELDS, ["placeId", "stopLabel", "mode"]) | |
| 207 | + && boundedString(value.placeId, LIMITS.idUtf8Bytes) | |
| 208 | + && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes) | |
| 209 | + && (value.localityLabel === undefined || boundedString(value.localityLabel, LIMITS.labelUtf8Bytes)) | |
| 210 | + && TRANSPORT_MODES.includes(value.mode); | |
| 211 | +} | |
| 212 | + | |
| 213 | +export function isPlaceSearchResult(value) { | |
| 214 | + return exactFields(value, ["schemaVersion", "places"], ["schemaVersion", "places"]) | |
| 215 | + && value.schemaVersion === SCHEMA_VERSION | |
| 216 | + && Array.isArray(value.places) | |
| 217 | + && value.places.length <= LIMITS.catalogSearchResults | |
| 218 | + && value.places.every(isPlaceSearchItem); | |
| 219 | +} | |
| 220 | + | |
| 221 | +export function isServiceOption(value) { | |
| 222 | + return exactFields(value, SERVICE_FIELDS, SERVICE_FIELDS) | |
| 223 | + && boundedString(value.serviceId, LIMITS.idUtf8Bytes) | |
| 224 | + && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes) | |
| 225 | + && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes) | |
| 226 | + && boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes) | |
| 227 | + && TRANSPORT_MODES.includes(value.lineMode) | |
| 228 | + && isLineColor(value.lineColor) | |
| 229 | + && isLineColor(value.lineTextColor); | |
| 230 | +} | |
| 231 | + | |
| 232 | +export function isServiceOptionsResult(value, placeId) { | |
| 233 | + return exactFields(value, ["schemaVersion", "placeId", "services"], ["schemaVersion", "placeId", "services"]) | |
| 234 | + && value.schemaVersion === SCHEMA_VERSION | |
| 235 | + && value.placeId === placeId | |
| 236 | + && Array.isArray(value.services) | |
| 237 | + && value.services.every(isServiceOption); | |
| 238 | +} | |
| 239 | + | |
| 240 | +export function favoriteFromService(id, service, sortOrder, displayName = undefined) { | |
| 241 | + if (!boundedString(id, LIMITS.idUtf8Bytes) || !isServiceOption(service) | |
| 242 | + || !Number.isInteger(sortOrder) || sortOrder < 0) return null; | |
| 243 | + const favorite = { | |
| 244 | + schemaVersion: SCHEMA_VERSION, | |
| 245 | + id, | |
| 246 | + serviceId: service.serviceId, | |
| 247 | + stopLabel: service.stopLabel, | |
| 248 | + lineLabel: service.lineLabel, | |
| 249 | + destinationLabel: service.destinationLabel, | |
| 250 | + lineMode: service.lineMode, | |
| 251 | + lineColor: service.lineColor, | |
| 252 | + lineTextColor: service.lineTextColor, | |
| 253 | + sortOrder, | |
| 254 | + }; | |
| 255 | + if (displayName !== undefined && boundedString(displayName, LIMITS.labelUtf8Bytes)) { | |
| 256 | + favorite.displayName = displayName; | |
| 257 | + } | |
| 258 | + return favorite; | |
| 259 | +} | |
| 260 | + | |
| 122 | 261 | function renumber(favorites) { |
| 123 | 262 | return favorites.map((favorite, sortOrder) => ({ ...favorite, sortOrder })); |
| 124 | 263 | } |
| @@ -191,6 +330,28 @@ export function reduceConfigState(state, action) { | ||
| 191 | 330 | return { ...state, keyDraft: { value: "", removeRequested: true } }; |
| 192 | 331 | case "key-remove-cancelled": |
| 193 | 332 | return { ...state, keyDraft: { ...state.keyDraft, removeRequested: false } }; |
| 333 | + case "favorite-add": { | |
| 334 | + if (state.favorites.length >= LIMITS.favorites || !isFavoriteShape(action.favorite) | |
| 335 | + || state.favorites.some((favorite) => favorite.id === action.favorite.id)) return state; | |
| 336 | + return { | |
| 337 | + ...state, | |
| 338 | + favorites: renumber([...state.favorites, { ...action.favorite }]), | |
| 339 | + }; | |
| 340 | + } | |
| 341 | + case "favorite-rename": { | |
| 342 | + const displayName = typeof action.displayName === "string" ? action.displayName.trim() : ""; | |
| 343 | + if (displayName.length > 0 && !boundedString(displayName, LIMITS.labelUtf8Bytes)) return state; | |
| 344 | + let changed = false; | |
| 345 | + const favorites = state.favorites.map((favorite) => { | |
| 346 | + if (favorite.id !== action.id) return favorite; | |
| 347 | + changed = true; | |
| 348 | + const renamed = { ...favorite }; | |
| 349 | + if (displayName.length === 0) delete renamed.displayName; | |
| 350 | + else renamed.displayName = displayName; | |
| 351 | + return renamed; | |
| 352 | + }); | |
| 353 | + return changed ? { ...state, favorites } : state; | |
| 354 | + } | |
| 194 | 355 | case "favorite-remove": { |
| 195 | 356 | if (!state.favorites.some((favorite) => favorite.id === action.id)) return state; |
| 196 | 357 | return { |
| @@ -260,8 +421,13 @@ function stampFavorite(favorite, sortOrder) { | ||
| 260 | 421 | stopLabel: favorite.stopLabel, |
| 261 | 422 | lineLabel: favorite.lineLabel, |
| 262 | 423 | destinationLabel: favorite.destinationLabel, |
| 263 | - sortOrder, | |
| 264 | 424 | }; |
| 425 | + if (Object.hasOwn(favorite, "lineMode")) { | |
| 426 | + stamped.lineMode = favorite.lineMode; | |
| 427 | + stamped.lineColor = favorite.lineColor; | |
| 428 | + stamped.lineTextColor = favorite.lineTextColor; | |
| 429 | + } | |
| 430 | + stamped.sortOrder = sortOrder; | |
| 265 | 431 | if (favorite.displayName !== undefined) stamped.displayName = favorite.displayName; |
| 266 | 432 | return stamped; |
| 267 | 433 | } |
| @@ -20,6 +20,9 @@ export const LIMITS = { | |||
| 20 | idUtf8Bytes: 64, | 20 | idUtf8Bytes: 64, |
| 21 | labelUtf8Bytes: 96, | 21 | labelUtf8Bytes: 96, |
| 22 | favorites: 8, | 22 | favorites: 8, |
| 23 | + catalogQueryMinCharacters: 2, | ||
| 24 | + catalogQueryMaxCharacters: 100, | ||
| 25 | + catalogSearchResults: 20, | ||
| 23 | }; | 26 | }; |
| 24 | 27 | ||
| 25 | const FAVORITE_FIELDS = [ | 28 | const FAVORITE_FIELDS = [ |
| @@ -30,6 +33,9 @@ const FAVORITE_FIELDS = [ | |||
| 30 | "stopLabel", | 33 | "stopLabel", |
| 31 | "lineLabel", | 34 | "lineLabel", |
| 32 | "destinationLabel", | 35 | "destinationLabel", |
| 36 | + "lineMode", | ||
| 37 | + "lineColor", | ||
| 38 | + "lineTextColor", | ||
| 33 | "sortOrder", | 39 | "sortOrder", |
| 34 | ]; | 40 | ]; |
| 35 | 41 | ||
| @@ -43,6 +49,10 @@ function boundedString(value, maximum) { | |||
| 43 | return typeof value === "string" && utf8Bytes(value) >= 1 && utf8Bytes(value) <= maximum; | 49 | return typeof value === "string" && utf8Bytes(value) >= 1 && utf8Bytes(value) <= maximum; |
| 44 | } | 50 | } |
| 45 | 51 | ||
| 52 | +export function isLineColor(value) { | ||
| 53 | + return typeof value === "string" && /^#[0-9a-f]{6}$/u.test(value); | ||
| 54 | +} | ||
| 55 | + | ||
| 46 | // --- Locale ----------------------------------------------------------------- | 56 | // --- Locale ----------------------------------------------------------------- |
| 47 | // French authored product copy is selected by any language tag beginning | 57 | // French authored product copy is selected by any language tag beginning |
| 48 | // "fr"; every other (or unknown) value falls back to English. User-authored | 58 | // "fr"; every other (or unknown) value falls back to English. User-authored |
| @@ -55,46 +65,100 @@ export function selectLocale(language) { | |||
| 55 | 65 | ||
| 56 | export const COPY = { | 66 | export const COPY = { |
| 57 | en: { | 67 | en: { |
| 58 | - pageTitle: "Lapin Futé — Settings", | 68 | + pageTitle: "Lapin Futé settings", |
| 59 | - keyTitle: "PRIM API key", | 69 | + intro: "Choose the departures you want at hand on your Pebble.", |
| 60 | - keyStatusConfigured: "A key is configured on your phone.", | 70 | + keyTitle: "PRIM access", |
| 61 | - keyStatusMissing: "No API key is configured.", | 71 | + keyStatusConfigured: "A key is configured on this phone.", |
| 62 | - keyLabel: "API key", | 72 | + keyStatusMissing: "No key is configured.", |
| 63 | - keyPlaceholder: "Enter a new key", | 73 | + keyExplanation: "Your personal key stays in plaintext on your phone. This page never receives the saved key.", |
| 74 | + keyLink: "Create a PRIM token", | ||
| 75 | + keyLabel: "Replace with a new key", | ||
| 76 | + keyPlaceholder: "Paste a new key", | ||
| 64 | keyShow: "Show", | 77 | keyShow: "Show", |
| 65 | keyHide: "Hide", | 78 | keyHide: "Hide", |
| 66 | - keyRemove: "Remove key", | 79 | + keyRemove: "Remove saved key", |
| 67 | - keyUndoRemove: "Keep key", | 80 | + keyUndoRemove: "Keep saved key", |
| 68 | - keyRemovePending: "The key will be removed when you save.", | 81 | + keyRemovePending: "The saved key will be removed.", |
| 69 | - keyErrorTooLong: "The key must be at most 512 bytes.", | 82 | + keyReplacementPending: "A replacement key is ready to save.", |
| 83 | + keyErrorTooLong: "The key must be at most 512 UTF-8 bytes.", | ||
| 70 | keyErrorNewline: "The key must not contain line breaks.", | 84 | keyErrorNewline: "The key must not contain line breaks.", |
| 71 | - favoritesTitle: "Favorites", | 85 | + favoritesTitle: "Favorite departures", |
| 72 | - favoritesEmpty: "No favorites yet.", | 86 | + favoritesEmpty: "No favorites yet. Search for a stop to add one.", |
| 87 | + favoriteAddTitle: "Add a favorite", | ||
| 88 | + searchLabel: "Stop or station", | ||
| 89 | + searchPlaceholder: "Search by name", | ||
| 90 | + searchHint: "Enter at least 2 characters.", | ||
| 91 | + searchLoading: "Searching…", | ||
| 92 | + searchNoResults: "No matching stop or station.", | ||
| 93 | + backendUnavailable: "The service catalog is unavailable. Your edits are safe; try again.", | ||
| 94 | + invalidService: "This service is no longer available. Search again.", | ||
| 95 | + servicesLoading: "Loading lines and directions…", | ||
| 96 | + servicesLabel: "Line and direction", | ||
| 97 | + servicesEmpty: "No service is available for this stop.", | ||
| 98 | + previewRecorded: "Recorded example", | ||
| 99 | + minutesShort: "min", | ||
| 100 | + previewTitle: "Preview", | ||
| 101 | + favoriteNameLabel: "Favorite name (optional)", | ||
| 102 | + favoriteNamePlaceholder: "For example, Home", | ||
| 103 | + favoriteAdd: "Add favorite", | ||
| 104 | + favoriteLimit: "You have reached the 8-favorite limit.", | ||
| 73 | favoriteMoveUp: "Move up", | 105 | favoriteMoveUp: "Move up", |
| 74 | favoriteMoveDown: "Move down", | 106 | favoriteMoveDown: "Move down", |
| 107 | + favoriteRename: "Rename", | ||
| 75 | favoriteRemove: "Remove", | 108 | favoriteRemove: "Remove", |
| 76 | - save: "Save", | 109 | + renamePrompt: "Favorite name", |
| 110 | + save: "Save settings", | ||
| 111 | + aboutOpen: "About", | ||
| 112 | + aboutBack: "Back to settings", | ||
| 113 | + aboutTitle: "About Lapin Futé", | ||
| 77 | }, | 114 | }, |
| 78 | fr: { | 115 | fr: { |
| 79 | - pageTitle: "Lapin Futé — Réglages", | 116 | + pageTitle: "Réglages Lapin Futé", |
| 80 | - keyTitle: "Clé API PRIM", | 117 | + intro: "Choisissez les prochains départs à garder sous la main sur votre Pebble.", |
| 81 | - keyStatusConfigured: "Une clé est configurée sur votre téléphone.", | 118 | + keyTitle: "Accès PRIM", |
| 82 | - keyStatusMissing: "Aucune clé API n’est configurée.", | 119 | + keyStatusConfigured: "Une clé est configurée sur ce téléphone.", |
| 83 | - keyLabel: "Clé API", | 120 | + keyStatusMissing: "Aucune clé n’est configurée.", |
| 84 | - keyPlaceholder: "Saisir une nouvelle clé", | 121 | + keyExplanation: "Votre clé personnelle reste en clair sur votre téléphone. Cette page ne reçoit jamais la clé enregistrée.", |
| 122 | + keyLink: "Créer un jeton PRIM", | ||
| 123 | + keyLabel: "Remplacer par une nouvelle clé", | ||
| 124 | + keyPlaceholder: "Coller une clé", | ||
| 85 | keyShow: "Afficher", | 125 | keyShow: "Afficher", |
| 86 | keyHide: "Masquer", | 126 | keyHide: "Masquer", |
| 87 | - keyRemove: "Supprimer la clé", | 127 | + keyRemove: "Supprimer la clé enregistrée", |
| 88 | - keyUndoRemove: "Conserver la clé", | 128 | + keyUndoRemove: "Conserver la clé enregistrée", |
| 89 | - keyRemovePending: "La clé sera supprimée à l’enregistrement.", | 129 | + keyRemovePending: "La clé enregistrée sera supprimée.", |
| 90 | - keyErrorTooLong: "La clé doit contenir au maximum 512 octets.", | 130 | + keyReplacementPending: "Une nouvelle clé est prête à être enregistrée.", |
| 131 | + keyErrorTooLong: "La clé doit contenir au maximum 512 octets UTF-8.", | ||
| 91 | keyErrorNewline: "La clé ne doit pas contenir de retour à la ligne.", | 132 | keyErrorNewline: "La clé ne doit pas contenir de retour à la ligne.", |
| 92 | - favoritesTitle: "Favoris", | 133 | + favoritesTitle: "Départs favoris", |
| 93 | - favoritesEmpty: "Aucun favori pour le moment.", | 134 | + favoritesEmpty: "Aucun favori. Recherchez un arrêt pour en ajouter un.", |
| 135 | + favoriteAddTitle: "Ajouter un favori", | ||
| 136 | + searchLabel: "Arrêt ou gare", | ||
| 137 | + searchPlaceholder: "Rechercher par nom", | ||
| 138 | + searchHint: "Saisissez au moins 2 caractères.", | ||
| 139 | + searchLoading: "Recherche…", | ||
| 140 | + searchNoResults: "Aucun arrêt ni gare ne correspond.", | ||
| 141 | + backendUnavailable: "Le catalogue est indisponible. Vos modifications sont conservées ; réessayez.", | ||
| 142 | + invalidService: "Ce service n’est plus disponible. Relancez la recherche.", | ||
| 143 | + servicesLoading: "Chargement des lignes et directions…", | ||
| 144 | + servicesLabel: "Ligne et direction", | ||
| 145 | + servicesEmpty: "Aucun service n’est disponible pour cet arrêt.", | ||
| 146 | + previewRecorded: "Exemple enregistré", | ||
| 147 | + minutesShort: "min", | ||
| 148 | + previewTitle: "Aperçu", | ||
| 149 | + favoriteNameLabel: "Nom du favori (facultatif)", | ||
| 150 | + favoriteNamePlaceholder: "Par exemple, Maison", | ||
| 151 | + favoriteAdd: "Ajouter le favori", | ||
| 152 | + favoriteLimit: "Vous avez atteint la limite de 8 favoris.", | ||
| 94 | favoriteMoveUp: "Monter", | 153 | favoriteMoveUp: "Monter", |
| 95 | favoriteMoveDown: "Descendre", | 154 | favoriteMoveDown: "Descendre", |
| 155 | + favoriteRename: "Renommer", | ||
| 96 | favoriteRemove: "Supprimer", | 156 | favoriteRemove: "Supprimer", |
| 97 | - save: "Enregistrer", | 157 | + renamePrompt: "Nom du favori", |
| 158 | + save: "Enregistrer les réglages", | ||
| 159 | + aboutOpen: "À propos", | ||
| 160 | + aboutBack: "Retour aux réglages", | ||
| 161 | + aboutTitle: "À propos de Lapin Futé", | ||
| 98 | }, | 162 | }, |
| 99 | }; | 163 | }; |
| 100 | 164 | ||
| @@ -107,6 +171,10 @@ export function copyFor(language) { | |||
| 107 | export function isFavoriteShape(value) { | 171 | export function isFavoriteShape(value) { |
| 108 | if (typeof value !== "object" || value === null || Array.isArray(value)) return false; | 172 | if (typeof value !== "object" || value === null || Array.isArray(value)) return false; |
| 109 | if (!Object.keys(value).every((field) => FAVORITE_FIELDS.includes(field))) return false; | 173 | if (!Object.keys(value).every((field) => FAVORITE_FIELDS.includes(field))) return false; |
| 174 | + const hasLineMode = Object.hasOwn(value, "lineMode"); | ||
| 175 | + const hasLineColor = Object.hasOwn(value, "lineColor"); | ||
| 176 | + const hasLineTextColor = Object.hasOwn(value, "lineTextColor"); | ||
| 177 | + if (hasLineMode !== hasLineColor || hasLineMode !== hasLineTextColor) return false; | ||
| 110 | return ( | 178 | return ( |
| 111 | value.schemaVersion === SCHEMA_VERSION && | 179 | value.schemaVersion === SCHEMA_VERSION && |
| 112 | boundedString(value.id, LIMITS.idUtf8Bytes) && | 180 | boundedString(value.id, LIMITS.idUtf8Bytes) && |
| @@ -115,10 +183,81 @@ export function isFavoriteShape(value) { | |||
| 115 | boundedString(value.stopLabel, LIMITS.labelUtf8Bytes) && | 183 | boundedString(value.stopLabel, LIMITS.labelUtf8Bytes) && |
| 116 | boundedString(value.lineLabel, LIMITS.labelUtf8Bytes) && | 184 | boundedString(value.lineLabel, LIMITS.labelUtf8Bytes) && |
| 117 | boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes) && | 185 | boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes) && |
| 186 | + (!hasLineMode || ( | ||
| 187 | + TRANSPORT_MODES.includes(value.lineMode) && | ||
| 188 | + isLineColor(value.lineColor) && | ||
| 189 | + isLineColor(value.lineTextColor) | ||
| 190 | + )) && | ||
| 118 | Number.isInteger(value.sortOrder) | 191 | Number.isInteger(value.sortOrder) |
| 119 | ); | 192 | ); |
| 120 | } | 193 | } |
| 121 | 194 | ||
| 195 | +const PLACE_FIELDS = ["placeId", "stopLabel", "localityLabel", "mode"]; | ||
| 196 | +const SERVICE_FIELDS = ["serviceId", "stopLabel", "lineLabel", "destinationLabel", "lineMode", "lineColor", "lineTextColor"]; | ||
| 197 | +const TRANSPORT_MODES = ["BUS", "METRO", "TRAM", "RER", "TRANSILIEN"]; | ||
| 198 | + | ||
| 199 | +function exactFields(value, allowed, required) { | ||
| 200 | + return typeof value === "object" && value !== null && !Array.isArray(value) | ||
| 201 | + && Object.keys(value).every((field) => allowed.includes(field)) | ||
| 202 | + && required.every((field) => Object.hasOwn(value, field)); | ||
| 203 | +} | ||
| 204 | + | ||
| 205 | +export function isPlaceSearchItem(value) { | ||
| 206 | + return exactFields(value, PLACE_FIELDS, ["placeId", "stopLabel", "mode"]) | ||
| 207 | + && boundedString(value.placeId, LIMITS.idUtf8Bytes) | ||
| 208 | + && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes) | ||
| 209 | + && (value.localityLabel === undefined || boundedString(value.localityLabel, LIMITS.labelUtf8Bytes)) | ||
| 210 | + && TRANSPORT_MODES.includes(value.mode); | ||
| 211 | +} | ||
| 212 | + | ||
| 213 | +export function isPlaceSearchResult(value) { | ||
| 214 | + return exactFields(value, ["schemaVersion", "places"], ["schemaVersion", "places"]) | ||
| 215 | + && value.schemaVersion === SCHEMA_VERSION | ||
| 216 | + && Array.isArray(value.places) | ||
| 217 | + && value.places.length <= LIMITS.catalogSearchResults | ||
| 218 | + && value.places.every(isPlaceSearchItem); | ||
| 219 | +} | ||
| 220 | + | ||
| 221 | +export function isServiceOption(value) { | ||
| 222 | + return exactFields(value, SERVICE_FIELDS, SERVICE_FIELDS) | ||
| 223 | + && boundedString(value.serviceId, LIMITS.idUtf8Bytes) | ||
| 224 | + && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes) | ||
| 225 | + && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes) | ||
| 226 | + && boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes) | ||
| 227 | + && TRANSPORT_MODES.includes(value.lineMode) | ||
| 228 | + && isLineColor(value.lineColor) | ||
| 229 | + && isLineColor(value.lineTextColor); | ||
| 230 | +} | ||
| 231 | + | ||
| 232 | +export function isServiceOptionsResult(value, placeId) { | ||
| 233 | + return exactFields(value, ["schemaVersion", "placeId", "services"], ["schemaVersion", "placeId", "services"]) | ||
| 234 | + && value.schemaVersion === SCHEMA_VERSION | ||
| 235 | + && value.placeId === placeId | ||
| 236 | + && Array.isArray(value.services) | ||
| 237 | + && value.services.every(isServiceOption); | ||
| 238 | +} | ||
| 239 | + | ||
| 240 | +export function favoriteFromService(id, service, sortOrder, displayName = undefined) { | ||
| 241 | + if (!boundedString(id, LIMITS.idUtf8Bytes) || !isServiceOption(service) | ||
| 242 | + || !Number.isInteger(sortOrder) || sortOrder < 0) return null; | ||
| 243 | + const favorite = { | ||
| 244 | + schemaVersion: SCHEMA_VERSION, | ||
| 245 | + id, | ||
| 246 | + serviceId: service.serviceId, | ||
| 247 | + stopLabel: service.stopLabel, | ||
| 248 | + lineLabel: service.lineLabel, | ||
| 249 | + destinationLabel: service.destinationLabel, | ||
| 250 | + lineMode: service.lineMode, | ||
| 251 | + lineColor: service.lineColor, | ||
| 252 | + lineTextColor: service.lineTextColor, | ||
| 253 | + sortOrder, | ||
| 254 | + }; | ||
| 255 | + if (displayName !== undefined && boundedString(displayName, LIMITS.labelUtf8Bytes)) { | ||
| 256 | + favorite.displayName = displayName; | ||
| 257 | + } | ||
| 258 | + return favorite; | ||
| 259 | +} | ||
| 260 | + | ||
| 122 | function renumber(favorites) { | 261 | function renumber(favorites) { |
| 123 | return favorites.map((favorite, sortOrder) => ({ ...favorite, sortOrder })); | 262 | return favorites.map((favorite, sortOrder) => ({ ...favorite, sortOrder })); |
| 124 | } | 263 | } |
| @@ -191,6 +330,28 @@ export function reduceConfigState(state, action) { | |||
| 191 | return { ...state, keyDraft: { value: "", removeRequested: true } }; | 330 | return { ...state, keyDraft: { value: "", removeRequested: true } }; |
| 192 | case "key-remove-cancelled": | 331 | case "key-remove-cancelled": |
| 193 | return { ...state, keyDraft: { ...state.keyDraft, removeRequested: false } }; | 332 | return { ...state, keyDraft: { ...state.keyDraft, removeRequested: false } }; |
| 333 | + case "favorite-add": { | ||
| 334 | + if (state.favorites.length >= LIMITS.favorites || !isFavoriteShape(action.favorite) | ||
| 335 | + || state.favorites.some((favorite) => favorite.id === action.favorite.id)) return state; | ||
| 336 | + return { | ||
| 337 | + ...state, | ||
| 338 | + favorites: renumber([...state.favorites, { ...action.favorite }]), | ||
| 339 | + }; | ||
| 340 | + } | ||
| 341 | + case "favorite-rename": { | ||
| 342 | + const displayName = typeof action.displayName === "string" ? action.displayName.trim() : ""; | ||
| 343 | + if (displayName.length > 0 && !boundedString(displayName, LIMITS.labelUtf8Bytes)) return state; | ||
| 344 | + let changed = false; | ||
| 345 | + const favorites = state.favorites.map((favorite) => { | ||
| 346 | + if (favorite.id !== action.id) return favorite; | ||
| 347 | + changed = true; | ||
| 348 | + const renamed = { ...favorite }; | ||
| 349 | + if (displayName.length === 0) delete renamed.displayName; | ||
| 350 | + else renamed.displayName = displayName; | ||
| 351 | + return renamed; | ||
| 352 | + }); | ||
| 353 | + return changed ? { ...state, favorites } : state; | ||
| 354 | + } | ||
| 194 | case "favorite-remove": { | 355 | case "favorite-remove": { |
| 195 | if (!state.favorites.some((favorite) => favorite.id === action.id)) return state; | 356 | if (!state.favorites.some((favorite) => favorite.id === action.id)) return state; |
| 196 | return { | 357 | return { |
| @@ -260,8 +421,13 @@ function stampFavorite(favorite, sortOrder) { | |||
| 260 | stopLabel: favorite.stopLabel, | 421 | stopLabel: favorite.stopLabel, |
| 261 | lineLabel: favorite.lineLabel, | 422 | lineLabel: favorite.lineLabel, |
| 262 | destinationLabel: favorite.destinationLabel, | 423 | destinationLabel: favorite.destinationLabel, |
| 263 | - sortOrder, | ||
| 264 | }; | 424 | }; |
| 425 | + if (Object.hasOwn(favorite, "lineMode")) { | ||
| 426 | + stamped.lineMode = favorite.lineMode; | ||
| 427 | + stamped.lineColor = favorite.lineColor; | ||
| 428 | + stamped.lineTextColor = favorite.lineTextColor; | ||
| 429 | + } | ||
| 430 | + stamped.sortOrder = sortOrder; | ||
| 265 | if (favorite.displayName !== undefined) stamped.displayName = favorite.displayName; | 431 | if (favorite.displayName !== undefined) stamped.displayName = favorite.displayName; |
| 266 | return stamped; | 432 | return stamped; |
| 267 | } | 433 | } |
modified
packages/config-page/src/config-page.js +350 -128 | @@ -1,177 +1,399 @@ | ||
| 1 | -// DOM wiring for the Lapin Futé configuration page. All state transitions and | |
| 2 | -// serialization live in config-core.js; this layer only renders and dispatches. | |
| 3 | -// | |
| 4 | -// Secret boundary: the personal PRIM key exists only in the masked input's | |
| 5 | -// value. The page holds no storage of any kind, performs no network requests, | |
| 6 | -// and never logs; its single navigation is the one-time close fragment, which | |
| 7 | -// the mobile app intercepts before any HTTP request could exist. | |
| 8 | -// | |
| 9 | -// The opening fragment carries only non-secret fields: hasKey, the watch | |
| 10 | -// language, and the current favorites list. The stored key itself is never | |
| 11 | -// present in the fragment, the DOM, or this source. | |
| 12 | - | |
| 13 | 1 | import { |
| 2 | + LIMITS, | |
| 3 | + apiKeyError, | |
| 14 | 4 | copyFor, |
| 15 | 5 | createCloseSession, |
| 6 | + favoriteFromService, | |
| 16 | 7 | initialConfigState, |
| 17 | 8 | parseConfigFragment, |
| 18 | 9 | planConfigResult, |
| 19 | 10 | reduceConfigState, |
| 11 | + utf8Bytes, | |
| 20 | 12 | } from "./config-core.js"; |
| 13 | +import { CatalogClientError, createCatalogClient } from "./catalog-client.js"; | |
| 14 | +import { RECORDED_PREVIEW } from "./preview-fixture.js"; | |
| 15 | +import { lineBadgeAssetUrl } from "./line-badge-assets.js"; | |
| 21 | 16 | |
| 22 | 17 | function byId(id) { |
| 23 | 18 | return document.getElementById(id); |
| 24 | 19 | } |
| 25 | 20 | |
| 26 | -const elements = { | |
| 27 | - pageTitle: byId("page-title"), | |
| 28 | - keyTitle: byId("key-title"), | |
| 29 | - keyStatus: byId("key-status"), | |
| 30 | - keyLabel: byId("key-label"), | |
| 31 | - keyInput: byId("key-input"), | |
| 32 | - keyToggle: byId("key-toggle"), | |
| 33 | - keyError: byId("key-error"), | |
| 34 | - keyPending: byId("key-pending"), | |
| 35 | - keyRemove: byId("key-remove"), | |
| 36 | - favoritesTitle: byId("favorites-title"), | |
| 37 | - favoritesEmpty: byId("favorites-empty"), | |
| 38 | - favoritesList: byId("favorites-list"), | |
| 39 | - save: byId("save"), | |
| 40 | -}; | |
| 41 | - | |
| 42 | -const query = parseConfigFragment(window.location.hash); | |
| 43 | -const copy = copyFor(query.language); | |
| 44 | -let state = initialConfigState(query); | |
| 45 | -let revealed = false; | |
| 46 | -const session = createCloseSession(); | |
| 47 | - | |
| 48 | -function setText(element, text) { | |
| 49 | - if (element.textContent !== text) { | |
| 50 | - element.textContent = text; | |
| 21 | +const elements = Object.fromEntries([ | |
| 22 | + "page-title", "intro", "key-title", "key-status", "key-explanation", "key-link", "key-label", | |
| 23 | + "key-input", "key-toggle", "key-error", "key-pending", "key-remove", "favorites-title", | |
| 24 | + "favorites-empty", "favorites-list", "add-title", "search-label", "place-search", "search-hint", | |
| 25 | + "catalog-status", "place-results", "service-step", "services-label", "service-select", "preview", | |
| 26 | + "preview-title", "preview-line", "preview-stop", "preview-destination", "preview-departures", | |
| 27 | + "favorite-name-label", "favorite-name", "favorite-add", "add-section", "save", "config-view", | |
| 28 | + "config-footer", "about-open", "about-view", "about-back", "about-title", "about-en", "about-fr", | |
| 29 | +].map((id) => [id.replaceAll("-", "_"), byId(id)])); | |
| 30 | + | |
| 31 | +const opening = parseConfigFragment(window.location.hash); | |
| 32 | +const copy = copyFor(opening.language); | |
| 33 | +const catalog = createCatalogClient(); | |
| 34 | +const closeSession = createCloseSession(); | |
| 35 | +let state = initialConfigState(opening); | |
| 36 | +let places = []; | |
| 37 | +let services = []; | |
| 38 | +let selectedPlace = null; | |
| 39 | +let selectedService = null; | |
| 40 | +let serviceController = null; | |
| 41 | +let searchGeneration = 0; | |
| 42 | +let idSequence = 0; | |
| 43 | +let keyRevealed = false; | |
| 44 | + | |
| 45 | +let aboutReturnFocus = elements.about_open; | |
| 46 | +function setText(element, value) { | |
| 47 | + if (element.textContent !== value) element.textContent = value; | |
| 48 | +} | |
| 49 | + | |
| 50 | +const NEUTRAL_LINE_BACKGROUND = "#52616f"; | |
| 51 | +const NEUTRAL_LINE_TEXT = "#ffffff"; | |
| 52 | + | |
| 53 | +function fallbackLineBadge(service) { | |
| 54 | + const namespace = "http://www.w3.org/2000/svg"; | |
| 55 | + const width = Math.max(40, 18 + (Array.from(service.lineLabel).length * 12)); | |
| 56 | + const backgroundColor = service.lineColor ?? NEUTRAL_LINE_BACKGROUND; | |
| 57 | + const textColor = service.lineTextColor ?? NEUTRAL_LINE_TEXT; | |
| 58 | + const svg = document.createElementNS(namespace, "svg"); | |
| 59 | + svg.setAttribute("viewBox", `0 0 ${width} 40`); | |
| 60 | + svg.setAttribute("role", "img"); | |
| 61 | + svg.setAttribute("aria-label", service.lineLabel); | |
| 62 | + svg.setAttribute("focusable", "false"); | |
| 63 | + const title = document.createElementNS(namespace, "title"); | |
| 64 | + title.textContent = service.lineLabel; | |
| 65 | + const background = document.createElementNS(namespace, "rect"); | |
| 66 | + background.setAttribute("width", String(width)); | |
| 67 | + background.setAttribute("height", "40"); | |
| 68 | + background.setAttribute("rx", "6"); | |
| 69 | + background.setAttribute("fill", backgroundColor); | |
| 70 | + const label = document.createElementNS(namespace, "text"); | |
| 71 | + label.setAttribute("x", String(width / 2)); | |
| 72 | + label.setAttribute("y", "21"); | |
| 73 | + label.setAttribute("text-anchor", "middle"); | |
| 74 | + label.setAttribute("dominant-baseline", "middle"); | |
| 75 | + label.setAttribute("fill", textColor); | |
| 76 | + label.setAttribute("font-family", "system-ui, sans-serif"); | |
| 77 | + label.setAttribute("font-size", "18"); | |
| 78 | + label.setAttribute("font-weight", "800"); | |
| 79 | + label.textContent = service.lineLabel; | |
| 80 | + svg.append(title, background, label); | |
| 81 | + return svg; | |
| 82 | +} | |
| 83 | + | |
| 84 | +function renderLineBadge(container, service) { | |
| 85 | + container.className = "line-badge"; | |
| 86 | + const assetUrl = service.lineMode === undefined | |
| 87 | + ? undefined | |
| 88 | + : lineBadgeAssetUrl(service.lineMode, service.lineLabel); | |
| 89 | + if (assetUrl === undefined) { | |
| 90 | + container.replaceChildren(fallbackLineBadge(service)); | |
| 91 | + return; | |
| 51 | 92 | } |
| 93 | + const image = document.createElement("img"); | |
| 94 | + image.alt = service.lineLabel; | |
| 95 | + image.src = assetUrl; | |
| 96 | + image.addEventListener("error", () => { | |
| 97 | + if (image.parentNode === container) container.replaceChildren(fallbackLineBadge(service)); | |
| 98 | + }, { once: true }); | |
| 99 | + container.replaceChildren(image); | |
| 52 | 100 | } |
| 53 | 101 | |
| 54 | 102 | function dispatch(action) { |
| 55 | - const next = reduceConfigState(state, action); | |
| 56 | - if (next !== state) { | |
| 57 | - state = next; | |
| 58 | - } | |
| 59 | - render(); | |
| 103 | + state = reduceConfigState(state, action); | |
| 104 | + renderKey(); | |
| 105 | + renderFavorites(); | |
| 60 | 106 | } |
| 61 | 107 | |
| 62 | -function applyStaticCopy() { | |
| 108 | +function applyCopy() { | |
| 63 | 109 | document.title = copy.pageTitle; |
| 64 | - document.documentElement.lang = query.locale; | |
| 65 | - setText(elements.pageTitle, copy.pageTitle); | |
| 66 | - setText(elements.keyTitle, copy.keyTitle); | |
| 67 | - setText(elements.keyLabel, copy.keyLabel); | |
| 68 | - elements.keyInput.placeholder = copy.keyPlaceholder; | |
| 69 | - setText(elements.keyToggle, copy.keyShow); | |
| 70 | - setText(elements.favoritesTitle, copy.favoritesTitle); | |
| 71 | - setText(elements.favoritesEmpty, copy.favoritesEmpty); | |
| 72 | - setText(elements.save, copy.save); | |
| 110 | + document.documentElement.lang = opening.locale; | |
| 111 | + for (const [key, element] of [ | |
| 112 | + ["pageTitle", elements.page_title], ["intro", elements.intro], ["keyTitle", elements.key_title], | |
| 113 | + ["keyExplanation", elements.key_explanation], ["keyLink", elements.key_link], | |
| 114 | + ["keyLabel", elements.key_label], ["favoritesTitle", elements.favorites_title], | |
| 115 | + ["favoritesEmpty", elements.favorites_empty], ["favoriteAddTitle", elements.add_title], | |
| 116 | + ["searchLabel", elements.search_label], ["searchHint", elements.search_hint], | |
| 117 | + ["servicesLabel", elements.services_label], ["previewTitle", elements.preview_title], | |
| 118 | + ["favoriteNameLabel", elements.favorite_name_label], ["favoriteAdd", elements.favorite_add], | |
| 119 | + ["save", elements.save], ["aboutOpen", elements.about_open], ["aboutBack", elements.about_back], | |
| 120 | + ["aboutTitle", elements.about_title], | |
| 121 | + ]) setText(element, copy[key]); | |
| 122 | + elements.key_input.placeholder = copy.keyPlaceholder; | |
| 123 | + elements.place_search.placeholder = copy.searchPlaceholder; | |
| 124 | + elements.favorite_name.placeholder = copy.favoriteNamePlaceholder; | |
| 125 | + elements.about_en.hidden = opening.locale !== "en"; | |
| 126 | + elements.about_fr.hidden = opening.locale !== "fr"; | |
| 73 | 127 | } |
| 74 | 128 | |
| 75 | -function render() { | |
| 76 | - renderKeySection(); | |
| 77 | - renderFavorites(); | |
| 129 | +function renderKey() { | |
| 130 | + setText(elements.key_status, state.hasKey ? copy.keyStatusConfigured : copy.keyStatusMissing); | |
| 131 | + const removing = state.keyDraft.removeRequested; | |
| 132 | + const replacing = state.keyDraft.value.length > 0; | |
| 133 | + elements.key_pending.hidden = !removing && !replacing; | |
| 134 | + if (removing) setText(elements.key_pending, copy.keyRemovePending); | |
| 135 | + else if (replacing) setText(elements.key_pending, copy.keyReplacementPending); | |
| 136 | + elements.key_remove.hidden = !state.hasKey; | |
| 137 | + setText(elements.key_remove, removing ? copy.keyUndoRemove : copy.keyRemove); | |
| 138 | + setText(elements.key_toggle, keyRevealed ? copy.keyHide : copy.keyShow); | |
| 78 | 139 | } |
| 79 | 140 | |
| 80 | -function renderKeySection() { | |
| 81 | - setText(elements.keyStatus, state.hasKey ? copy.keyStatusConfigured : copy.keyStatusMissing); | |
| 82 | - const pending = state.keyDraft.removeRequested; | |
| 83 | - elements.keyPending.hidden = !pending; | |
| 84 | - if (pending) { | |
| 85 | - setText(elements.keyPending, copy.keyRemovePending); | |
| 86 | - } | |
| 87 | - elements.keyRemove.hidden = !state.hasKey; | |
| 88 | - setText(elements.keyRemove, pending ? copy.keyUndoRemove : copy.keyRemove); | |
| 141 | +function actionButton(label, disabled, action, className = "quiet") { | |
| 142 | + const button = document.createElement("button"); | |
| 143 | + button.type = "button"; | |
| 144 | + button.className = className; | |
| 145 | + button.textContent = label; | |
| 146 | + button.disabled = disabled; | |
| 147 | + button.addEventListener("click", action); | |
| 148 | + return button; | |
| 89 | 149 | } |
| 90 | 150 | |
| 91 | 151 | function renderFavorites() { |
| 92 | - elements.favoritesList.replaceChildren(); | |
| 93 | - elements.favoritesEmpty.hidden = state.favorites.length > 0; | |
| 152 | + elements.favorites_list.replaceChildren(); | |
| 153 | + elements.favorites_empty.hidden = state.favorites.length > 0; | |
| 154 | + elements.add_section.hidden = state.favorites.length >= LIMITS.favorites; | |
| 155 | + if (state.favorites.length >= LIMITS.favorites) setText(elements.catalog_status, copy.favoriteLimit); | |
| 156 | + else if (elements.catalog_status.textContent === copy.favoriteLimit) setText(elements.catalog_status, ""); | |
| 157 | + | |
| 94 | 158 | state.favorites.forEach((favorite, index) => { |
| 95 | - elements.favoritesList.append(favoriteItem(favorite, index)); | |
| 159 | + const item = document.createElement("li"); | |
| 160 | + item.className = "favorite"; | |
| 161 | + const route = document.createElement("span"); | |
| 162 | + renderLineBadge(route, favorite); | |
| 163 | + const labels = document.createElement("div"); | |
| 164 | + labels.className = "favorite-labels"; | |
| 165 | + const stop = document.createElement("strong"); | |
| 166 | + stop.textContent = favorite.stopLabel; | |
| 167 | + const destination = document.createElement("span"); | |
| 168 | + destination.textContent = favorite.destinationLabel; | |
| 169 | + labels.append(stop, destination); | |
| 170 | + | |
| 171 | + const renameLabel = document.createElement("label"); | |
| 172 | + renameLabel.className = "visually-hidden"; | |
| 173 | + const renameId = `favorite-name-${index}`; | |
| 174 | + renameLabel.htmlFor = renameId; | |
| 175 | + renameLabel.textContent = copy.favoriteRename; | |
| 176 | + const rename = document.createElement("input"); | |
| 177 | + rename.id = renameId; | |
| 178 | + rename.type = "text"; | |
| 179 | + rename.autocomplete = "off"; | |
| 180 | + rename.maxLength = LIMITS.labelUtf8Bytes; | |
| 181 | + rename.value = favorite.displayName ?? ""; | |
| 182 | + rename.placeholder = copy.favoriteNamePlaceholder; | |
| 183 | + rename.addEventListener("change", () => { | |
| 184 | + if (utf8Bytes(rename.value.trim()) <= LIMITS.labelUtf8Bytes) { | |
| 185 | + dispatch({ type: "favorite-rename", id: favorite.id, displayName: rename.value }); | |
| 186 | + } else { | |
| 187 | + rename.value = favorite.displayName ?? ""; | |
| 188 | + } | |
| 189 | + }); | |
| 190 | + | |
| 191 | + | |
| 192 | + const actions = document.createElement("div"); | |
| 193 | + actions.className = "favorite-actions"; | |
| 194 | + actions.append( | |
| 195 | + actionButton(copy.favoriteMoveUp, index === 0, () => dispatch({ type: "favorite-move", id: favorite.id, delta: -1 })), | |
| 196 | + actionButton(copy.favoriteMoveDown, index === state.favorites.length - 1, () => dispatch({ type: "favorite-move", id: favorite.id, delta: 1 })), | |
| 197 | + actionButton(copy.favoriteRemove, false, () => dispatch({ type: "favorite-remove", id: favorite.id }), "quiet danger"), | |
| 198 | + ); | |
| 199 | + item.append(route, labels, renameLabel, rename, actions); | |
| 200 | + elements.favorites_list.append(item); | |
| 96 | 201 | }); |
| 97 | 202 | } |
| 98 | 203 | |
| 99 | -function favoriteItem(favorite, index) { | |
| 100 | - const item = document.createElement("li"); | |
| 101 | - item.className = "favorite"; | |
| 102 | - | |
| 103 | - const heading = document.createElement("p"); | |
| 104 | - heading.className = "labels"; | |
| 105 | - heading.textContent = favorite.displayName ?? favorite.stopLabel; | |
| 106 | - | |
| 107 | - const detail = document.createElement("p"); | |
| 108 | - detail.className = "sub"; | |
| 109 | - // Authored and transport labels cross every locale verbatim. | |
| 110 | - detail.textContent = [favorite.stopLabel, favorite.lineLabel, favorite.destinationLabel].join(" · "); | |
| 111 | - | |
| 112 | - const actions = document.createElement("div"); | |
| 113 | - actions.className = "favorite-actions"; | |
| 114 | - actions.append( | |
| 115 | - listButton(copy.favoriteMoveUp, index === 0, () => | |
| 116 | - dispatch({ type: "favorite-move", id: favorite.id, delta: -1 }), | |
| 117 | - ), | |
| 118 | - listButton(copy.favoriteMoveDown, index === state.favorites.length - 1, () => | |
| 119 | - dispatch({ type: "favorite-move", id: favorite.id, delta: 1 }), | |
| 120 | - ), | |
| 121 | - listButton(copy.favoriteRemove, false, () => | |
| 122 | - dispatch({ type: "favorite-remove", id: favorite.id }), | |
| 123 | - ), | |
| 124 | - ); | |
| 204 | +function clearServiceSelection() { | |
| 205 | + if (serviceController !== null) serviceController.abort(); | |
| 206 | + serviceController = null; | |
| 207 | + services = []; | |
| 208 | + selectedPlace = null; | |
| 209 | + selectedService = null; | |
| 210 | + elements.service_step.hidden = true; | |
| 211 | + elements.preview.hidden = true; | |
| 212 | + elements.service_select.replaceChildren(); | |
| 213 | +} | |
| 125 | 214 | |
| 126 | - item.append(heading, detail, actions); | |
| 127 | - return item; | |
| 215 | +function renderPlaces() { | |
| 216 | + elements.place_results.replaceChildren(); | |
| 217 | + for (const place of places) { | |
| 218 | + const item = document.createElement("li"); | |
| 219 | + const label = place.localityLabel | |
| 220 | + ? `${place.stopLabel} — ${place.localityLabel} · ${place.mode}` | |
| 221 | + : `${place.stopLabel} · ${place.mode}`; | |
| 222 | + item.append(actionButton(label, false, () => selectPlace(place), "choice")); | |
| 223 | + elements.place_results.append(item); | |
| 224 | + } | |
| 128 | 225 | } |
| 129 | 226 | |
| 130 | -function listButton(label, disabled, onClick) { | |
| 131 | - const button = document.createElement("button"); | |
| 132 | - button.type = "button"; | |
| 133 | - button.textContent = label; | |
| 134 | - button.disabled = disabled; | |
| 135 | - button.addEventListener("click", onClick); | |
| 136 | - return button; | |
| 227 | +async function selectPlace(place) { | |
| 228 | + clearServiceSelection(); | |
| 229 | + selectedPlace = place; | |
| 230 | + serviceController = new AbortController(); | |
| 231 | + const controller = serviceController; | |
| 232 | + setText(elements.catalog_status, copy.servicesLoading); | |
| 233 | + try { | |
| 234 | + services = await catalog.listServices(place.placeId, controller.signal); | |
| 235 | + if (controller.signal.aborted) return; | |
| 236 | + serviceController = null; | |
| 237 | + if (services.length === 0) { | |
| 238 | + setText(elements.catalog_status, copy.servicesEmpty); | |
| 239 | + return; | |
| 240 | + } | |
| 241 | + elements.service_select.replaceChildren(); | |
| 242 | + const prompt = document.createElement("option"); | |
| 243 | + prompt.value = ""; | |
| 244 | + prompt.textContent = copy.servicesLabel; | |
| 245 | + elements.service_select.append(prompt); | |
| 246 | + services.forEach((service, index) => { | |
| 247 | + const option = document.createElement("option"); | |
| 248 | + option.value = String(index); | |
| 249 | + option.textContent = `${service.lineLabel} · ${service.destinationLabel}`; | |
| 250 | + elements.service_select.append(option); | |
| 251 | + }); | |
| 252 | + elements.service_step.hidden = false; | |
| 253 | + setText(elements.catalog_status, ""); | |
| 254 | + elements.service_select.focus(); | |
| 255 | + } catch (error) { | |
| 256 | + if (controller.signal.aborted) return; | |
| 257 | + serviceController = null; | |
| 258 | + setText(elements.catalog_status, error instanceof CatalogClientError && error.code === "INVALID_SERVICE" | |
| 259 | + ? copy.invalidService | |
| 260 | + : copy.backendUnavailable); | |
| 261 | + } | |
| 262 | +} | |
| 263 | + | |
| 264 | +function renderPreview() { | |
| 265 | + elements.preview.hidden = selectedService === null; | |
| 266 | + if (selectedService === null) return; | |
| 267 | + renderLineBadge(elements.preview_line, selectedService); | |
| 268 | + setText(elements.preview_stop, selectedService.stopLabel); | |
| 269 | + setText(elements.preview_destination, selectedService.destinationLabel); | |
| 270 | + setText( | |
| 271 | + elements.preview_departures, | |
| 272 | + `${copy.previewRecorded}: ${RECORDED_PREVIEW.map((entry) => `${entry.minutes} ${copy.minutesShort}`).join(" · ")}`, | |
| 273 | + ); | |
| 274 | +} | |
| 275 | + | |
| 276 | +function nextFavoriteId() { | |
| 277 | + let candidate; | |
| 278 | + do { | |
| 279 | + idSequence += 1; | |
| 280 | + candidate = `cfg-${Date.now().toString(36)}-${idSequence.toString(36)}`; | |
| 281 | + } while (state.favorites.some((favorite) => favorite.id === candidate)); | |
| 282 | + return candidate; | |
| 137 | 283 | } |
| 138 | 284 | |
| 139 | -elements.keyInput.addEventListener("input", () => { | |
| 140 | - elements.keyError.hidden = true; | |
| 141 | - dispatch({ type: "key-draft", value: elements.keyInput.value }); | |
| 285 | +elements.key_input.addEventListener("input", () => { | |
| 286 | + elements.key_error.hidden = true; | |
| 287 | + dispatch({ type: "key-draft", value: elements.key_input.value }); | |
| 142 | 288 | }); |
| 143 | 289 | |
| 144 | -elements.keyToggle.addEventListener("click", () => { | |
| 145 | - revealed = !revealed; | |
| 146 | - elements.keyInput.type = revealed ? "text" : "password"; | |
| 147 | - setText(elements.keyToggle, revealed ? copy.keyHide : copy.keyShow); | |
| 148 | - elements.keyToggle.setAttribute("aria-pressed", String(revealed)); | |
| 149 | - elements.keyInput.focus(); | |
| 290 | +elements.key_toggle.addEventListener("click", () => { | |
| 291 | + keyRevealed = !keyRevealed; | |
| 292 | + elements.key_input.type = keyRevealed ? "text" : "password"; | |
| 293 | + elements.key_toggle.setAttribute("aria-pressed", String(keyRevealed)); | |
| 294 | + renderKey(); | |
| 295 | + elements.key_input.focus(); | |
| 150 | 296 | }); |
| 151 | 297 | |
| 152 | -elements.keyRemove.addEventListener("click", () => { | |
| 153 | - dispatch( | |
| 154 | - state.keyDraft.removeRequested | |
| 155 | - ? { type: "key-remove-cancelled" } | |
| 156 | - : { type: "key-remove-requested" }, | |
| 157 | - ); | |
| 298 | +elements.key_remove.addEventListener("click", () => { | |
| 299 | + elements.key_input.value = ""; | |
| 300 | + dispatch(state.keyDraft.removeRequested | |
| 301 | + ? { type: "key-remove-cancelled" } | |
| 302 | + : { type: "key-remove-requested" }); | |
| 158 | 303 | }); |
| 159 | 304 | |
| 160 | -elements.save.addEventListener("click", () => { | |
| 161 | - const outcome = planConfigResult(state); | |
| 162 | - if (!outcome.ok) { | |
| 163 | - elements.keyError.textContent = copy[outcome.error]; | |
| 164 | - elements.keyError.hidden = false; | |
| 165 | - elements.keyInput.focus(); | |
| 305 | +elements.place_search.addEventListener("input", async () => { | |
| 306 | + const generation = ++searchGeneration; | |
| 307 | + clearServiceSelection(); | |
| 308 | + places = []; | |
| 309 | + renderPlaces(); | |
| 310 | + const query = elements.place_search.value.trim(); | |
| 311 | + const length = Array.from(query).length; | |
| 312 | + if (length < LIMITS.catalogQueryMinCharacters) { | |
| 313 | + catalog.cancelSearch(); | |
| 314 | + setText(elements.catalog_status, ""); | |
| 166 | 315 | return; |
| 167 | 316 | } |
| 168 | - const fragment = session.close(outcome.payload); | |
| 169 | - if (fragment === null) { | |
| 317 | + setText(elements.catalog_status, copy.searchLoading); | |
| 318 | + try { | |
| 319 | + const result = await catalog.searchPlaces(query); | |
| 320 | + if (generation !== searchGeneration) return; | |
| 321 | + places = result; | |
| 322 | + renderPlaces(); | |
| 323 | + setText(elements.catalog_status, places.length === 0 ? copy.searchNoResults : ""); | |
| 324 | + } catch (error) { | |
| 325 | + if (error?.name === "AbortError" || generation !== searchGeneration) return; | |
| 326 | + setText(elements.catalog_status, error instanceof CatalogClientError && error.code === "INVALID_QUERY" | |
| 327 | + ? copy.searchHint | |
| 328 | + : copy.backendUnavailable); | |
| 329 | + } | |
| 330 | +}); | |
| 331 | + | |
| 332 | +elements.service_select.addEventListener("change", () => { | |
| 333 | + const index = Number(elements.service_select.value); | |
| 334 | + selectedService = Number.isInteger(index) && index >= 0 ? services[index] ?? null : null; | |
| 335 | + renderPreview(); | |
| 336 | +}); | |
| 337 | + | |
| 338 | +elements.favorite_add.addEventListener("click", () => { | |
| 339 | + if (selectedPlace === null || selectedService === null || !services.includes(selectedService)) { | |
| 340 | + setText(elements.catalog_status, copy.invalidService); | |
| 170 | 341 | return; |
| 171 | 342 | } |
| 343 | + const name = elements.favorite_name.value.trim(); | |
| 344 | + if (name.length > 0 && utf8Bytes(name) > LIMITS.labelUtf8Bytes) { | |
| 345 | + setText(elements.catalog_status, copy.invalidService); | |
| 346 | + return; | |
| 347 | + } | |
| 348 | + const favorite = favoriteFromService(nextFavoriteId(), selectedService, state.favorites.length, name || undefined); | |
| 349 | + if (favorite === null) { | |
| 350 | + setText(elements.catalog_status, copy.invalidService); | |
| 351 | + return; | |
| 352 | + } | |
| 353 | + dispatch({ type: "favorite-add", favorite }); | |
| 354 | + elements.favorite_name.value = ""; | |
| 355 | + elements.place_search.value = ""; | |
| 356 | + places = []; | |
| 357 | + renderPlaces(); | |
| 358 | + clearServiceSelection(); | |
| 359 | + setText(elements.catalog_status, ""); | |
| 360 | +}); | |
| 361 | + | |
| 362 | +elements.about_open.addEventListener("click", () => { | |
| 363 | + aboutReturnFocus = document.activeElement instanceof HTMLElement | |
| 364 | + ? document.activeElement | |
| 365 | + : elements.about_open; | |
| 366 | + elements.config_view.hidden = true; | |
| 367 | + elements.config_footer.hidden = true; | |
| 368 | + elements.about_view.hidden = false; | |
| 369 | + elements.about_back.focus(); | |
| 370 | +}); | |
| 371 | + | |
| 372 | +elements.about_back.addEventListener("click", () => { | |
| 373 | + elements.about_view.hidden = true; | |
| 374 | + elements.config_view.hidden = false; | |
| 375 | + elements.config_footer.hidden = false; | |
| 376 | + aboutReturnFocus.focus(); | |
| 377 | +}); | |
| 378 | + | |
| 379 | +elements.save.addEventListener("click", () => { | |
| 380 | + const keyError = apiKeyError(state.keyDraft.value); | |
| 381 | + if (keyError !== null) { | |
| 382 | + setText(elements.key_error, copy[keyError]); | |
| 383 | + elements.key_error.hidden = false; | |
| 384 | + elements.key_input.focus(); | |
| 385 | + return; | |
| 386 | + } | |
| 387 | + const outcome = planConfigResult(state); | |
| 388 | + if (!outcome.ok) return; | |
| 389 | + const closeUrl = closeSession.close(outcome.payload); | |
| 390 | + if (closeUrl === null) return; | |
| 172 | 391 | elements.save.disabled = true; |
| 173 | - window.location.assign(fragment); | |
| 392 | + window.location.assign(closeUrl); | |
| 174 | 393 | }); |
| 175 | 394 | |
| 176 | -applyStaticCopy(); | |
| 177 | -render(); | |
| 395 | +applyCopy(); | |
| 396 | +renderKey(); | |
| 397 | +renderFavorites(); | |
| 398 | +setText(elements.catalog_status, ""); | |
| 399 | +document.documentElement.classList.add("ready"); | |
| @@ -1,177 +1,399 @@ | |||
| 1 | -// DOM wiring for the Lapin Futé configuration page. All state transitions and | ||
| 2 | -// serialization live in config-core.js; this layer only renders and dispatches. | ||
| 3 | -// | ||
| 4 | -// Secret boundary: the personal PRIM key exists only in the masked input's | ||
| 5 | -// value. The page holds no storage of any kind, performs no network requests, | ||
| 6 | -// and never logs; its single navigation is the one-time close fragment, which | ||
| 7 | -// the mobile app intercepts before any HTTP request could exist. | ||
| 8 | -// | ||
| 9 | -// The opening fragment carries only non-secret fields: hasKey, the watch | ||
| 10 | -// language, and the current favorites list. The stored key itself is never | ||
| 11 | -// present in the fragment, the DOM, or this source. | ||
| 12 | - | ||
| 13 | import { | 1 | import { |
| 2 | + LIMITS, | ||
| 3 | + apiKeyError, | ||
| 14 | copyFor, | 4 | copyFor, |
| 15 | createCloseSession, | 5 | createCloseSession, |
| 6 | + favoriteFromService, | ||
| 16 | initialConfigState, | 7 | initialConfigState, |
| 17 | parseConfigFragment, | 8 | parseConfigFragment, |
| 18 | planConfigResult, | 9 | planConfigResult, |
| 19 | reduceConfigState, | 10 | reduceConfigState, |
| 11 | + utf8Bytes, | ||
| 20 | } from "./config-core.js"; | 12 | } from "./config-core.js"; |
| 13 | +import { CatalogClientError, createCatalogClient } from "./catalog-client.js"; | ||
| 14 | +import { RECORDED_PREVIEW } from "./preview-fixture.js"; | ||
| 15 | +import { lineBadgeAssetUrl } from "./line-badge-assets.js"; | ||
| 21 | 16 | ||
| 22 | function byId(id) { | 17 | function byId(id) { |
| 23 | return document.getElementById(id); | 18 | return document.getElementById(id); |
| 24 | } | 19 | } |
| 25 | 20 | ||
| 26 | -const elements = { | 21 | +const elements = Object.fromEntries([ |
| 27 | - pageTitle: byId("page-title"), | 22 | + "page-title", "intro", "key-title", "key-status", "key-explanation", "key-link", "key-label", |
| 28 | - keyTitle: byId("key-title"), | 23 | + "key-input", "key-toggle", "key-error", "key-pending", "key-remove", "favorites-title", |
| 29 | - keyStatus: byId("key-status"), | 24 | + "favorites-empty", "favorites-list", "add-title", "search-label", "place-search", "search-hint", |
| 30 | - keyLabel: byId("key-label"), | 25 | + "catalog-status", "place-results", "service-step", "services-label", "service-select", "preview", |
| 31 | - keyInput: byId("key-input"), | 26 | + "preview-title", "preview-line", "preview-stop", "preview-destination", "preview-departures", |
| 32 | - keyToggle: byId("key-toggle"), | 27 | + "favorite-name-label", "favorite-name", "favorite-add", "add-section", "save", "config-view", |
| 33 | - keyError: byId("key-error"), | 28 | + "config-footer", "about-open", "about-view", "about-back", "about-title", "about-en", "about-fr", |
| 34 | - keyPending: byId("key-pending"), | 29 | +].map((id) => [id.replaceAll("-", "_"), byId(id)])); |
| 35 | - keyRemove: byId("key-remove"), | 30 | + |
| 36 | - favoritesTitle: byId("favorites-title"), | 31 | +const opening = parseConfigFragment(window.location.hash); |
| 37 | - favoritesEmpty: byId("favorites-empty"), | 32 | +const copy = copyFor(opening.language); |
| 38 | - favoritesList: byId("favorites-list"), | 33 | +const catalog = createCatalogClient(); |
| 39 | - save: byId("save"), | 34 | +const closeSession = createCloseSession(); |
| 40 | -}; | 35 | +let state = initialConfigState(opening); |
| 41 | - | 36 | +let places = []; |
| 42 | -const query = parseConfigFragment(window.location.hash); | 37 | +let services = []; |
| 43 | -const copy = copyFor(query.language); | 38 | +let selectedPlace = null; |
| 44 | -let state = initialConfigState(query); | 39 | +let selectedService = null; |
| 45 | -let revealed = false; | 40 | +let serviceController = null; |
| 46 | -const session = createCloseSession(); | 41 | +let searchGeneration = 0; |
| 47 | - | 42 | +let idSequence = 0; |
| 48 | -function setText(element, text) { | 43 | +let keyRevealed = false; |
| 49 | - if (element.textContent !== text) { | 44 | + |
| 50 | - element.textContent = text; | 45 | +let aboutReturnFocus = elements.about_open; |
| 46 | +function setText(element, value) { | ||
| 47 | + if (element.textContent !== value) element.textContent = value; | ||
| 48 | +} | ||
| 49 | + | ||
| 50 | +const NEUTRAL_LINE_BACKGROUND = "#52616f"; | ||
| 51 | +const NEUTRAL_LINE_TEXT = "#ffffff"; | ||
| 52 | + | ||
| 53 | +function fallbackLineBadge(service) { | ||
| 54 | + const namespace = "http://www.w3.org/2000/svg"; | ||
| 55 | + const width = Math.max(40, 18 + (Array.from(service.lineLabel).length * 12)); | ||
| 56 | + const backgroundColor = service.lineColor ?? NEUTRAL_LINE_BACKGROUND; | ||
| 57 | + const textColor = service.lineTextColor ?? NEUTRAL_LINE_TEXT; | ||
| 58 | + const svg = document.createElementNS(namespace, "svg"); | ||
| 59 | + svg.setAttribute("viewBox", `0 0 ${width} 40`); | ||
| 60 | + svg.setAttribute("role", "img"); | ||
| 61 | + svg.setAttribute("aria-label", service.lineLabel); | ||
| 62 | + svg.setAttribute("focusable", "false"); | ||
| 63 | + const title = document.createElementNS(namespace, "title"); | ||
| 64 | + title.textContent = service.lineLabel; | ||
| 65 | + const background = document.createElementNS(namespace, "rect"); | ||
| 66 | + background.setAttribute("width", String(width)); | ||
| 67 | + background.setAttribute("height", "40"); | ||
| 68 | + background.setAttribute("rx", "6"); | ||
| 69 | + background.setAttribute("fill", backgroundColor); | ||
| 70 | + const label = document.createElementNS(namespace, "text"); | ||
| 71 | + label.setAttribute("x", String(width / 2)); | ||
| 72 | + label.setAttribute("y", "21"); | ||
| 73 | + label.setAttribute("text-anchor", "middle"); | ||
| 74 | + label.setAttribute("dominant-baseline", "middle"); | ||
| 75 | + label.setAttribute("fill", textColor); | ||
| 76 | + label.setAttribute("font-family", "system-ui, sans-serif"); | ||
| 77 | + label.setAttribute("font-size", "18"); | ||
| 78 | + label.setAttribute("font-weight", "800"); | ||
| 79 | + label.textContent = service.lineLabel; | ||
| 80 | + svg.append(title, background, label); | ||
| 81 | + return svg; | ||
| 82 | +} | ||
| 83 | + | ||
| 84 | +function renderLineBadge(container, service) { | ||
| 85 | + container.className = "line-badge"; | ||
| 86 | + const assetUrl = service.lineMode === undefined | ||
| 87 | + ? undefined | ||
| 88 | + : lineBadgeAssetUrl(service.lineMode, service.lineLabel); | ||
| 89 | + if (assetUrl === undefined) { | ||
| 90 | + container.replaceChildren(fallbackLineBadge(service)); | ||
| 91 | + return; | ||
| 51 | } | 92 | } |
| 93 | + const image = document.createElement("img"); | ||
| 94 | + image.alt = service.lineLabel; | ||
| 95 | + image.src = assetUrl; | ||
| 96 | + image.addEventListener("error", () => { | ||
| 97 | + if (image.parentNode === container) container.replaceChildren(fallbackLineBadge(service)); | ||
| 98 | + }, { once: true }); | ||
| 99 | + container.replaceChildren(image); | ||
| 52 | } | 100 | } |
| 53 | 101 | ||
| 54 | function dispatch(action) { | 102 | function dispatch(action) { |
| 55 | - const next = reduceConfigState(state, action); | 103 | + state = reduceConfigState(state, action); |
| 56 | - if (next !== state) { | 104 | + renderKey(); |
| 57 | - state = next; | 105 | + renderFavorites(); |
| 58 | - } | ||
| 59 | - render(); | ||
| 60 | } | 106 | } |
| 61 | 107 | ||
| 62 | -function applyStaticCopy() { | 108 | +function applyCopy() { |
| 63 | document.title = copy.pageTitle; | 109 | document.title = copy.pageTitle; |
| 64 | - document.documentElement.lang = query.locale; | 110 | + document.documentElement.lang = opening.locale; |
| 65 | - setText(elements.pageTitle, copy.pageTitle); | 111 | + for (const [key, element] of [ |
| 66 | - setText(elements.keyTitle, copy.keyTitle); | 112 | + ["pageTitle", elements.page_title], ["intro", elements.intro], ["keyTitle", elements.key_title], |
| 67 | - setText(elements.keyLabel, copy.keyLabel); | 113 | + ["keyExplanation", elements.key_explanation], ["keyLink", elements.key_link], |
| 68 | - elements.keyInput.placeholder = copy.keyPlaceholder; | 114 | + ["keyLabel", elements.key_label], ["favoritesTitle", elements.favorites_title], |
| 69 | - setText(elements.keyToggle, copy.keyShow); | 115 | + ["favoritesEmpty", elements.favorites_empty], ["favoriteAddTitle", elements.add_title], |
| 70 | - setText(elements.favoritesTitle, copy.favoritesTitle); | 116 | + ["searchLabel", elements.search_label], ["searchHint", elements.search_hint], |
| 71 | - setText(elements.favoritesEmpty, copy.favoritesEmpty); | 117 | + ["servicesLabel", elements.services_label], ["previewTitle", elements.preview_title], |
| 72 | - setText(elements.save, copy.save); | 118 | + ["favoriteNameLabel", elements.favorite_name_label], ["favoriteAdd", elements.favorite_add], |
| 119 | + ["save", elements.save], ["aboutOpen", elements.about_open], ["aboutBack", elements.about_back], | ||
| 120 | + ["aboutTitle", elements.about_title], | ||
| 121 | + ]) setText(element, copy[key]); | ||
| 122 | + elements.key_input.placeholder = copy.keyPlaceholder; | ||
| 123 | + elements.place_search.placeholder = copy.searchPlaceholder; | ||
| 124 | + elements.favorite_name.placeholder = copy.favoriteNamePlaceholder; | ||
| 125 | + elements.about_en.hidden = opening.locale !== "en"; | ||
| 126 | + elements.about_fr.hidden = opening.locale !== "fr"; | ||
| 73 | } | 127 | } |
| 74 | 128 | ||
| 75 | -function render() { | 129 | +function renderKey() { |
| 76 | - renderKeySection(); | 130 | + setText(elements.key_status, state.hasKey ? copy.keyStatusConfigured : copy.keyStatusMissing); |
| 77 | - renderFavorites(); | 131 | + const removing = state.keyDraft.removeRequested; |
| 132 | + const replacing = state.keyDraft.value.length > 0; | ||
| 133 | + elements.key_pending.hidden = !removing && !replacing; | ||
| 134 | + if (removing) setText(elements.key_pending, copy.keyRemovePending); | ||
| 135 | + else if (replacing) setText(elements.key_pending, copy.keyReplacementPending); | ||
| 136 | + elements.key_remove.hidden = !state.hasKey; | ||
| 137 | + setText(elements.key_remove, removing ? copy.keyUndoRemove : copy.keyRemove); | ||
| 138 | + setText(elements.key_toggle, keyRevealed ? copy.keyHide : copy.keyShow); | ||
| 78 | } | 139 | } |
| 79 | 140 | ||
| 80 | -function renderKeySection() { | 141 | +function actionButton(label, disabled, action, className = "quiet") { |
| 81 | - setText(elements.keyStatus, state.hasKey ? copy.keyStatusConfigured : copy.keyStatusMissing); | 142 | + const button = document.createElement("button"); |
| 82 | - const pending = state.keyDraft.removeRequested; | 143 | + button.type = "button"; |
| 83 | - elements.keyPending.hidden = !pending; | 144 | + button.className = className; |
| 84 | - if (pending) { | 145 | + button.textContent = label; |
| 85 | - setText(elements.keyPending, copy.keyRemovePending); | 146 | + button.disabled = disabled; |
| 86 | - } | 147 | + button.addEventListener("click", action); |
| 87 | - elements.keyRemove.hidden = !state.hasKey; | 148 | + return button; |
| 88 | - setText(elements.keyRemove, pending ? copy.keyUndoRemove : copy.keyRemove); | ||
| 89 | } | 149 | } |
| 90 | 150 | ||
| 91 | function renderFavorites() { | 151 | function renderFavorites() { |
| 92 | - elements.favoritesList.replaceChildren(); | 152 | + elements.favorites_list.replaceChildren(); |
| 93 | - elements.favoritesEmpty.hidden = state.favorites.length > 0; | 153 | + elements.favorites_empty.hidden = state.favorites.length > 0; |
| 154 | + elements.add_section.hidden = state.favorites.length >= LIMITS.favorites; | ||
| 155 | + if (state.favorites.length >= LIMITS.favorites) setText(elements.catalog_status, copy.favoriteLimit); | ||
| 156 | + else if (elements.catalog_status.textContent === copy.favoriteLimit) setText(elements.catalog_status, ""); | ||
| 157 | + | ||
| 94 | state.favorites.forEach((favorite, index) => { | 158 | state.favorites.forEach((favorite, index) => { |
| 95 | - elements.favoritesList.append(favoriteItem(favorite, index)); | 159 | + const item = document.createElement("li"); |
| 160 | + item.className = "favorite"; | ||
| 161 | + const route = document.createElement("span"); | ||
| 162 | + renderLineBadge(route, favorite); | ||
| 163 | + const labels = document.createElement("div"); | ||
| 164 | + labels.className = "favorite-labels"; | ||
| 165 | + const stop = document.createElement("strong"); | ||
| 166 | + stop.textContent = favorite.stopLabel; | ||
| 167 | + const destination = document.createElement("span"); | ||
| 168 | + destination.textContent = favorite.destinationLabel; | ||
| 169 | + labels.append(stop, destination); | ||
| 170 | + | ||
| 171 | + const renameLabel = document.createElement("label"); | ||
| 172 | + renameLabel.className = "visually-hidden"; | ||
| 173 | + const renameId = `favorite-name-${index}`; | ||
| 174 | + renameLabel.htmlFor = renameId; | ||
| 175 | + renameLabel.textContent = copy.favoriteRename; | ||
| 176 | + const rename = document.createElement("input"); | ||
| 177 | + rename.id = renameId; | ||
| 178 | + rename.type = "text"; | ||
| 179 | + rename.autocomplete = "off"; | ||
| 180 | + rename.maxLength = LIMITS.labelUtf8Bytes; | ||
| 181 | + rename.value = favorite.displayName ?? ""; | ||
| 182 | + rename.placeholder = copy.favoriteNamePlaceholder; | ||
| 183 | + rename.addEventListener("change", () => { | ||
| 184 | + if (utf8Bytes(rename.value.trim()) <= LIMITS.labelUtf8Bytes) { | ||
| 185 | + dispatch({ type: "favorite-rename", id: favorite.id, displayName: rename.value }); | ||
| 186 | + } else { | ||
| 187 | + rename.value = favorite.displayName ?? ""; | ||
| 188 | + } | ||
| 189 | + }); | ||
| 190 | + | ||
| 191 | + | ||
| 192 | + const actions = document.createElement("div"); | ||
| 193 | + actions.className = "favorite-actions"; | ||
| 194 | + actions.append( | ||
| 195 | + actionButton(copy.favoriteMoveUp, index === 0, () => dispatch({ type: "favorite-move", id: favorite.id, delta: -1 })), | ||
| 196 | + actionButton(copy.favoriteMoveDown, index === state.favorites.length - 1, () => dispatch({ type: "favorite-move", id: favorite.id, delta: 1 })), | ||
| 197 | + actionButton(copy.favoriteRemove, false, () => dispatch({ type: "favorite-remove", id: favorite.id }), "quiet danger"), | ||
| 198 | + ); | ||
| 199 | + item.append(route, labels, renameLabel, rename, actions); | ||
| 200 | + elements.favorites_list.append(item); | ||
| 96 | }); | 201 | }); |
| 97 | } | 202 | } |
| 98 | 203 | ||
| 99 | -function favoriteItem(favorite, index) { | 204 | +function clearServiceSelection() { |
| 100 | - const item = document.createElement("li"); | 205 | + if (serviceController !== null) serviceController.abort(); |
| 101 | - item.className = "favorite"; | 206 | + serviceController = null; |
| 102 | - | 207 | + services = []; |
| 103 | - const heading = document.createElement("p"); | 208 | + selectedPlace = null; |
| 104 | - heading.className = "labels"; | 209 | + selectedService = null; |
| 105 | - heading.textContent = favorite.displayName ?? favorite.stopLabel; | 210 | + elements.service_step.hidden = true; |
| 106 | - | 211 | + elements.preview.hidden = true; |
| 107 | - const detail = document.createElement("p"); | 212 | + elements.service_select.replaceChildren(); |
| 108 | - detail.className = "sub"; | 213 | +} |
| 109 | - // Authored and transport labels cross every locale verbatim. | ||
| 110 | - detail.textContent = [favorite.stopLabel, favorite.lineLabel, favorite.destinationLabel].join(" · "); | ||
| 111 | - | ||
| 112 | - const actions = document.createElement("div"); | ||
| 113 | - actions.className = "favorite-actions"; | ||
| 114 | - actions.append( | ||
| 115 | - listButton(copy.favoriteMoveUp, index === 0, () => | ||
| 116 | - dispatch({ type: "favorite-move", id: favorite.id, delta: -1 }), | ||
| 117 | - ), | ||
| 118 | - listButton(copy.favoriteMoveDown, index === state.favorites.length - 1, () => | ||
| 119 | - dispatch({ type: "favorite-move", id: favorite.id, delta: 1 }), | ||
| 120 | - ), | ||
| 121 | - listButton(copy.favoriteRemove, false, () => | ||
| 122 | - dispatch({ type: "favorite-remove", id: favorite.id }), | ||
| 123 | - ), | ||
| 124 | - ); | ||
| 125 | 214 | ||
| 126 | - item.append(heading, detail, actions); | 215 | +function renderPlaces() { |
| 127 | - return item; | 216 | + elements.place_results.replaceChildren(); |
| 217 | + for (const place of places) { | ||
| 218 | + const item = document.createElement("li"); | ||
| 219 | + const label = place.localityLabel | ||
| 220 | + ? `${place.stopLabel} — ${place.localityLabel} · ${place.mode}` | ||
| 221 | + : `${place.stopLabel} · ${place.mode}`; | ||
| 222 | + item.append(actionButton(label, false, () => selectPlace(place), "choice")); | ||
| 223 | + elements.place_results.append(item); | ||
| 224 | + } | ||
| 128 | } | 225 | } |
| 129 | 226 | ||
| 130 | -function listButton(label, disabled, onClick) { | 227 | +async function selectPlace(place) { |
| 131 | - const button = document.createElement("button"); | 228 | + clearServiceSelection(); |
| 132 | - button.type = "button"; | 229 | + selectedPlace = place; |
| 133 | - button.textContent = label; | 230 | + serviceController = new AbortController(); |
| 134 | - button.disabled = disabled; | 231 | + const controller = serviceController; |
| 135 | - button.addEventListener("click", onClick); | 232 | + setText(elements.catalog_status, copy.servicesLoading); |
| 136 | - return button; | 233 | + try { |
| 234 | + services = await catalog.listServices(place.placeId, controller.signal); | ||
| 235 | + if (controller.signal.aborted) return; | ||
| 236 | + serviceController = null; | ||
| 237 | + if (services.length === 0) { | ||
| 238 | + setText(elements.catalog_status, copy.servicesEmpty); | ||
| 239 | + return; | ||
| 240 | + } | ||
| 241 | + elements.service_select.replaceChildren(); | ||
| 242 | + const prompt = document.createElement("option"); | ||
| 243 | + prompt.value = ""; | ||
| 244 | + prompt.textContent = copy.servicesLabel; | ||
| 245 | + elements.service_select.append(prompt); | ||
| 246 | + services.forEach((service, index) => { | ||
| 247 | + const option = document.createElement("option"); | ||
| 248 | + option.value = String(index); | ||
| 249 | + option.textContent = `${service.lineLabel} · ${service.destinationLabel}`; | ||
| 250 | + elements.service_select.append(option); | ||
| 251 | + }); | ||
| 252 | + elements.service_step.hidden = false; | ||
| 253 | + setText(elements.catalog_status, ""); | ||
| 254 | + elements.service_select.focus(); | ||
| 255 | + } catch (error) { | ||
| 256 | + if (controller.signal.aborted) return; | ||
| 257 | + serviceController = null; | ||
| 258 | + setText(elements.catalog_status, error instanceof CatalogClientError && error.code === "INVALID_SERVICE" | ||
| 259 | + ? copy.invalidService | ||
| 260 | + : copy.backendUnavailable); | ||
| 261 | + } | ||
| 262 | +} | ||
| 263 | + | ||
| 264 | +function renderPreview() { | ||
| 265 | + elements.preview.hidden = selectedService === null; | ||
| 266 | + if (selectedService === null) return; | ||
| 267 | + renderLineBadge(elements.preview_line, selectedService); | ||
| 268 | + setText(elements.preview_stop, selectedService.stopLabel); | ||
| 269 | + setText(elements.preview_destination, selectedService.destinationLabel); | ||
| 270 | + setText( | ||
| 271 | + elements.preview_departures, | ||
| 272 | + `${copy.previewRecorded}: ${RECORDED_PREVIEW.map((entry) => `${entry.minutes} ${copy.minutesShort}`).join(" · ")}`, | ||
| 273 | + ); | ||
| 274 | +} | ||
| 275 | + | ||
| 276 | +function nextFavoriteId() { | ||
| 277 | + let candidate; | ||
| 278 | + do { | ||
| 279 | + idSequence += 1; | ||
| 280 | + candidate = `cfg-${Date.now().toString(36)}-${idSequence.toString(36)}`; | ||
| 281 | + } while (state.favorites.some((favorite) => favorite.id === candidate)); | ||
| 282 | + return candidate; | ||
| 137 | } | 283 | } |
| 138 | 284 | ||
| 139 | -elements.keyInput.addEventListener("input", () => { | 285 | +elements.key_input.addEventListener("input", () => { |
| 140 | - elements.keyError.hidden = true; | 286 | + elements.key_error.hidden = true; |
| 141 | - dispatch({ type: "key-draft", value: elements.keyInput.value }); | 287 | + dispatch({ type: "key-draft", value: elements.key_input.value }); |
| 142 | }); | 288 | }); |
| 143 | 289 | ||
| 144 | -elements.keyToggle.addEventListener("click", () => { | 290 | +elements.key_toggle.addEventListener("click", () => { |
| 145 | - revealed = !revealed; | 291 | + keyRevealed = !keyRevealed; |
| 146 | - elements.keyInput.type = revealed ? "text" : "password"; | 292 | + elements.key_input.type = keyRevealed ? "text" : "password"; |
| 147 | - setText(elements.keyToggle, revealed ? copy.keyHide : copy.keyShow); | 293 | + elements.key_toggle.setAttribute("aria-pressed", String(keyRevealed)); |
| 148 | - elements.keyToggle.setAttribute("aria-pressed", String(revealed)); | 294 | + renderKey(); |
| 149 | - elements.keyInput.focus(); | 295 | + elements.key_input.focus(); |
| 150 | }); | 296 | }); |
| 151 | 297 | ||
| 152 | -elements.keyRemove.addEventListener("click", () => { | 298 | +elements.key_remove.addEventListener("click", () => { |
| 153 | - dispatch( | 299 | + elements.key_input.value = ""; |
| 154 | - state.keyDraft.removeRequested | 300 | + dispatch(state.keyDraft.removeRequested |
| 155 | - ? { type: "key-remove-cancelled" } | 301 | + ? { type: "key-remove-cancelled" } |
| 156 | - : { type: "key-remove-requested" }, | 302 | + : { type: "key-remove-requested" }); |
| 157 | - ); | ||
| 158 | }); | 303 | }); |
| 159 | 304 | ||
| 160 | -elements.save.addEventListener("click", () => { | 305 | +elements.place_search.addEventListener("input", async () => { |
| 161 | - const outcome = planConfigResult(state); | 306 | + const generation = ++searchGeneration; |
| 162 | - if (!outcome.ok) { | 307 | + clearServiceSelection(); |
| 163 | - elements.keyError.textContent = copy[outcome.error]; | 308 | + places = []; |
| 164 | - elements.keyError.hidden = false; | 309 | + renderPlaces(); |
| 165 | - elements.keyInput.focus(); | 310 | + const query = elements.place_search.value.trim(); |
| 311 | + const length = Array.from(query).length; | ||
| 312 | + if (length < LIMITS.catalogQueryMinCharacters) { | ||
| 313 | + catalog.cancelSearch(); | ||
| 314 | + setText(elements.catalog_status, ""); | ||
| 166 | return; | 315 | return; |
| 167 | } | 316 | } |
| 168 | - const fragment = session.close(outcome.payload); | 317 | + setText(elements.catalog_status, copy.searchLoading); |
| 169 | - if (fragment === null) { | 318 | + try { |
| 319 | + const result = await catalog.searchPlaces(query); | ||
| 320 | + if (generation !== searchGeneration) return; | ||
| 321 | + places = result; | ||
| 322 | + renderPlaces(); | ||
| 323 | + setText(elements.catalog_status, places.length === 0 ? copy.searchNoResults : ""); | ||
| 324 | + } catch (error) { | ||
| 325 | + if (error?.name === "AbortError" || generation !== searchGeneration) return; | ||
| 326 | + setText(elements.catalog_status, error instanceof CatalogClientError && error.code === "INVALID_QUERY" | ||
| 327 | + ? copy.searchHint | ||
| 328 | + : copy.backendUnavailable); | ||
| 329 | + } | ||
| 330 | +}); | ||
| 331 | + | ||
| 332 | +elements.service_select.addEventListener("change", () => { | ||
| 333 | + const index = Number(elements.service_select.value); | ||
| 334 | + selectedService = Number.isInteger(index) && index >= 0 ? services[index] ?? null : null; | ||
| 335 | + renderPreview(); | ||
| 336 | +}); | ||
| 337 | + | ||
| 338 | +elements.favorite_add.addEventListener("click", () => { | ||
| 339 | + if (selectedPlace === null || selectedService === null || !services.includes(selectedService)) { | ||
| 340 | + setText(elements.catalog_status, copy.invalidService); | ||
| 170 | return; | 341 | return; |
| 171 | } | 342 | } |
| 343 | + const name = elements.favorite_name.value.trim(); | ||
| 344 | + if (name.length > 0 && utf8Bytes(name) > LIMITS.labelUtf8Bytes) { | ||
| 345 | + setText(elements.catalog_status, copy.invalidService); | ||
| 346 | + return; | ||
| 347 | + } | ||
| 348 | + const favorite = favoriteFromService(nextFavoriteId(), selectedService, state.favorites.length, name || undefined); | ||
| 349 | + if (favorite === null) { | ||
| 350 | + setText(elements.catalog_status, copy.invalidService); | ||
| 351 | + return; | ||
| 352 | + } | ||
| 353 | + dispatch({ type: "favorite-add", favorite }); | ||
| 354 | + elements.favorite_name.value = ""; | ||
| 355 | + elements.place_search.value = ""; | ||
| 356 | + places = []; | ||
| 357 | + renderPlaces(); | ||
| 358 | + clearServiceSelection(); | ||
| 359 | + setText(elements.catalog_status, ""); | ||
| 360 | +}); | ||
| 361 | + | ||
| 362 | +elements.about_open.addEventListener("click", () => { | ||
| 363 | + aboutReturnFocus = document.activeElement instanceof HTMLElement | ||
| 364 | + ? document.activeElement | ||
| 365 | + : elements.about_open; | ||
| 366 | + elements.config_view.hidden = true; | ||
| 367 | + elements.config_footer.hidden = true; | ||
| 368 | + elements.about_view.hidden = false; | ||
| 369 | + elements.about_back.focus(); | ||
| 370 | +}); | ||
| 371 | + | ||
| 372 | +elements.about_back.addEventListener("click", () => { | ||
| 373 | + elements.about_view.hidden = true; | ||
| 374 | + elements.config_view.hidden = false; | ||
| 375 | + elements.config_footer.hidden = false; | ||
| 376 | + aboutReturnFocus.focus(); | ||
| 377 | +}); | ||
| 378 | + | ||
| 379 | +elements.save.addEventListener("click", () => { | ||
| 380 | + const keyError = apiKeyError(state.keyDraft.value); | ||
| 381 | + if (keyError !== null) { | ||
| 382 | + setText(elements.key_error, copy[keyError]); | ||
| 383 | + elements.key_error.hidden = false; | ||
| 384 | + elements.key_input.focus(); | ||
| 385 | + return; | ||
| 386 | + } | ||
| 387 | + const outcome = planConfigResult(state); | ||
| 388 | + if (!outcome.ok) return; | ||
| 389 | + const closeUrl = closeSession.close(outcome.payload); | ||
| 390 | + if (closeUrl === null) return; | ||
| 172 | elements.save.disabled = true; | 391 | elements.save.disabled = true; |
| 173 | - window.location.assign(fragment); | 392 | + window.location.assign(closeUrl); |
| 174 | }); | 393 | }); |
| 175 | 394 | ||
| 176 | -applyStaticCopy(); | 395 | +applyCopy(); |
| 177 | -render(); | 396 | +renderKey(); |
| 397 | +renderFavorites(); | ||
| 398 | +setText(elements.catalog_status, ""); | ||
| 399 | +document.documentElement.classList.add("ready"); | ||
added
packages/config-page/src/line-badge-assets.js +188 -0 | new file mode 100644 | ||
| @@ -0,0 +1,188 @@ | ||
| 1 | +// Generated by scripts/sync-line-badges.mjs. Do not edit. | |
| 2 | +const ASSETS = new Map([ | |
| 3 | + [ | |
| 4 | + "METRO:1", | |
| 5 | + "metro-1.png" | |
| 6 | + ], | |
| 7 | + [ | |
| 8 | + "METRO:10", | |
| 9 | + "metro-10.png" | |
| 10 | + ], | |
| 11 | + [ | |
| 12 | + "METRO:11", | |
| 13 | + "metro-11.png" | |
| 14 | + ], | |
| 15 | + [ | |
| 16 | + "METRO:12", | |
| 17 | + "metro-12.png" | |
| 18 | + ], | |
| 19 | + [ | |
| 20 | + "METRO:13", | |
| 21 | + "metro-13.png" | |
| 22 | + ], | |
| 23 | + [ | |
| 24 | + "METRO:14", | |
| 25 | + "metro-14.png" | |
| 26 | + ], | |
| 27 | + [ | |
| 28 | + "METRO:2", | |
| 29 | + "metro-2.png" | |
| 30 | + ], | |
| 31 | + [ | |
| 32 | + "METRO:3", | |
| 33 | + "metro-3.png" | |
| 34 | + ], | |
| 35 | + [ | |
| 36 | + "METRO:3B", | |
| 37 | + "metro-3b.png" | |
| 38 | + ], | |
| 39 | + [ | |
| 40 | + "METRO:4", | |
| 41 | + "metro-4.png" | |
| 42 | + ], | |
| 43 | + [ | |
| 44 | + "METRO:5", | |
| 45 | + "metro-5.png" | |
| 46 | + ], | |
| 47 | + [ | |
| 48 | + "METRO:6", | |
| 49 | + "metro-6.png" | |
| 50 | + ], | |
| 51 | + [ | |
| 52 | + "METRO:7", | |
| 53 | + "metro-7.png" | |
| 54 | + ], | |
| 55 | + [ | |
| 56 | + "METRO:7B", | |
| 57 | + "metro-7b.png" | |
| 58 | + ], | |
| 59 | + [ | |
| 60 | + "METRO:8", | |
| 61 | + "metro-8.png" | |
| 62 | + ], | |
| 63 | + [ | |
| 64 | + "METRO:9", | |
| 65 | + "metro-9.png" | |
| 66 | + ], | |
| 67 | + [ | |
| 68 | + "RER:A", | |
| 69 | + "rer-a.png" | |
| 70 | + ], | |
| 71 | + [ | |
| 72 | + "RER:B", | |
| 73 | + "rer-b.png" | |
| 74 | + ], | |
| 75 | + [ | |
| 76 | + "RER:C", | |
| 77 | + "rer-c.png" | |
| 78 | + ], | |
| 79 | + [ | |
| 80 | + "RER:D", | |
| 81 | + "rer-d.png" | |
| 82 | + ], | |
| 83 | + [ | |
| 84 | + "RER:E", | |
| 85 | + "rer-e.png" | |
| 86 | + ], | |
| 87 | + [ | |
| 88 | + "TRAM:T1", | |
| 89 | + "tram-t1.png" | |
| 90 | + ], | |
| 91 | + [ | |
| 92 | + "TRAM:T10", | |
| 93 | + "tram-t10.png" | |
| 94 | + ], | |
| 95 | + [ | |
| 96 | + "TRAM:T11", | |
| 97 | + "tram-t11.png" | |
| 98 | + ], | |
| 99 | + [ | |
| 100 | + "TRAM:T12", | |
| 101 | + "tram-t12.png" | |
| 102 | + ], | |
| 103 | + [ | |
| 104 | + "TRAM:T13", | |
| 105 | + "tram-t13.png" | |
| 106 | + ], | |
| 107 | + [ | |
| 108 | + "TRAM:T14", | |
| 109 | + "tram-t14.svg" | |
| 110 | + ], | |
| 111 | + [ | |
| 112 | + "TRAM:T2", | |
| 113 | + "tram-t2.png" | |
| 114 | + ], | |
| 115 | + [ | |
| 116 | + "TRAM:T3a", | |
| 117 | + "tram-t3a.png" | |
| 118 | + ], | |
| 119 | + [ | |
| 120 | + "TRAM:T3b", | |
| 121 | + "tram-t3b.png" | |
| 122 | + ], | |
| 123 | + [ | |
| 124 | + "TRAM:T4", | |
| 125 | + "tram-t4.png" | |
| 126 | + ], | |
| 127 | + [ | |
| 128 | + "TRAM:T5", | |
| 129 | + "tram-t5.png" | |
| 130 | + ], | |
| 131 | + [ | |
| 132 | + "TRAM:T6", | |
| 133 | + "tram-t6.png" | |
| 134 | + ], | |
| 135 | + [ | |
| 136 | + "TRAM:T7", | |
| 137 | + "tram-t7.png" | |
| 138 | + ], | |
| 139 | + [ | |
| 140 | + "TRAM:T8", | |
| 141 | + "tram-t8.png" | |
| 142 | + ], | |
| 143 | + [ | |
| 144 | + "TRAM:T9", | |
| 145 | + "tram-t9.png" | |
| 146 | + ], | |
| 147 | + [ | |
| 148 | + "TRANSILIEN:H", | |
| 149 | + "transilien-h.png" | |
| 150 | + ], | |
| 151 | + [ | |
| 152 | + "TRANSILIEN:J", | |
| 153 | + "transilien-j.png" | |
| 154 | + ], | |
| 155 | + [ | |
| 156 | + "TRANSILIEN:K", | |
| 157 | + "transilien-k.png" | |
| 158 | + ], | |
| 159 | + [ | |
| 160 | + "TRANSILIEN:L", | |
| 161 | + "transilien-l.png" | |
| 162 | + ], | |
| 163 | + [ | |
| 164 | + "TRANSILIEN:N", | |
| 165 | + "transilien-n.png" | |
| 166 | + ], | |
| 167 | + [ | |
| 168 | + "TRANSILIEN:P", | |
| 169 | + "transilien-p.png" | |
| 170 | + ], | |
| 171 | + [ | |
| 172 | + "TRANSILIEN:R", | |
| 173 | + "transilien-r.png" | |
| 174 | + ], | |
| 175 | + [ | |
| 176 | + "TRANSILIEN:U", | |
| 177 | + "transilien-u.png" | |
| 178 | + ], | |
| 179 | + [ | |
| 180 | + "TRANSILIEN:V", | |
| 181 | + "transilien-v.png" | |
| 182 | + ] | |
| 183 | +]); | |
| 184 | + | |
| 185 | +export function lineBadgeAssetUrl(lineMode, lineLabel) { | |
| 186 | + const file = ASSETS.get(`${lineMode}:${lineLabel}`); | |
| 187 | + return file === undefined ? undefined : new URL(`../assets/line-badges/${file}`, import.meta.url).href; | |
| 188 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,188 @@ | |||
| 1 | +// Generated by scripts/sync-line-badges.mjs. Do not edit. | ||
| 2 | +const ASSETS = new Map([ | ||
| 3 | + [ | ||
| 4 | + "METRO:1", | ||
| 5 | + "metro-1.png" | ||
| 6 | + ], | ||
| 7 | + [ | ||
| 8 | + "METRO:10", | ||
| 9 | + "metro-10.png" | ||
| 10 | + ], | ||
| 11 | + [ | ||
| 12 | + "METRO:11", | ||
| 13 | + "metro-11.png" | ||
| 14 | + ], | ||
| 15 | + [ | ||
| 16 | + "METRO:12", | ||
| 17 | + "metro-12.png" | ||
| 18 | + ], | ||
| 19 | + [ | ||
| 20 | + "METRO:13", | ||
| 21 | + "metro-13.png" | ||
| 22 | + ], | ||
| 23 | + [ | ||
| 24 | + "METRO:14", | ||
| 25 | + "metro-14.png" | ||
| 26 | + ], | ||
| 27 | + [ | ||
| 28 | + "METRO:2", | ||
| 29 | + "metro-2.png" | ||
| 30 | + ], | ||
| 31 | + [ | ||
| 32 | + "METRO:3", | ||
| 33 | + "metro-3.png" | ||
| 34 | + ], | ||
| 35 | + [ | ||
| 36 | + "METRO:3B", | ||
| 37 | + "metro-3b.png" | ||
| 38 | + ], | ||
| 39 | + [ | ||
| 40 | + "METRO:4", | ||
| 41 | + "metro-4.png" | ||
| 42 | + ], | ||
| 43 | + [ | ||
| 44 | + "METRO:5", | ||
| 45 | + "metro-5.png" | ||
| 46 | + ], | ||
| 47 | + [ | ||
| 48 | + "METRO:6", | ||
| 49 | + "metro-6.png" | ||
| 50 | + ], | ||
| 51 | + [ | ||
| 52 | + "METRO:7", | ||
| 53 | + "metro-7.png" | ||
| 54 | + ], | ||
| 55 | + [ | ||
| 56 | + "METRO:7B", | ||
| 57 | + "metro-7b.png" | ||
| 58 | + ], | ||
| 59 | + [ | ||
| 60 | + "METRO:8", | ||
| 61 | + "metro-8.png" | ||
| 62 | + ], | ||
| 63 | + [ | ||
| 64 | + "METRO:9", | ||
| 65 | + "metro-9.png" | ||
| 66 | + ], | ||
| 67 | + [ | ||
| 68 | + "RER:A", | ||
| 69 | + "rer-a.png" | ||
| 70 | + ], | ||
| 71 | + [ | ||
| 72 | + "RER:B", | ||
| 73 | + "rer-b.png" | ||
| 74 | + ], | ||
| 75 | + [ | ||
| 76 | + "RER:C", | ||
| 77 | + "rer-c.png" | ||
| 78 | + ], | ||
| 79 | + [ | ||
| 80 | + "RER:D", | ||
| 81 | + "rer-d.png" | ||
| 82 | + ], | ||
| 83 | + [ | ||
| 84 | + "RER:E", | ||
| 85 | + "rer-e.png" | ||
| 86 | + ], | ||
| 87 | + [ | ||
| 88 | + "TRAM:T1", | ||
| 89 | + "tram-t1.png" | ||
| 90 | + ], | ||
| 91 | + [ | ||
| 92 | + "TRAM:T10", | ||
| 93 | + "tram-t10.png" | ||
| 94 | + ], | ||
| 95 | + [ | ||
| 96 | + "TRAM:T11", | ||
| 97 | + "tram-t11.png" | ||
| 98 | + ], | ||
| 99 | + [ | ||
| 100 | + "TRAM:T12", | ||
| 101 | + "tram-t12.png" | ||
| 102 | + ], | ||
| 103 | + [ | ||
| 104 | + "TRAM:T13", | ||
| 105 | + "tram-t13.png" | ||
| 106 | + ], | ||
| 107 | + [ | ||
| 108 | + "TRAM:T14", | ||
| 109 | + "tram-t14.svg" | ||
| 110 | + ], | ||
| 111 | + [ | ||
| 112 | + "TRAM:T2", | ||
| 113 | + "tram-t2.png" | ||
| 114 | + ], | ||
| 115 | + [ | ||
| 116 | + "TRAM:T3a", | ||
| 117 | + "tram-t3a.png" | ||
| 118 | + ], | ||
| 119 | + [ | ||
| 120 | + "TRAM:T3b", | ||
| 121 | + "tram-t3b.png" | ||
| 122 | + ], | ||
| 123 | + [ | ||
| 124 | + "TRAM:T4", | ||
| 125 | + "tram-t4.png" | ||
| 126 | + ], | ||
| 127 | + [ | ||
| 128 | + "TRAM:T5", | ||
| 129 | + "tram-t5.png" | ||
| 130 | + ], | ||
| 131 | + [ | ||
| 132 | + "TRAM:T6", | ||
| 133 | + "tram-t6.png" | ||
| 134 | + ], | ||
| 135 | + [ | ||
| 136 | + "TRAM:T7", | ||
| 137 | + "tram-t7.png" | ||
| 138 | + ], | ||
| 139 | + [ | ||
| 140 | + "TRAM:T8", | ||
| 141 | + "tram-t8.png" | ||
| 142 | + ], | ||
| 143 | + [ | ||
| 144 | + "TRAM:T9", | ||
| 145 | + "tram-t9.png" | ||
| 146 | + ], | ||
| 147 | + [ | ||
| 148 | + "TRANSILIEN:H", | ||
| 149 | + "transilien-h.png" | ||
| 150 | + ], | ||
| 151 | + [ | ||
| 152 | + "TRANSILIEN:J", | ||
| 153 | + "transilien-j.png" | ||
| 154 | + ], | ||
| 155 | + [ | ||
| 156 | + "TRANSILIEN:K", | ||
| 157 | + "transilien-k.png" | ||
| 158 | + ], | ||
| 159 | + [ | ||
| 160 | + "TRANSILIEN:L", | ||
| 161 | + "transilien-l.png" | ||
| 162 | + ], | ||
| 163 | + [ | ||
| 164 | + "TRANSILIEN:N", | ||
| 165 | + "transilien-n.png" | ||
| 166 | + ], | ||
| 167 | + [ | ||
| 168 | + "TRANSILIEN:P", | ||
| 169 | + "transilien-p.png" | ||
| 170 | + ], | ||
| 171 | + [ | ||
| 172 | + "TRANSILIEN:R", | ||
| 173 | + "transilien-r.png" | ||
| 174 | + ], | ||
| 175 | + [ | ||
| 176 | + "TRANSILIEN:U", | ||
| 177 | + "transilien-u.png" | ||
| 178 | + ], | ||
| 179 | + [ | ||
| 180 | + "TRANSILIEN:V", | ||
| 181 | + "transilien-v.png" | ||
| 182 | + ] | ||
| 183 | +]); | ||
| 184 | + | ||
| 185 | +export function lineBadgeAssetUrl(lineMode, lineLabel) { | ||
| 186 | + const file = ASSETS.get(`${lineMode}:${lineLabel}`); | ||
| 187 | + return file === undefined ? undefined : new URL(`../assets/line-badges/${file}`, import.meta.url).href; | ||
| 188 | +} | ||
added
packages/config-page/src/preview-fixture.js +7 -0 | new file mode 100644 | ||
| @@ -0,0 +1,7 @@ | ||
| 1 | +// Public, credential-free projection of fixtures/departures/foundation.json. | |
| 2 | +// The test suite keeps this display-only preview synchronized with the recorded | |
| 3 | +// departure fixture without making live departure normalization a page dependency. | |
| 4 | +export const RECORDED_PREVIEW = Object.freeze([ | |
| 5 | + Object.freeze({ minutes: 2, status: "DELAYED" }), | |
| 6 | + Object.freeze({ minutes: 6, status: "ON_TIME" }), | |
| 7 | +]); | |
| new file mode 100644 | |||
| @@ -0,0 +1,7 @@ | |||
| 1 | +// Public, credential-free projection of fixtures/departures/foundation.json. | ||
| 2 | +// The test suite keeps this display-only preview synchronized with the recorded | ||
| 3 | +// departure fixture without making live departure normalization a page dependency. | ||
| 4 | +export const RECORDED_PREVIEW = Object.freeze([ | ||
| 5 | + Object.freeze({ minutes: 2, status: "DELAYED" }), | ||
| 6 | + Object.freeze({ minutes: 6, status: "ON_TIME" }), | ||
| 7 | +]); | ||
modified
packages/config-page/styles/config-page.css +189 -138 | @@ -1,181 +1,232 @@ | ||
| 1 | -/* Lapin Futé configuration page: mobile-first, framework-free. */ | |
| 2 | - | |
| 3 | 1 | :root { |
| 4 | - color-scheme: light dark; | |
| 5 | - --bg: #f4f4f2; | |
| 6 | - --surface: #ffffff; | |
| 7 | - --text: #1c1c1e; | |
| 8 | - --muted: #5c5c60; | |
| 9 | - --accent: #0a6ebd; | |
| 10 | - --on-accent: #ffffff; | |
| 11 | - --danger: #b3261e; | |
| 12 | - --border: #d9d9de; | |
| 13 | -} | |
| 14 | - | |
| 15 | -@media (prefers-color-scheme: dark) { | |
| 16 | - :root { | |
| 17 | - --bg: #141416; | |
| 18 | - --surface: #1f1f23; | |
| 19 | - --text: #f2f2f4; | |
| 20 | - --muted: #a0a0a8; | |
| 21 | - --accent: #6db3f2; | |
| 22 | - --on-accent: #0b0b0d; | |
| 23 | - --danger: #f2b8b5; | |
| 24 | - --border: #3a3a40; | |
| 25 | - } | |
| 26 | -} | |
| 27 | - | |
| 28 | -* { | |
| 29 | - box-sizing: border-box; | |
| 2 | + color-scheme: light; | |
| 3 | + --navy: #102a43; | |
| 4 | + --blue: #1769aa; | |
| 5 | + --blue-soft: #eaf3fa; | |
| 6 | + --red: #c7353a; | |
| 7 | + --ink: #17212b; | |
| 8 | + --muted: #52616f; | |
| 9 | + --line: #cad6e0; | |
| 10 | + --paper: #f4f7f9; | |
| 11 | + --white: #ffffff; | |
| 12 | + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; | |
| 30 | 13 | } |
| 31 | 14 | |
| 15 | +* { box-sizing: border-box; } | |
| 16 | + | |
| 17 | +html:not(.ready) body { visibility: hidden; } | |
| 18 | + | |
| 32 | 19 | body { |
| 33 | 20 | margin: 0; |
| 34 | - background: var(--bg); | |
| 35 | - color: var(--text); | |
| 36 | - font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; | |
| 37 | - -webkit-text-size-adjust: 100%; | |
| 21 | + min-width: 18rem; | |
| 22 | + background: var(--paper); | |
| 23 | + color: var(--ink); | |
| 24 | + font-size: 1rem; | |
| 25 | + line-height: 1.45; | |
| 38 | 26 | } |
| 39 | 27 | |
| 40 | -main { | |
| 41 | - max-width: 30rem; | |
| 42 | - margin: 0 auto; | |
| 43 | - padding: 1rem 1rem 2rem; | |
| 44 | -} | |
| 28 | +button, input, select { font: inherit; } | |
| 29 | +button, a { -webkit-tap-highlight-color: transparent; } | |
| 45 | 30 | |
| 46 | -h1 { | |
| 47 | - font-size: 1.35rem; | |
| 48 | - margin: 0.25rem 0 1rem; | |
| 31 | +.masthead { | |
| 32 | + display: flex; | |
| 33 | + gap: 0.875rem; | |
| 34 | + align-items: center; | |
| 35 | + padding: max(1rem, env(safe-area-inset-top)) 1rem 1rem; | |
| 36 | + background: var(--navy); | |
| 37 | + color: var(--white); | |
| 38 | + border-bottom: 0.25rem solid var(--red); | |
| 39 | +} | |
| 40 | + | |
| 41 | +.route-mark { | |
| 42 | + display: inline-grid; | |
| 43 | + place-items: center; | |
| 44 | + flex: 0 0 auto; | |
| 45 | + min-width: 2.5rem; | |
| 46 | + min-height: 2.5rem; | |
| 47 | + padding: 0.25rem 0.5rem; | |
| 48 | + border-radius: 0.35rem; | |
| 49 | + background: var(--blue); | |
| 50 | + color: var(--white); | |
| 51 | + font-weight: 800; | |
| 52 | + letter-spacing: -0.02em; | |
| 53 | +} | |
| 54 | + | |
| 55 | +.line-badge { | |
| 56 | + display: inline-grid; | |
| 57 | + place-items: center; | |
| 58 | + flex: 0 0 auto; | |
| 59 | + min-width: 2.5rem; | |
| 60 | + min-height: 2.5rem; | |
| 61 | + vertical-align: middle; | |
| 62 | +} | |
| 63 | + | |
| 64 | +.line-badge img, .line-badge svg { | |
| 65 | + display: block; | |
| 66 | + width: auto; | |
| 67 | + height: 2.5rem; | |
| 68 | + max-width: 8rem; | |
| 49 | 69 | } |
| 50 | 70 | |
| 51 | -h2 { | |
| 52 | - font-size: 1.05rem; | |
| 53 | - margin: 0 0 0.5rem; | |
| 71 | +h1, h2, p { margin-top: 0; } | |
| 72 | +h1 { margin-bottom: 0.15rem; font-size: 1.45rem; line-height: 1.1; } | |
| 73 | +.masthead p { margin: 0; color: #dbe9f4; font-size: 0.88rem; } | |
| 74 | + | |
| 75 | +main { | |
| 76 | + width: min(100%, 38rem); | |
| 77 | + margin: 0 auto; | |
| 78 | + padding: 0.75rem; | |
| 54 | 79 | } |
| 55 | 80 | |
| 56 | 81 | section { |
| 57 | - background: var(--surface); | |
| 58 | - border: 1px solid var(--border); | |
| 59 | - border-radius: 0.75rem; | |
| 82 | + margin-bottom: 0.75rem; | |
| 60 | 83 | padding: 1rem; |
| 61 | - margin-bottom: 1rem; | |
| 84 | + background: var(--white); | |
| 85 | + border: 1px solid var(--line); | |
| 86 | + border-radius: 0.5rem; | |
| 62 | 87 | } |
| 63 | 88 | |
| 64 | -.status { | |
| 65 | - color: var(--muted); | |
| 66 | - margin: 0 0 0.75rem; | |
| 89 | +.section-heading { | |
| 90 | + display: flex; | |
| 91 | + align-items: baseline; | |
| 92 | + gap: 0.625rem; | |
| 93 | + margin-bottom: 0.75rem; | |
| 94 | + border-bottom: 1px solid var(--line); | |
| 67 | 95 | } |
| 68 | 96 | |
| 97 | +.step { color: var(--blue); font-size: 0.75rem; font-weight: 800; letter-spacing: 0.08em; } | |
| 98 | +h2 { margin-bottom: 0.55rem; color: var(--navy); font-size: 1.05rem; } | |
| 99 | +h3 { margin: 1.15rem 0 0.45rem; color: var(--navy); font-size: 0.98rem; } | |
| 100 | + | |
| 69 | 101 | .field-label { |
| 70 | 102 | display: block; |
| 71 | - margin: 0 0 0.35rem; | |
| 72 | - font-weight: 600; | |
| 103 | + margin: 0.8rem 0 0.35rem; | |
| 104 | + color: var(--navy); | |
| 105 | + font-size: 0.88rem; | |
| 106 | + font-weight: 700; | |
| 73 | 107 | } |
| 74 | 108 | |
| 75 | -.key-row { | |
| 76 | - display: flex; | |
| 77 | - gap: 0.5rem; | |
| 78 | -} | |
| 79 | - | |
| 80 | -#key-input { | |
| 81 | - flex: 1; | |
| 82 | - min-width: 0; | |
| 109 | +input, select { | |
| 110 | + width: 100%; | |
| 83 | 111 | min-height: 2.75rem; |
| 84 | - padding: 0 0.75rem; | |
| 85 | - border: 1px solid var(--border); | |
| 86 | - border-radius: 0.5rem; | |
| 87 | - background: var(--bg); | |
| 88 | - color: var(--text); | |
| 89 | - font: inherit; | |
| 112 | + padding: 0.65rem 0.75rem; | |
| 113 | + border: 1px solid #8fa3b5; | |
| 114 | + border-radius: 0.35rem; | |
| 115 | + background: var(--white); | |
| 116 | + color: var(--ink); | |
| 90 | 117 | } |
| 91 | 118 | |
| 92 | -#key-input:focus-visible, | |
| 93 | -button:focus-visible { | |
| 94 | - outline: 2px solid var(--accent); | |
| 95 | - outline-offset: 1px; | |
| 119 | +input:focus-visible, select:focus-visible, button:focus-visible, a:focus-visible { | |
| 120 | + outline: 0.2rem solid #ffb81c; | |
| 121 | + outline-offset: 0.15rem; | |
| 96 | 122 | } |
| 97 | 123 | |
| 124 | +.key-row { display: grid; grid-template-columns: 1fr auto; gap: 0.5rem; } | |
| 125 | +.status, .support, .pending, .error { margin-bottom: 0.55rem; font-size: 0.86rem; } | |
| 126 | +.status, .support { color: var(--muted); } | |
| 127 | +.pending { color: var(--blue); font-weight: 700; } | |
| 128 | +.error { color: #9b1c24; font-weight: 700; } | |
| 129 | +a { color: var(--blue); font-weight: 700; } | |
| 130 | + | |
| 98 | 131 | button { |
| 99 | 132 | min-height: 2.75rem; |
| 100 | - padding: 0 0.9rem; | |
| 101 | - border: 1px solid var(--border); | |
| 102 | - border-radius: 0.5rem; | |
| 103 | - background: var(--surface); | |
| 104 | - color: var(--text); | |
| 105 | - font: inherit; | |
| 106 | -} | |
| 107 | - | |
| 108 | -button:disabled { | |
| 109 | - opacity: 0.5; | |
| 110 | -} | |
| 111 | - | |
| 112 | -.primary { | |
| 113 | - width: 100%; | |
| 114 | - background: var(--accent); | |
| 115 | - border-color: var(--accent); | |
| 116 | - color: var(--on-accent); | |
| 117 | - font-weight: 600; | |
| 118 | -} | |
| 119 | - | |
| 120 | -.danger { | |
| 121 | - color: var(--danger); | |
| 122 | - border-color: var(--danger); | |
| 123 | -} | |
| 124 | - | |
| 125 | -.error { | |
| 126 | - color: var(--danger); | |
| 127 | - margin: 0.5rem 0 0; | |
| 128 | -} | |
| 129 | - | |
| 130 | -.pending { | |
| 131 | - color: var(--danger); | |
| 132 | - margin: 0.5rem 0; | |
| 133 | -} | |
| 134 | - | |
| 135 | -#key-remove { | |
| 136 | - margin-top: 0.25rem; | |
| 137 | -} | |
| 138 | - | |
| 139 | -#favorites-list { | |
| 140 | - list-style: none; | |
| 133 | + padding: 0.55rem 0.8rem; | |
| 134 | + border: 1px solid transparent; | |
| 135 | + border-radius: 0.35rem; | |
| 136 | + cursor: pointer; | |
| 137 | + font-weight: 750; | |
| 138 | +} | |
| 139 | +button:disabled { cursor: default; opacity: 0.45; } | |
| 140 | +.quiet { border-color: var(--line); background: var(--white); color: var(--navy); } | |
| 141 | +.danger { color: #9b1c24; } | |
| 142 | +#key-remove { margin-top: 0.25rem; } | |
| 143 | + | |
| 144 | +.favorites, .choice-list { | |
| 145 | + display: grid; | |
| 146 | + gap: 0.55rem; | |
| 141 | 147 | margin: 0; |
| 142 | 148 | padding: 0; |
| 143 | - display: grid; | |
| 144 | - gap: 0.5rem; | |
| 149 | + list-style: none; | |
| 145 | 150 | } |
| 146 | 151 | |
| 147 | 152 | .favorite { |
| 148 | - border: 1px solid var(--border); | |
| 149 | - border-radius: 0.5rem; | |
| 150 | - padding: 0.5rem 0.75rem; | |
| 153 | + display: grid; | |
| 154 | + grid-template-columns: auto 1fr; | |
| 155 | + gap: 0.65rem; | |
| 156 | + padding: 0.75rem 0; | |
| 157 | + border-bottom: 1px solid var(--line); | |
| 158 | +} | |
| 159 | +.favorite:last-child { border-bottom: 0; } | |
| 160 | +.favorite-labels { display: grid; align-content: center; min-width: 0; } | |
| 161 | +.favorite-labels strong, .favorite-labels span { overflow-wrap: anywhere; } | |
| 162 | +.favorite-labels span { color: var(--muted); font-size: 0.85rem; } | |
| 163 | +.favorite > input { grid-column: 1 / -1; } | |
| 164 | +.favorite-actions { | |
| 165 | + display: grid; | |
| 166 | + grid-column: 1 / -1; | |
| 167 | + grid-template-columns: repeat(3, 1fr); | |
| 168 | + gap: 0.4rem; | |
| 169 | +} | |
| 170 | +.favorite-actions button { min-height: 2.5rem; padding-inline: 0.35rem; font-size: 0.78rem; } | |
| 171 | + | |
| 172 | +.choice-list { margin-top: 0.6rem; } | |
| 173 | +.choice { width: 100%; border-color: var(--line); background: var(--blue-soft); color: var(--navy); text-align: left; } | |
| 174 | + | |
| 175 | +.preview { | |
| 176 | + margin-top: 0.85rem; | |
| 177 | + padding: 0.8rem; | |
| 178 | + border-left: 0.3rem solid var(--red); | |
| 179 | + background: var(--blue-soft); | |
| 180 | +} | |
| 181 | +.preview p { margin-bottom: 0.35rem; } | |
| 182 | +.preview .line-badge { min-width: 2rem; min-height: 2rem; margin-right: 0.35rem; } | |
| 183 | +.preview .line-badge img, .preview .line-badge svg { height: 2rem; } | |
| 184 | +.eyebrow { color: var(--blue); font-size: 0.72rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; } | |
| 185 | +.destination { padding-left: 2.85rem; color: var(--muted); } | |
| 186 | +.recorded-preview { color: var(--muted); font-size: 0.82rem; font-variant-numeric: tabular-nums; } | |
| 187 | +.secondary { width: 100%; margin-top: 0.75rem; background: var(--blue); color: var(--white); } | |
| 188 | + | |
| 189 | +footer { | |
| 190 | + position: sticky; | |
| 191 | + bottom: 0; | |
| 192 | + padding: 0.65rem max(0.75rem, env(safe-area-inset-right)) max(0.65rem, env(safe-area-inset-bottom)) max(0.75rem, env(safe-area-inset-left)); | |
| 193 | + background: rgba(244, 247, 249, 0.96); | |
| 194 | + border-top: 1px solid var(--line); | |
| 195 | +} | |
| 196 | +.footer-actions { | |
| 197 | + display: grid; | |
| 198 | + grid-template-columns: auto minmax(0, 1fr); | |
| 199 | + gap: 0.5rem; | |
| 200 | + width: min(100%, 36.5rem); | |
| 201 | + margin: 0 auto; | |
| 151 | 202 | } |
| 203 | +.primary { display: block; width: 100%; background: var(--red); color: var(--white); } | |
| 152 | 204 | |
| 153 | -.favorite .labels { | |
| 154 | - margin: 0; | |
| 155 | - font-weight: 600; | |
| 156 | -} | |
| 205 | +.about-view { overflow-wrap: anywhere; } | |
| 206 | +.about-heading { margin-top: 1rem; } | |
| 207 | +.about-version { color: var(--muted); font-size: 0.86rem; font-weight: 700; } | |
| 208 | +.about-view p { margin-bottom: 0.65rem; } | |
| 157 | 209 | |
| 158 | -.favorite .sub { | |
| 159 | - margin: 0.1rem 0 0; | |
| 160 | - color: var(--muted); | |
| 161 | - font-size: 0.9rem; | |
| 162 | - overflow-wrap: anywhere; | |
| 210 | +.visually-hidden { | |
| 211 | + position: absolute; | |
| 212 | + width: 1px; | |
| 213 | + height: 1px; | |
| 214 | + padding: 0; | |
| 215 | + margin: -1px; | |
| 216 | + overflow: hidden; | |
| 217 | + clip: rect(0, 0, 0, 0); | |
| 218 | + white-space: nowrap; | |
| 219 | + border: 0; | |
| 163 | 220 | } |
| 164 | 221 | |
| 165 | -.favorite-actions { | |
| 166 | - display: flex; | |
| 167 | - gap: 0.5rem; | |
| 168 | - margin-top: 0.5rem; | |
| 169 | -} | |
| 222 | +[hidden] { display: none !important; } | |
| 170 | 223 | |
| 171 | -.favorite-actions button { | |
| 172 | - min-height: 2.25rem; | |
| 173 | - padding: 0 0.6rem; | |
| 174 | - font-size: 0.9rem; | |
| 224 | +@media (min-width: 32rem) { | |
| 225 | + main { padding: 1rem; } | |
| 226 | + section { padding: 1.2rem; } | |
| 227 | + .masthead { padding-inline: calc((100% - 36rem) / 2); } | |
| 175 | 228 | } |
| 176 | 229 | |
| 177 | -@media (min-width: 30rem) { | |
| 178 | - main { | |
| 179 | - padding-top: 2rem; | |
| 180 | - } | |
| 230 | +@media (prefers-reduced-motion: reduce) { | |
| 231 | + *, *::before, *::after { scroll-behavior: auto !important; } | |
| 181 | 232 | } |
| @@ -1,181 +1,232 @@ | |||
| 1 | -/* Lapin Futé configuration page: mobile-first, framework-free. */ | ||
| 2 | - | ||
| 3 | :root { | 1 | :root { |
| 4 | - color-scheme: light dark; | 2 | + color-scheme: light; |
| 5 | - --bg: #f4f4f2; | 3 | + --navy: #102a43; |
| 6 | - --surface: #ffffff; | 4 | + --blue: #1769aa; |
| 7 | - --text: #1c1c1e; | 5 | + --blue-soft: #eaf3fa; |
| 8 | - --muted: #5c5c60; | 6 | + --red: #c7353a; |
| 9 | - --accent: #0a6ebd; | 7 | + --ink: #17212b; |
| 10 | - --on-accent: #ffffff; | 8 | + --muted: #52616f; |
| 11 | - --danger: #b3261e; | 9 | + --line: #cad6e0; |
| 12 | - --border: #d9d9de; | 10 | + --paper: #f4f7f9; |
| 13 | -} | 11 | + --white: #ffffff; |
| 14 | - | 12 | + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; |
| 15 | -@media (prefers-color-scheme: dark) { | ||
| 16 | - :root { | ||
| 17 | - --bg: #141416; | ||
| 18 | - --surface: #1f1f23; | ||
| 19 | - --text: #f2f2f4; | ||
| 20 | - --muted: #a0a0a8; | ||
| 21 | - --accent: #6db3f2; | ||
| 22 | - --on-accent: #0b0b0d; | ||
| 23 | - --danger: #f2b8b5; | ||
| 24 | - --border: #3a3a40; | ||
| 25 | - } | ||
| 26 | -} | ||
| 27 | - | ||
| 28 | -* { | ||
| 29 | - box-sizing: border-box; | ||
| 30 | } | 13 | } |
| 31 | 14 | ||
| 15 | +* { box-sizing: border-box; } | ||
| 16 | + | ||
| 17 | +html:not(.ready) body { visibility: hidden; } | ||
| 18 | + | ||
| 32 | body { | 19 | body { |
| 33 | margin: 0; | 20 | margin: 0; |
| 34 | - background: var(--bg); | 21 | + min-width: 18rem; |
| 35 | - color: var(--text); | 22 | + background: var(--paper); |
| 36 | - font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; | 23 | + color: var(--ink); |
| 37 | - -webkit-text-size-adjust: 100%; | 24 | + font-size: 1rem; |
| 25 | + line-height: 1.45; | ||
| 38 | } | 26 | } |
| 39 | 27 | ||
| 40 | -main { | 28 | +button, input, select { font: inherit; } |
| 41 | - max-width: 30rem; | 29 | +button, a { -webkit-tap-highlight-color: transparent; } |
| 42 | - margin: 0 auto; | ||
| 43 | - padding: 1rem 1rem 2rem; | ||
| 44 | -} | ||
| 45 | 30 | ||
| 46 | -h1 { | 31 | +.masthead { |
| 47 | - font-size: 1.35rem; | 32 | + display: flex; |
| 48 | - margin: 0.25rem 0 1rem; | 33 | + gap: 0.875rem; |
| 34 | + align-items: center; | ||
| 35 | + padding: max(1rem, env(safe-area-inset-top)) 1rem 1rem; | ||
| 36 | + background: var(--navy); | ||
| 37 | + color: var(--white); | ||
| 38 | + border-bottom: 0.25rem solid var(--red); | ||
| 39 | +} | ||
| 40 | + | ||
| 41 | +.route-mark { | ||
| 42 | + display: inline-grid; | ||
| 43 | + place-items: center; | ||
| 44 | + flex: 0 0 auto; | ||
| 45 | + min-width: 2.5rem; | ||
| 46 | + min-height: 2.5rem; | ||
| 47 | + padding: 0.25rem 0.5rem; | ||
| 48 | + border-radius: 0.35rem; | ||
| 49 | + background: var(--blue); | ||
| 50 | + color: var(--white); | ||
| 51 | + font-weight: 800; | ||
| 52 | + letter-spacing: -0.02em; | ||
| 53 | +} | ||
| 54 | + | ||
| 55 | +.line-badge { | ||
| 56 | + display: inline-grid; | ||
| 57 | + place-items: center; | ||
| 58 | + flex: 0 0 auto; | ||
| 59 | + min-width: 2.5rem; | ||
| 60 | + min-height: 2.5rem; | ||
| 61 | + vertical-align: middle; | ||
| 62 | +} | ||
| 63 | + | ||
| 64 | +.line-badge img, .line-badge svg { | ||
| 65 | + display: block; | ||
| 66 | + width: auto; | ||
| 67 | + height: 2.5rem; | ||
| 68 | + max-width: 8rem; | ||
| 49 | } | 69 | } |
| 50 | 70 | ||
| 51 | -h2 { | 71 | +h1, h2, p { margin-top: 0; } |
| 52 | - font-size: 1.05rem; | 72 | +h1 { margin-bottom: 0.15rem; font-size: 1.45rem; line-height: 1.1; } |
| 53 | - margin: 0 0 0.5rem; | 73 | +.masthead p { margin: 0; color: #dbe9f4; font-size: 0.88rem; } |
| 74 | + | ||
| 75 | +main { | ||
| 76 | + width: min(100%, 38rem); | ||
| 77 | + margin: 0 auto; | ||
| 78 | + padding: 0.75rem; | ||
| 54 | } | 79 | } |
| 55 | 80 | ||
| 56 | section { | 81 | section { |
| 57 | - background: var(--surface); | 82 | + margin-bottom: 0.75rem; |
| 58 | - border: 1px solid var(--border); | ||
| 59 | - border-radius: 0.75rem; | ||
| 60 | padding: 1rem; | 83 | padding: 1rem; |
| 61 | - margin-bottom: 1rem; | 84 | + background: var(--white); |
| 85 | + border: 1px solid var(--line); | ||
| 86 | + border-radius: 0.5rem; | ||
| 62 | } | 87 | } |
| 63 | 88 | ||
| 64 | -.status { | 89 | +.section-heading { |
| 65 | - color: var(--muted); | 90 | + display: flex; |
| 66 | - margin: 0 0 0.75rem; | 91 | + align-items: baseline; |
| 92 | + gap: 0.625rem; | ||
| 93 | + margin-bottom: 0.75rem; | ||
| 94 | + border-bottom: 1px solid var(--line); | ||
| 67 | } | 95 | } |
| 68 | 96 | ||
| 97 | +.step { color: var(--blue); font-size: 0.75rem; font-weight: 800; letter-spacing: 0.08em; } | ||
| 98 | +h2 { margin-bottom: 0.55rem; color: var(--navy); font-size: 1.05rem; } | ||
| 99 | +h3 { margin: 1.15rem 0 0.45rem; color: var(--navy); font-size: 0.98rem; } | ||
| 100 | + | ||
| 69 | .field-label { | 101 | .field-label { |
| 70 | display: block; | 102 | display: block; |
| 71 | - margin: 0 0 0.35rem; | 103 | + margin: 0.8rem 0 0.35rem; |
| 72 | - font-weight: 600; | 104 | + color: var(--navy); |
| 105 | + font-size: 0.88rem; | ||
| 106 | + font-weight: 700; | ||
| 73 | } | 107 | } |
| 74 | 108 | ||
| 75 | -.key-row { | 109 | +input, select { |
| 76 | - display: flex; | 110 | + width: 100%; |
| 77 | - gap: 0.5rem; | ||
| 78 | -} | ||
| 79 | - | ||
| 80 | -#key-input { | ||
| 81 | - flex: 1; | ||
| 82 | - min-width: 0; | ||
| 83 | min-height: 2.75rem; | 111 | min-height: 2.75rem; |
| 84 | - padding: 0 0.75rem; | 112 | + padding: 0.65rem 0.75rem; |
| 85 | - border: 1px solid var(--border); | 113 | + border: 1px solid #8fa3b5; |
| 86 | - border-radius: 0.5rem; | 114 | + border-radius: 0.35rem; |
| 87 | - background: var(--bg); | 115 | + background: var(--white); |
| 88 | - color: var(--text); | 116 | + color: var(--ink); |
| 89 | - font: inherit; | ||
| 90 | } | 117 | } |
| 91 | 118 | ||
| 92 | -#key-input:focus-visible, | 119 | +input:focus-visible, select:focus-visible, button:focus-visible, a:focus-visible { |
| 93 | -button:focus-visible { | 120 | + outline: 0.2rem solid #ffb81c; |
| 94 | - outline: 2px solid var(--accent); | 121 | + outline-offset: 0.15rem; |
| 95 | - outline-offset: 1px; | ||
| 96 | } | 122 | } |
| 97 | 123 | ||
| 124 | +.key-row { display: grid; grid-template-columns: 1fr auto; gap: 0.5rem; } | ||
| 125 | +.status, .support, .pending, .error { margin-bottom: 0.55rem; font-size: 0.86rem; } | ||
| 126 | +.status, .support { color: var(--muted); } | ||
| 127 | +.pending { color: var(--blue); font-weight: 700; } | ||
| 128 | +.error { color: #9b1c24; font-weight: 700; } | ||
| 129 | +a { color: var(--blue); font-weight: 700; } | ||
| 130 | + | ||
| 98 | button { | 131 | button { |
| 99 | min-height: 2.75rem; | 132 | min-height: 2.75rem; |
| 100 | - padding: 0 0.9rem; | 133 | + padding: 0.55rem 0.8rem; |
| 101 | - border: 1px solid var(--border); | 134 | + border: 1px solid transparent; |
| 102 | - border-radius: 0.5rem; | 135 | + border-radius: 0.35rem; |
| 103 | - background: var(--surface); | 136 | + cursor: pointer; |
| 104 | - color: var(--text); | 137 | + font-weight: 750; |
| 105 | - font: inherit; | 138 | +} |
| 106 | -} | 139 | +button:disabled { cursor: default; opacity: 0.45; } |
| 107 | - | 140 | +.quiet { border-color: var(--line); background: var(--white); color: var(--navy); } |
| 108 | -button:disabled { | 141 | +.danger { color: #9b1c24; } |
| 109 | - opacity: 0.5; | 142 | +#key-remove { margin-top: 0.25rem; } |
| 110 | -} | 143 | + |
| 111 | - | 144 | +.favorites, .choice-list { |
| 112 | -.primary { | 145 | + display: grid; |
| 113 | - width: 100%; | 146 | + gap: 0.55rem; |
| 114 | - background: var(--accent); | ||
| 115 | - border-color: var(--accent); | ||
| 116 | - color: var(--on-accent); | ||
| 117 | - font-weight: 600; | ||
| 118 | -} | ||
| 119 | - | ||
| 120 | -.danger { | ||
| 121 | - color: var(--danger); | ||
| 122 | - border-color: var(--danger); | ||
| 123 | -} | ||
| 124 | - | ||
| 125 | -.error { | ||
| 126 | - color: var(--danger); | ||
| 127 | - margin: 0.5rem 0 0; | ||
| 128 | -} | ||
| 129 | - | ||
| 130 | -.pending { | ||
| 131 | - color: var(--danger); | ||
| 132 | - margin: 0.5rem 0; | ||
| 133 | -} | ||
| 134 | - | ||
| 135 | -#key-remove { | ||
| 136 | - margin-top: 0.25rem; | ||
| 137 | -} | ||
| 138 | - | ||
| 139 | -#favorites-list { | ||
| 140 | - list-style: none; | ||
| 141 | margin: 0; | 147 | margin: 0; |
| 142 | padding: 0; | 148 | padding: 0; |
| 143 | - display: grid; | 149 | + list-style: none; |
| 144 | - gap: 0.5rem; | ||
| 145 | } | 150 | } |
| 146 | 151 | ||
| 147 | .favorite { | 152 | .favorite { |
| 148 | - border: 1px solid var(--border); | 153 | + display: grid; |
| 149 | - border-radius: 0.5rem; | 154 | + grid-template-columns: auto 1fr; |
| 150 | - padding: 0.5rem 0.75rem; | 155 | + gap: 0.65rem; |
| 156 | + padding: 0.75rem 0; | ||
| 157 | + border-bottom: 1px solid var(--line); | ||
| 158 | +} | ||
| 159 | +.favorite:last-child { border-bottom: 0; } | ||
| 160 | +.favorite-labels { display: grid; align-content: center; min-width: 0; } | ||
| 161 | +.favorite-labels strong, .favorite-labels span { overflow-wrap: anywhere; } | ||
| 162 | +.favorite-labels span { color: var(--muted); font-size: 0.85rem; } | ||
| 163 | +.favorite > input { grid-column: 1 / -1; } | ||
| 164 | +.favorite-actions { | ||
| 165 | + display: grid; | ||
| 166 | + grid-column: 1 / -1; | ||
| 167 | + grid-template-columns: repeat(3, 1fr); | ||
| 168 | + gap: 0.4rem; | ||
| 169 | +} | ||
| 170 | +.favorite-actions button { min-height: 2.5rem; padding-inline: 0.35rem; font-size: 0.78rem; } | ||
| 171 | + | ||
| 172 | +.choice-list { margin-top: 0.6rem; } | ||
| 173 | +.choice { width: 100%; border-color: var(--line); background: var(--blue-soft); color: var(--navy); text-align: left; } | ||
| 174 | + | ||
| 175 | +.preview { | ||
| 176 | + margin-top: 0.85rem; | ||
| 177 | + padding: 0.8rem; | ||
| 178 | + border-left: 0.3rem solid var(--red); | ||
| 179 | + background: var(--blue-soft); | ||
| 180 | +} | ||
| 181 | +.preview p { margin-bottom: 0.35rem; } | ||
| 182 | +.preview .line-badge { min-width: 2rem; min-height: 2rem; margin-right: 0.35rem; } | ||
| 183 | +.preview .line-badge img, .preview .line-badge svg { height: 2rem; } | ||
| 184 | +.eyebrow { color: var(--blue); font-size: 0.72rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; } | ||
| 185 | +.destination { padding-left: 2.85rem; color: var(--muted); } | ||
| 186 | +.recorded-preview { color: var(--muted); font-size: 0.82rem; font-variant-numeric: tabular-nums; } | ||
| 187 | +.secondary { width: 100%; margin-top: 0.75rem; background: var(--blue); color: var(--white); } | ||
| 188 | + | ||
| 189 | +footer { | ||
| 190 | + position: sticky; | ||
| 191 | + bottom: 0; | ||
| 192 | + padding: 0.65rem max(0.75rem, env(safe-area-inset-right)) max(0.65rem, env(safe-area-inset-bottom)) max(0.75rem, env(safe-area-inset-left)); | ||
| 193 | + background: rgba(244, 247, 249, 0.96); | ||
| 194 | + border-top: 1px solid var(--line); | ||
| 195 | +} | ||
| 196 | +.footer-actions { | ||
| 197 | + display: grid; | ||
| 198 | + grid-template-columns: auto minmax(0, 1fr); | ||
| 199 | + gap: 0.5rem; | ||
| 200 | + width: min(100%, 36.5rem); | ||
| 201 | + margin: 0 auto; | ||
| 151 | } | 202 | } |
| 203 | +.primary { display: block; width: 100%; background: var(--red); color: var(--white); } | ||
| 152 | 204 | ||
| 153 | -.favorite .labels { | 205 | +.about-view { overflow-wrap: anywhere; } |
| 154 | - margin: 0; | 206 | +.about-heading { margin-top: 1rem; } |
| 155 | - font-weight: 600; | 207 | +.about-version { color: var(--muted); font-size: 0.86rem; font-weight: 700; } |
| 156 | -} | 208 | +.about-view p { margin-bottom: 0.65rem; } |
| 157 | 209 | ||
| 158 | -.favorite .sub { | 210 | +.visually-hidden { |
| 159 | - margin: 0.1rem 0 0; | 211 | + position: absolute; |
| 160 | - color: var(--muted); | 212 | + width: 1px; |
| 161 | - font-size: 0.9rem; | 213 | + height: 1px; |
| 162 | - overflow-wrap: anywhere; | 214 | + padding: 0; |
| 215 | + margin: -1px; | ||
| 216 | + overflow: hidden; | ||
| 217 | + clip: rect(0, 0, 0, 0); | ||
| 218 | + white-space: nowrap; | ||
| 219 | + border: 0; | ||
| 163 | } | 220 | } |
| 164 | 221 | ||
| 165 | -.favorite-actions { | 222 | +[hidden] { display: none !important; } |
| 166 | - display: flex; | ||
| 167 | - gap: 0.5rem; | ||
| 168 | - margin-top: 0.5rem; | ||
| 169 | -} | ||
| 170 | 223 | ||
| 171 | -.favorite-actions button { | 224 | +@media (min-width: 32rem) { |
| 172 | - min-height: 2.25rem; | 225 | + main { padding: 1rem; } |
| 173 | - padding: 0 0.6rem; | 226 | + section { padding: 1.2rem; } |
| 174 | - font-size: 0.9rem; | 227 | + .masthead { padding-inline: calc((100% - 36rem) / 2); } |
| 175 | } | 228 | } |
| 176 | 229 | ||
| 177 | -@media (min-width: 30rem) { | 230 | +@media (prefers-reduced-motion: reduce) { |
| 178 | - main { | 231 | + *, *::before, *::after { scroll-behavior: auto !important; } |
| 179 | - padding-top: 2rem; | ||
| 180 | - } | ||
| 181 | } | 232 | } |
modified
packages/config-page/test/config-page.test.ts +245 -6 | @@ -23,8 +23,11 @@ import { | ||
| 23 | 23 | copyFor, |
| 24 | 24 | createCloseSession, |
| 25 | 25 | encodeCloseFragment, |
| 26 | + favoriteFromService, | |
| 26 | 27 | initialConfigState, |
| 27 | 28 | isFavoriteShape, |
| 29 | + isPlaceSearchResult, | |
| 30 | + isServiceOptionsResult, | |
| 28 | 31 | parseConfigFragment, |
| 29 | 32 | planApiKeyUpdate, |
| 30 | 33 | planConfigResult, |
| @@ -32,12 +35,34 @@ import { | ||
| 32 | 35 | selectLocale, |
| 33 | 36 | utf8Bytes, |
| 34 | 37 | } from "../src/config-core.js"; |
| 38 | +import { | |
| 39 | + SEARCH_DEBOUNCE_MS, | |
| 40 | + CatalogClientError, | |
| 41 | + createCatalogClient, | |
| 42 | +} from "../src/catalog-client.js"; | |
| 43 | +import { RECORDED_PREVIEW } from "../src/preview-fixture.js"; | |
| 44 | +import { lineBadgeAssetUrl } from "../src/line-badge-assets.js"; | |
| 35 | 45 | |
| 36 | 46 | const here = dirname(fileURLToPath(import.meta.url)); |
| 37 | 47 | const require = createRequire(import.meta.url); |
| 38 | 48 | const companionConfiguration = require("../../companion/src/configuration.js"); |
| 39 | 49 | |
| 40 | 50 | function favorite(id: string, sortOrder: number): Favorite { |
| 51 | + return { | |
| 52 | + schemaVersion: 1, | |
| 53 | + id, | |
| 54 | + serviceId: `service:${id}`, | |
| 55 | + stopLabel: `Arrêt ${id}`, | |
| 56 | + lineLabel: "Métro 1", | |
| 57 | + destinationLabel: "La Défense", | |
| 58 | + lineMode: "METRO", | |
| 59 | + lineColor: "#ffbe00", | |
| 60 | + lineTextColor: "#000000", | |
| 61 | + sortOrder, | |
| 62 | + }; | |
| 63 | +} | |
| 64 | + | |
| 65 | +function legacyFavorite(id: string, sortOrder: number): Favorite { | |
| 41 | 66 | return { |
| 42 | 67 | schemaVersion: 1, |
| 43 | 68 | id, |
| @@ -66,6 +91,9 @@ test("mirrored constants equal the canonical contract", () => { | ||
| 66 | 91 | assert.equal(LIMITS.idUtf8Bytes, CANONICAL_LIMITS.idUtf8Bytes); |
| 67 | 92 | assert.equal(LIMITS.labelUtf8Bytes, CANONICAL_LIMITS.labelUtf8Bytes); |
| 68 | 93 | assert.equal(LIMITS.favorites, CANONICAL_LIMITS.favorites); |
| 94 | + assert.equal(LIMITS.catalogQueryMinCharacters, CANONICAL_LIMITS.catalogQueryMinCharacters); | |
| 95 | + assert.equal(LIMITS.catalogQueryMaxCharacters, CANONICAL_LIMITS.catalogQueryMaxCharacters); | |
| 96 | + assert.equal(LIMITS.catalogSearchResults, CANONICAL_LIMITS.catalogSearchResults); | |
| 69 | 97 | }); |
| 70 | 98 | |
| 71 | 99 | test("french language tags select french copy and everything else falls back to english", () => { |
| @@ -127,6 +155,43 @@ test("opening fragment parsing is secret-free and validates favorites", () => { | ||
| 127 | 155 | ); |
| 128 | 156 | assert.deepEqual(invalid.favorites.map((entry) => entry.id), ["ok"]); |
| 129 | 157 | }); |
| 158 | +test("legacy favorites round trip without inventing presentation properties and partial groups reject", () => { | |
| 159 | + const legacy = legacyFavorite("legacy", 0); | |
| 160 | + const parsed = parseConfigFragment(fragmentWith([legacy])); | |
| 161 | + const presentationFields = ["lineMode", "lineColor", "lineTextColor"] as const; | |
| 162 | + | |
| 163 | + assert.equal(isFavorite(legacy), true); | |
| 164 | + assert.equal(isFavoriteShape(legacy), true); | |
| 165 | + assert.deepEqual(parsed.favorites, [legacy]); | |
| 166 | + for (const field of presentationFields) { | |
| 167 | + assert.equal(Object.hasOwn(parsed.favorites[0], field), false); | |
| 168 | + } | |
| 169 | + | |
| 170 | + const partials = [ | |
| 171 | + { ...legacy, lineMode: "METRO" }, | |
| 172 | + { ...legacy, lineMode: "METRO", lineColor: "#ffbe00" }, | |
| 173 | + { ...legacy, lineColor: "#ffbe00", lineTextColor: "#000000" }, | |
| 174 | + ]; | |
| 175 | + for (const partial of partials) { | |
| 176 | + assert.equal(isFavorite(partial), false); | |
| 177 | + assert.equal(isFavoriteShape(partial), false); | |
| 178 | + assert.deepEqual(parseConfigFragment(openingFragment({ | |
| 179 | + hasKey: true, | |
| 180 | + favorites: [partial], | |
| 181 | + language: "fr_FR", | |
| 182 | + })).favorites, []); | |
| 183 | + } | |
| 184 | + assert.equal(isFavorite({ ...legacy, lineMode: undefined }), false); | |
| 185 | + assert.equal(isFavoriteShape({ ...legacy, lineMode: undefined }), false); | |
| 186 | + | |
| 187 | + const outcome = planConfigResult(initialConfigState(parsed)); | |
| 188 | + assert.equal(outcome.ok, true); | |
| 189 | + if (!outcome.ok) return; | |
| 190 | + assert.deepEqual(outcome.payload.favorites, [legacy]); | |
| 191 | + for (const field of presentationFields) { | |
| 192 | + assert.equal(Object.hasOwn(outcome.payload.favorites[0], field), false); | |
| 193 | + } | |
| 194 | +}); | |
| 130 | 195 | test("the config page consumes the companion-produced opening fragment exactly", () => { |
| 131 | 196 | const key = "stored-personal-key"; |
| 132 | 197 | const url = companionConfiguration.configurationUrl( |
| @@ -288,6 +353,9 @@ test("favorite edits replace the whole list atomically with renumbered order", ( | ||
| 288 | 353 | stopLabel: "Arrêt work", |
| 289 | 354 | lineLabel: "Métro 1", |
| 290 | 355 | destinationLabel: "La Défense", |
| 356 | + lineMode: "METRO", | |
| 357 | + lineColor: "#ffbe00", | |
| 358 | + lineTextColor: "#000000", | |
| 291 | 359 | sortOrder: 0, |
| 292 | 360 | displayName: "Bureau", |
| 293 | 361 | }, |
| @@ -367,11 +435,182 @@ test("the close fragment carries the key value only for REPLACE and is one-shot" | ||
| 367 | 435 | assert.equal(encodeCloseFragment(removeOutcome.payload).includes("super-secret-key"), false); |
| 368 | 436 | }); |
| 369 | 437 | |
| 370 | -test("page sources contain no storage, network, timer, or logging surface", () => { | |
| 371 | - const forbidden = | |
| 372 | - /fetch\(|XMLHttpRequest|localStorage|sessionStorage|document\.cookie|WebSocket|console\.|setInterval|setTimeout\(/; | |
| 373 | - for (const file of ["config-core.js", "config-page.js"]) { | |
| 374 | - const source = readFileSync(join(here, "../src", file), "utf8"); | |
| 375 | - assert.doesNotMatch(source, forbidden); | |
| 438 | +test("three backend services can be added, renamed, reordered, and removed atomically", () => { | |
| 439 | + const services = [ | |
| 440 | + { serviceId: "svc-a", stopLabel: "Châtelet", lineLabel: "4", destinationLabel: "Bagneux", lineMode: "METRO", lineColor: "#be418d", lineTextColor: "#ffffff" }, | |
| 441 | + { serviceId: "svc-b", stopLabel: "République", lineLabel: "96", destinationLabel: "Porte des Lilas", lineMode: "BUS", lineColor: "#007852", lineTextColor: "#ffffff" }, | |
| 442 | + { serviceId: "svc-c", stopLabel: "Nation", lineLabel: "A", destinationLabel: "Cergy", lineMode: "RER", lineColor: "#e3051c", lineTextColor: "#ffffff" }, | |
| 443 | + ]; | |
| 444 | + let state = initialConfigState(parseConfigFragment(fragmentWith([]))); | |
| 445 | + services.forEach((service, index) => { | |
| 446 | + const entry = favoriteFromService(`cfg-${index}`, service, index); | |
| 447 | + assert.ok(entry); | |
| 448 | + state = reduceConfigState(state, { type: "favorite-add", favorite: entry }); | |
| 449 | + }); | |
| 450 | + assert.deepEqual(state.favorites.map((entry) => entry.serviceId), ["svc-a", "svc-b", "svc-c"]); | |
| 451 | + assert.deepEqual( | |
| 452 | + state.favorites.map(({ lineMode, lineColor, lineTextColor }) => ({ lineMode, lineColor, lineTextColor })), | |
| 453 | + services.map(({ lineMode, lineColor, lineTextColor }) => ({ lineMode, lineColor, lineTextColor })), | |
| 454 | + ); | |
| 455 | + state = reduceConfigState(state, { type: "favorite-rename", id: "cfg-1", displayName: "Travail" }); | |
| 456 | + state = reduceConfigState(state, { type: "favorite-move", id: "cfg-2", delta: -2 }); | |
| 457 | + state = reduceConfigState(state, { type: "favorite-remove", id: "cfg-0" }); | |
| 458 | + assert.deepEqual(state.favorites.map((entry) => [entry.id, entry.sortOrder, entry.displayName]), [ | |
| 459 | + ["cfg-2", 0, undefined], | |
| 460 | + ["cfg-1", 1, "Travail"], | |
| 461 | + ]); | |
| 462 | + const outcome = planConfigResult(state); | |
| 463 | + assert.equal(outcome.ok, true); | |
| 464 | + if (outcome.ok) assert.equal(outcome.payload.favorites.every(isFavorite), true); | |
| 465 | +}); | |
| 466 | + | |
| 467 | +test("catalog response guards reject identifiers and malformed or oversized collections", () => { | |
| 468 | + const place = { placeId: "plc-a", stopLabel: "Châtelet", localityLabel: "Paris", mode: "METRO" }; | |
| 469 | + const service = { serviceId: "svc-a", stopLabel: "Châtelet", lineLabel: "4", destinationLabel: "Bagneux", lineMode: "METRO", lineColor: "#be418d", lineTextColor: "#ffffff" }; | |
| 470 | + assert.equal(isPlaceSearchResult({ schemaVersion: 1, places: [place] }), true); | |
| 471 | + assert.equal(isPlaceSearchResult({ schemaVersion: 1, places: Array(21).fill(place) }), false); | |
| 472 | + assert.equal(isPlaceSearchResult({ schemaVersion: 1, places: [{ ...place, monitoringRef: "raw" }] }), false); | |
| 473 | + assert.equal(isServiceOptionsResult({ schemaVersion: 1, placeId: "plc-a", services: [service] }, "plc-a"), true); | |
| 474 | + assert.equal(isServiceOptionsResult({ schemaVersion: 1, placeId: "other", services: [service] }, "plc-a"), false); | |
| 475 | + assert.equal(favoriteFromService("cfg-a", { ...service, lineRef: "raw" }, 0), null); | |
| 476 | + assert.equal(isServiceOptionsResult({ schemaVersion: 1, placeId: "plc-a", services: [{ ...service, lineColor: "#BE418D" }] }, "plc-a"), false); | |
| 477 | + assert.equal(isServiceOptionsResult({ schemaVersion: 1, placeId: "plc-a", services: [{ ...service, lineMode: "metro" }] }, "plc-a"), false); | |
| 478 | + const { lineTextColor: _missingTextColor, ...missingTextColor } = service; | |
| 479 | + assert.equal(favoriteFromService("cfg-a", missingTextColor, 0), null); | |
| 480 | +}); | |
| 481 | + | |
| 482 | +test("favorite preview uses the credential-free recorded departure fixture", () => { | |
| 483 | + const recorded = JSON.parse( | |
| 484 | + readFileSync(join(here, "../../../fixtures/departures/foundation.json"), "utf8"), | |
| 485 | + ); | |
| 486 | + assert.deepEqual( | |
| 487 | + RECORDED_PREVIEW, | |
| 488 | + recorded.result.departures.map(({ minutes, status }: { minutes: number; status: string }) => ({ minutes, status })), | |
| 489 | + ); | |
| 490 | + assert.equal(JSON.stringify(RECORDED_PREVIEW).includes(recorded.favorite.serviceId), false); | |
| 491 | +}); | |
| 492 | +test("catalog search waits 300 ms, cancels superseded work, and validates responses", async () => { | |
| 493 | + const timers = new Map<number, () => void>(); | |
| 494 | + const delays: number[] = []; | |
| 495 | + let nextTimer = 0; | |
| 496 | + const requests: string[] = []; | |
| 497 | + const client = createCatalogClient({ | |
| 498 | + setTimer(callback: () => void, delay: number) { | |
| 499 | + const id = ++nextTimer; | |
| 500 | + | |
| 501 | + delays.push(delay); | |
| 502 | + timers.set(id, callback); | |
| 503 | + return id; | |
| 504 | + }, | |
| 505 | + clearTimer(id: number) { | |
| 506 | + timers.delete(id); | |
| 507 | + }, | |
| 508 | + async fetchImpl(url: string) { | |
| 509 | + requests.push(url); | |
| 510 | + return { | |
| 511 | + ok: true, | |
| 512 | + async json() { | |
| 513 | + return { schemaVersion: 1, places: [] }; | |
| 514 | + }, | |
| 515 | + }; | |
| 516 | + }, | |
| 517 | + }); | |
| 518 | + const superseded = client.searchPlaces("ch"); | |
| 519 | + const latest = client.searchPlaces("cha"); | |
| 520 | + await assert.rejects(superseded, (error: unknown) => error instanceof DOMException && error.name === "AbortError"); | |
| 521 | + assert.deepEqual(delays, [SEARCH_DEBOUNCE_MS, SEARCH_DEBOUNCE_MS]); | |
| 522 | + assert.equal(timers.size, 1); | |
| 523 | + [...timers.values()][0]!(); | |
| 524 | + assert.deepEqual(await latest, []); | |
| 525 | + assert.deepEqual(requests, ["/api/catalog/places?q=cha"]); | |
| 526 | + | |
| 527 | + const invalid = createCatalogClient({ | |
| 528 | + setTimer(callback: () => void) { | |
| 529 | + queueMicrotask(callback); | |
| 530 | + return 1; | |
| 531 | + }, | |
| 532 | + clearTimer() {}, | |
| 533 | + async fetchImpl() { | |
| 534 | + return { ok: true, async json() { return { schemaVersion: 1, places: [{ placeId: "leak" }] }; } }; | |
| 535 | + }, | |
| 536 | + }); | |
| 537 | + await assert.rejects(invalid.searchPlaces("invalid"), (error: unknown) => | |
| 538 | + error instanceof CatalogClientError && error.code === "BACKEND_UNAVAILABLE"); | |
| 539 | +}); | |
| 540 | + | |
| 541 | +test("page keeps secrets out of durable and observable surfaces", () => { | |
| 542 | + const core = readFileSync(join(here, "../src/config-core.js"), "utf8"); | |
| 543 | + const controller = readFileSync(join(here, "../src/config-page.js"), "utf8"); | |
| 544 | + const client = readFileSync(join(here, "../src/catalog-client.js"), "utf8"); | |
| 545 | + const html = readFileSync(join(here, "../index.html"), "utf8"); | |
| 546 | + for (const source of [core, controller, client]) { | |
| 547 | + assert.doesNotMatch(source, /localStorage|sessionStorage|document\\.cookie|WebSocket|console\\./u); | |
| 548 | + } | |
| 549 | + assert.doesNotMatch(client, /prim\\.iledefrance-mobilites/u); | |
| 550 | + assert.match(html, /type="password" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"/u); | |
| 551 | + assert.match(html, /connect-src 'self'/u); | |
| 552 | + assert.doesNotMatch(html, /value=["'][^"']+["']/u); | |
| 553 | + assert.match(html, /img-src 'self'/u); | |
| 554 | + assert.doesNotMatch(html, /img-src 'none'/u); | |
| 555 | +}); | |
| 556 | + | |
| 557 | +test("bilingual About copy exposes the required legal, privacy, attribution, and safe-link facts", () => { | |
| 558 | + const html = readFileSync(join(here, "../index.html"), "utf8"); | |
| 559 | + const controller = readFileSync(join(here, "../src/config-page.js"), "utf8"); | |
| 560 | + for (const fact of [ | |
| 561 | + "Version 0.1.0", | |
| 562 | + "Publication director:", | |
| 563 | + "Directeur de la publication :", | |
| 564 | + "Anthodev", | |
| 565 | + "88 Colin P. Kelly Jr. St.", | |
| 566 | + "localStorage", | |
| 567 | + "no analytics or telemetry", | |
| 568 | + "aucun cookie", | |
| 569 | + "Référentiel des lignes", | |
| 570 | + "conditions d’utilisation IDFM/PRIM", | |
| 571 | + "search terms are sent", | |
| 572 | + "termes saisis sont transmis", | |
| 573 | + "ODbL 1.0", | |
| 574 | + "github.com/Anthodev/lapin-fute", | |
| 575 | + ]) assert.equal(html.includes(fact), true, fact); | |
| 576 | + assert.doesNotMatch(html, /software licen[cs]e|licence du logiciel/iu); | |
| 577 | + assert.equal((html.match(/target="_blank"/gu) ?? []).length, (html.match(/rel="noopener noreferrer"/gu) ?? []).length); | |
| 578 | + assert.match(controller, /lineBadgeAssetUrl\(service\.lineMode, service\.lineLabel\)/u); | |
| 579 | + assert.match(controller, /image\.addEventListener\("error"/u); | |
| 580 | + assert.match(controller, /elements\.config_view\.hidden = true/u); | |
| 581 | + assert.match(controller, /aboutReturnFocus\.focus\(\)/u); | |
| 582 | +}); | |
| 583 | + | |
| 584 | +test("official rail badges cover every RER and Transilien line", () => { | |
| 585 | + for (const line of ["A", "B", "C", "D", "E"]) { | |
| 586 | + assert.match(lineBadgeAssetUrl("RER", line) ?? "", new RegExp(`/rer-${line.toLowerCase()}\\.png$`, "u")); | |
| 587 | + } | |
| 588 | + for (const line of ["H", "J", "K", "L", "N", "P", "R", "U", "V"]) { | |
| 589 | + assert.match( | |
| 590 | + lineBadgeAssetUrl("TRANSILIEN", line) ?? "", | |
| 591 | + new RegExp(`/transilien-${line.toLowerCase()}\\.png$`, "u"), | |
| 592 | + ); | |
| 376 | 593 | } |
| 377 | 594 | }); |
| 595 | + | |
| 596 | +test("legacy favorites resolve no official image and use an accessible neutral fallback", () => { | |
| 597 | + const legacy = legacyFavorite("legacy-badge", 0); | |
| 598 | + const controller = readFileSync(join(here, "../src/config-page.js"), "utf8"); | |
| 599 | + | |
| 600 | + assert.equal(lineBadgeAssetUrl(legacy.lineMode, legacy.lineLabel), undefined); | |
| 601 | + assert.match( | |
| 602 | + controller, | |
| 603 | + /const assetUrl = service\.lineMode === undefined\s+\? undefined\s+:\s+lineBadgeAssetUrl\(service\.lineMode, service\.lineLabel\)/u, | |
| 604 | + ); | |
| 605 | + assert.match(controller, /const NEUTRAL_LINE_BACKGROUND = "#52616f"/u); | |
| 606 | + assert.match(controller, /const NEUTRAL_LINE_TEXT = "#ffffff"/u); | |
| 607 | + assert.match(controller, /const backgroundColor = service\.lineColor \?\? NEUTRAL_LINE_BACKGROUND/u); | |
| 608 | + assert.match(controller, /const textColor = service\.lineTextColor \?\? NEUTRAL_LINE_TEXT/u); | |
| 609 | + assert.match(controller, /svg\.setAttribute\("role", "img"\)/u); | |
| 610 | + assert.match(controller, /svg\.setAttribute\("aria-label", service\.lineLabel\)/u); | |
| 611 | +}); | |
| 612 | + | |
| 613 | +test("the first-load gzip metric includes the eagerly imported badge resolver", () => { | |
| 614 | + const measurement = readFileSync(join(here, "../../../scripts/measure-config-page.mjs"), "utf8"); | |
| 615 | + assert.match(measurement, /packages\/config-page\/src\/line-badge-assets\.js/u); | |
| 616 | +}); | |
| @@ -23,8 +23,11 @@ import { | |||
| 23 | copyFor, | 23 | copyFor, |
| 24 | createCloseSession, | 24 | createCloseSession, |
| 25 | encodeCloseFragment, | 25 | encodeCloseFragment, |
| 26 | + favoriteFromService, | ||
| 26 | initialConfigState, | 27 | initialConfigState, |
| 27 | isFavoriteShape, | 28 | isFavoriteShape, |
| 29 | + isPlaceSearchResult, | ||
| 30 | + isServiceOptionsResult, | ||
| 28 | parseConfigFragment, | 31 | parseConfigFragment, |
| 29 | planApiKeyUpdate, | 32 | planApiKeyUpdate, |
| 30 | planConfigResult, | 33 | planConfigResult, |
| @@ -32,12 +35,34 @@ import { | |||
| 32 | selectLocale, | 35 | selectLocale, |
| 33 | utf8Bytes, | 36 | utf8Bytes, |
| 34 | } from "../src/config-core.js"; | 37 | } from "../src/config-core.js"; |
| 38 | +import { | ||
| 39 | + SEARCH_DEBOUNCE_MS, | ||
| 40 | + CatalogClientError, | ||
| 41 | + createCatalogClient, | ||
| 42 | +} from "../src/catalog-client.js"; | ||
| 43 | +import { RECORDED_PREVIEW } from "../src/preview-fixture.js"; | ||
| 44 | +import { lineBadgeAssetUrl } from "../src/line-badge-assets.js"; | ||
| 35 | 45 | ||
| 36 | const here = dirname(fileURLToPath(import.meta.url)); | 46 | const here = dirname(fileURLToPath(import.meta.url)); |
| 37 | const require = createRequire(import.meta.url); | 47 | const require = createRequire(import.meta.url); |
| 38 | const companionConfiguration = require("../../companion/src/configuration.js"); | 48 | const companionConfiguration = require("../../companion/src/configuration.js"); |
| 39 | 49 | ||
| 40 | function favorite(id: string, sortOrder: number): Favorite { | 50 | function favorite(id: string, sortOrder: number): Favorite { |
| 51 | + return { | ||
| 52 | + schemaVersion: 1, | ||
| 53 | + id, | ||
| 54 | + serviceId: `service:${id}`, | ||
| 55 | + stopLabel: `Arrêt ${id}`, | ||
| 56 | + lineLabel: "Métro 1", | ||
| 57 | + destinationLabel: "La Défense", | ||
| 58 | + lineMode: "METRO", | ||
| 59 | + lineColor: "#ffbe00", | ||
| 60 | + lineTextColor: "#000000", | ||
| 61 | + sortOrder, | ||
| 62 | + }; | ||
| 63 | +} | ||
| 64 | + | ||
| 65 | +function legacyFavorite(id: string, sortOrder: number): Favorite { | ||
| 41 | return { | 66 | return { |
| 42 | schemaVersion: 1, | 67 | schemaVersion: 1, |
| 43 | id, | 68 | id, |
| @@ -66,6 +91,9 @@ test("mirrored constants equal the canonical contract", () => { | |||
| 66 | assert.equal(LIMITS.idUtf8Bytes, CANONICAL_LIMITS.idUtf8Bytes); | 91 | assert.equal(LIMITS.idUtf8Bytes, CANONICAL_LIMITS.idUtf8Bytes); |
| 67 | assert.equal(LIMITS.labelUtf8Bytes, CANONICAL_LIMITS.labelUtf8Bytes); | 92 | assert.equal(LIMITS.labelUtf8Bytes, CANONICAL_LIMITS.labelUtf8Bytes); |
| 68 | assert.equal(LIMITS.favorites, CANONICAL_LIMITS.favorites); | 93 | assert.equal(LIMITS.favorites, CANONICAL_LIMITS.favorites); |
| 94 | + assert.equal(LIMITS.catalogQueryMinCharacters, CANONICAL_LIMITS.catalogQueryMinCharacters); | ||
| 95 | + assert.equal(LIMITS.catalogQueryMaxCharacters, CANONICAL_LIMITS.catalogQueryMaxCharacters); | ||
| 96 | + assert.equal(LIMITS.catalogSearchResults, CANONICAL_LIMITS.catalogSearchResults); | ||
| 69 | }); | 97 | }); |
| 70 | 98 | ||
| 71 | test("french language tags select french copy and everything else falls back to english", () => { | 99 | test("french language tags select french copy and everything else falls back to english", () => { |
| @@ -127,6 +155,43 @@ test("opening fragment parsing is secret-free and validates favorites", () => { | |||
| 127 | ); | 155 | ); |
| 128 | assert.deepEqual(invalid.favorites.map((entry) => entry.id), ["ok"]); | 156 | assert.deepEqual(invalid.favorites.map((entry) => entry.id), ["ok"]); |
| 129 | }); | 157 | }); |
| 158 | +test("legacy favorites round trip without inventing presentation properties and partial groups reject", () => { | ||
| 159 | + const legacy = legacyFavorite("legacy", 0); | ||
| 160 | + const parsed = parseConfigFragment(fragmentWith([legacy])); | ||
| 161 | + const presentationFields = ["lineMode", "lineColor", "lineTextColor"] as const; | ||
| 162 | + | ||
| 163 | + assert.equal(isFavorite(legacy), true); | ||
| 164 | + assert.equal(isFavoriteShape(legacy), true); | ||
| 165 | + assert.deepEqual(parsed.favorites, [legacy]); | ||
| 166 | + for (const field of presentationFields) { | ||
| 167 | + assert.equal(Object.hasOwn(parsed.favorites[0], field), false); | ||
| 168 | + } | ||
| 169 | + | ||
| 170 | + const partials = [ | ||
| 171 | + { ...legacy, lineMode: "METRO" }, | ||
| 172 | + { ...legacy, lineMode: "METRO", lineColor: "#ffbe00" }, | ||
| 173 | + { ...legacy, lineColor: "#ffbe00", lineTextColor: "#000000" }, | ||
| 174 | + ]; | ||
| 175 | + for (const partial of partials) { | ||
| 176 | + assert.equal(isFavorite(partial), false); | ||
| 177 | + assert.equal(isFavoriteShape(partial), false); | ||
| 178 | + assert.deepEqual(parseConfigFragment(openingFragment({ | ||
| 179 | + hasKey: true, | ||
| 180 | + favorites: [partial], | ||
| 181 | + language: "fr_FR", | ||
| 182 | + })).favorites, []); | ||
| 183 | + } | ||
| 184 | + assert.equal(isFavorite({ ...legacy, lineMode: undefined }), false); | ||
| 185 | + assert.equal(isFavoriteShape({ ...legacy, lineMode: undefined }), false); | ||
| 186 | + | ||
| 187 | + const outcome = planConfigResult(initialConfigState(parsed)); | ||
| 188 | + assert.equal(outcome.ok, true); | ||
| 189 | + if (!outcome.ok) return; | ||
| 190 | + assert.deepEqual(outcome.payload.favorites, [legacy]); | ||
| 191 | + for (const field of presentationFields) { | ||
| 192 | + assert.equal(Object.hasOwn(outcome.payload.favorites[0], field), false); | ||
| 193 | + } | ||
| 194 | +}); | ||
| 130 | test("the config page consumes the companion-produced opening fragment exactly", () => { | 195 | test("the config page consumes the companion-produced opening fragment exactly", () => { |
| 131 | const key = "stored-personal-key"; | 196 | const key = "stored-personal-key"; |
| 132 | const url = companionConfiguration.configurationUrl( | 197 | const url = companionConfiguration.configurationUrl( |
| @@ -288,6 +353,9 @@ test("favorite edits replace the whole list atomically with renumbered order", ( | |||
| 288 | stopLabel: "Arrêt work", | 353 | stopLabel: "Arrêt work", |
| 289 | lineLabel: "Métro 1", | 354 | lineLabel: "Métro 1", |
| 290 | destinationLabel: "La Défense", | 355 | destinationLabel: "La Défense", |
| 356 | + lineMode: "METRO", | ||
| 357 | + lineColor: "#ffbe00", | ||
| 358 | + lineTextColor: "#000000", | ||
| 291 | sortOrder: 0, | 359 | sortOrder: 0, |
| 292 | displayName: "Bureau", | 360 | displayName: "Bureau", |
| 293 | }, | 361 | }, |
| @@ -367,11 +435,182 @@ test("the close fragment carries the key value only for REPLACE and is one-shot" | |||
| 367 | assert.equal(encodeCloseFragment(removeOutcome.payload).includes("super-secret-key"), false); | 435 | assert.equal(encodeCloseFragment(removeOutcome.payload).includes("super-secret-key"), false); |
| 368 | }); | 436 | }); |
| 369 | 437 | ||
| 370 | -test("page sources contain no storage, network, timer, or logging surface", () => { | 438 | +test("three backend services can be added, renamed, reordered, and removed atomically", () => { |
| 371 | - const forbidden = | 439 | + const services = [ |
| 372 | - /fetch\(|XMLHttpRequest|localStorage|sessionStorage|document\.cookie|WebSocket|console\.|setInterval|setTimeout\(/; | 440 | + { serviceId: "svc-a", stopLabel: "Châtelet", lineLabel: "4", destinationLabel: "Bagneux", lineMode: "METRO", lineColor: "#be418d", lineTextColor: "#ffffff" }, |
| 373 | - for (const file of ["config-core.js", "config-page.js"]) { | 441 | + { serviceId: "svc-b", stopLabel: "République", lineLabel: "96", destinationLabel: "Porte des Lilas", lineMode: "BUS", lineColor: "#007852", lineTextColor: "#ffffff" }, |
| 374 | - const source = readFileSync(join(here, "../src", file), "utf8"); | 442 | + { serviceId: "svc-c", stopLabel: "Nation", lineLabel: "A", destinationLabel: "Cergy", lineMode: "RER", lineColor: "#e3051c", lineTextColor: "#ffffff" }, |
| 375 | - assert.doesNotMatch(source, forbidden); | 443 | + ]; |
| 444 | + let state = initialConfigState(parseConfigFragment(fragmentWith([]))); | ||
| 445 | + services.forEach((service, index) => { | ||
| 446 | + const entry = favoriteFromService(`cfg-${index}`, service, index); | ||
| 447 | + assert.ok(entry); | ||
| 448 | + state = reduceConfigState(state, { type: "favorite-add", favorite: entry }); | ||
| 449 | + }); | ||
| 450 | + assert.deepEqual(state.favorites.map((entry) => entry.serviceId), ["svc-a", "svc-b", "svc-c"]); | ||
| 451 | + assert.deepEqual( | ||
| 452 | + state.favorites.map(({ lineMode, lineColor, lineTextColor }) => ({ lineMode, lineColor, lineTextColor })), | ||
| 453 | + services.map(({ lineMode, lineColor, lineTextColor }) => ({ lineMode, lineColor, lineTextColor })), | ||
| 454 | + ); | ||
| 455 | + state = reduceConfigState(state, { type: "favorite-rename", id: "cfg-1", displayName: "Travail" }); | ||
| 456 | + state = reduceConfigState(state, { type: "favorite-move", id: "cfg-2", delta: -2 }); | ||
| 457 | + state = reduceConfigState(state, { type: "favorite-remove", id: "cfg-0" }); | ||
| 458 | + assert.deepEqual(state.favorites.map((entry) => [entry.id, entry.sortOrder, entry.displayName]), [ | ||
| 459 | + ["cfg-2", 0, undefined], | ||
| 460 | + ["cfg-1", 1, "Travail"], | ||
| 461 | + ]); | ||
| 462 | + const outcome = planConfigResult(state); | ||
| 463 | + assert.equal(outcome.ok, true); | ||
| 464 | + if (outcome.ok) assert.equal(outcome.payload.favorites.every(isFavorite), true); | ||
| 465 | +}); | ||
| 466 | + | ||
| 467 | +test("catalog response guards reject identifiers and malformed or oversized collections", () => { | ||
| 468 | + const place = { placeId: "plc-a", stopLabel: "Châtelet", localityLabel: "Paris", mode: "METRO" }; | ||
| 469 | + const service = { serviceId: "svc-a", stopLabel: "Châtelet", lineLabel: "4", destinationLabel: "Bagneux", lineMode: "METRO", lineColor: "#be418d", lineTextColor: "#ffffff" }; | ||
| 470 | + assert.equal(isPlaceSearchResult({ schemaVersion: 1, places: [place] }), true); | ||
| 471 | + assert.equal(isPlaceSearchResult({ schemaVersion: 1, places: Array(21).fill(place) }), false); | ||
| 472 | + assert.equal(isPlaceSearchResult({ schemaVersion: 1, places: [{ ...place, monitoringRef: "raw" }] }), false); | ||
| 473 | + assert.equal(isServiceOptionsResult({ schemaVersion: 1, placeId: "plc-a", services: [service] }, "plc-a"), true); | ||
| 474 | + assert.equal(isServiceOptionsResult({ schemaVersion: 1, placeId: "other", services: [service] }, "plc-a"), false); | ||
| 475 | + assert.equal(favoriteFromService("cfg-a", { ...service, lineRef: "raw" }, 0), null); | ||
| 476 | + assert.equal(isServiceOptionsResult({ schemaVersion: 1, placeId: "plc-a", services: [{ ...service, lineColor: "#BE418D" }] }, "plc-a"), false); | ||
| 477 | + assert.equal(isServiceOptionsResult({ schemaVersion: 1, placeId: "plc-a", services: [{ ...service, lineMode: "metro" }] }, "plc-a"), false); | ||
| 478 | + const { lineTextColor: _missingTextColor, ...missingTextColor } = service; | ||
| 479 | + assert.equal(favoriteFromService("cfg-a", missingTextColor, 0), null); | ||
| 480 | +}); | ||
| 481 | + | ||
| 482 | +test("favorite preview uses the credential-free recorded departure fixture", () => { | ||
| 483 | + const recorded = JSON.parse( | ||
| 484 | + readFileSync(join(here, "../../../fixtures/departures/foundation.json"), "utf8"), | ||
| 485 | + ); | ||
| 486 | + assert.deepEqual( | ||
| 487 | + RECORDED_PREVIEW, | ||
| 488 | + recorded.result.departures.map(({ minutes, status }: { minutes: number; status: string }) => ({ minutes, status })), | ||
| 489 | + ); | ||
| 490 | + assert.equal(JSON.stringify(RECORDED_PREVIEW).includes(recorded.favorite.serviceId), false); | ||
| 491 | +}); | ||
| 492 | +test("catalog search waits 300 ms, cancels superseded work, and validates responses", async () => { | ||
| 493 | + const timers = new Map<number, () => void>(); | ||
| 494 | + const delays: number[] = []; | ||
| 495 | + let nextTimer = 0; | ||
| 496 | + const requests: string[] = []; | ||
| 497 | + const client = createCatalogClient({ | ||
| 498 | + setTimer(callback: () => void, delay: number) { | ||
| 499 | + const id = ++nextTimer; | ||
| 500 | + | ||
| 501 | + delays.push(delay); | ||
| 502 | + timers.set(id, callback); | ||
| 503 | + return id; | ||
| 504 | + }, | ||
| 505 | + clearTimer(id: number) { | ||
| 506 | + timers.delete(id); | ||
| 507 | + }, | ||
| 508 | + async fetchImpl(url: string) { | ||
| 509 | + requests.push(url); | ||
| 510 | + return { | ||
| 511 | + ok: true, | ||
| 512 | + async json() { | ||
| 513 | + return { schemaVersion: 1, places: [] }; | ||
| 514 | + }, | ||
| 515 | + }; | ||
| 516 | + }, | ||
| 517 | + }); | ||
| 518 | + const superseded = client.searchPlaces("ch"); | ||
| 519 | + const latest = client.searchPlaces("cha"); | ||
| 520 | + await assert.rejects(superseded, (error: unknown) => error instanceof DOMException && error.name === "AbortError"); | ||
| 521 | + assert.deepEqual(delays, [SEARCH_DEBOUNCE_MS, SEARCH_DEBOUNCE_MS]); | ||
| 522 | + assert.equal(timers.size, 1); | ||
| 523 | + [...timers.values()][0]!(); | ||
| 524 | + assert.deepEqual(await latest, []); | ||
| 525 | + assert.deepEqual(requests, ["/api/catalog/places?q=cha"]); | ||
| 526 | + | ||
| 527 | + const invalid = createCatalogClient({ | ||
| 528 | + setTimer(callback: () => void) { | ||
| 529 | + queueMicrotask(callback); | ||
| 530 | + return 1; | ||
| 531 | + }, | ||
| 532 | + clearTimer() {}, | ||
| 533 | + async fetchImpl() { | ||
| 534 | + return { ok: true, async json() { return { schemaVersion: 1, places: [{ placeId: "leak" }] }; } }; | ||
| 535 | + }, | ||
| 536 | + }); | ||
| 537 | + await assert.rejects(invalid.searchPlaces("invalid"), (error: unknown) => | ||
| 538 | + error instanceof CatalogClientError && error.code === "BACKEND_UNAVAILABLE"); | ||
| 539 | +}); | ||
| 540 | + | ||
| 541 | +test("page keeps secrets out of durable and observable surfaces", () => { | ||
| 542 | + const core = readFileSync(join(here, "../src/config-core.js"), "utf8"); | ||
| 543 | + const controller = readFileSync(join(here, "../src/config-page.js"), "utf8"); | ||
| 544 | + const client = readFileSync(join(here, "../src/catalog-client.js"), "utf8"); | ||
| 545 | + const html = readFileSync(join(here, "../index.html"), "utf8"); | ||
| 546 | + for (const source of [core, controller, client]) { | ||
| 547 | + assert.doesNotMatch(source, /localStorage|sessionStorage|document\\.cookie|WebSocket|console\\./u); | ||
| 548 | + } | ||
| 549 | + assert.doesNotMatch(client, /prim\\.iledefrance-mobilites/u); | ||
| 550 | + assert.match(html, /type="password" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"/u); | ||
| 551 | + assert.match(html, /connect-src 'self'/u); | ||
| 552 | + assert.doesNotMatch(html, /value=["'][^"']+["']/u); | ||
| 553 | + assert.match(html, /img-src 'self'/u); | ||
| 554 | + assert.doesNotMatch(html, /img-src 'none'/u); | ||
| 555 | +}); | ||
| 556 | + | ||
| 557 | +test("bilingual About copy exposes the required legal, privacy, attribution, and safe-link facts", () => { | ||
| 558 | + const html = readFileSync(join(here, "../index.html"), "utf8"); | ||
| 559 | + const controller = readFileSync(join(here, "../src/config-page.js"), "utf8"); | ||
| 560 | + for (const fact of [ | ||
| 561 | + "Version 0.1.0", | ||
| 562 | + "Publication director:", | ||
| 563 | + "Directeur de la publication :", | ||
| 564 | + "Anthodev", | ||
| 565 | + "88 Colin P. Kelly Jr. St.", | ||
| 566 | + "localStorage", | ||
| 567 | + "no analytics or telemetry", | ||
| 568 | + "aucun cookie", | ||
| 569 | + "Référentiel des lignes", | ||
| 570 | + "conditions d’utilisation IDFM/PRIM", | ||
| 571 | + "search terms are sent", | ||
| 572 | + "termes saisis sont transmis", | ||
| 573 | + "ODbL 1.0", | ||
| 574 | + "github.com/Anthodev/lapin-fute", | ||
| 575 | + ]) assert.equal(html.includes(fact), true, fact); | ||
| 576 | + assert.doesNotMatch(html, /software licen[cs]e|licence du logiciel/iu); | ||
| 577 | + assert.equal((html.match(/target="_blank"/gu) ?? []).length, (html.match(/rel="noopener noreferrer"/gu) ?? []).length); | ||
| 578 | + assert.match(controller, /lineBadgeAssetUrl\(service\.lineMode, service\.lineLabel\)/u); | ||
| 579 | + assert.match(controller, /image\.addEventListener\("error"/u); | ||
| 580 | + assert.match(controller, /elements\.config_view\.hidden = true/u); | ||
| 581 | + assert.match(controller, /aboutReturnFocus\.focus\(\)/u); | ||
| 582 | +}); | ||
| 583 | + | ||
| 584 | +test("official rail badges cover every RER and Transilien line", () => { | ||
| 585 | + for (const line of ["A", "B", "C", "D", "E"]) { | ||
| 586 | + assert.match(lineBadgeAssetUrl("RER", line) ?? "", new RegExp(`/rer-${line.toLowerCase()}\\.png$`, "u")); | ||
| 587 | + } | ||
| 588 | + for (const line of ["H", "J", "K", "L", "N", "P", "R", "U", "V"]) { | ||
| 589 | + assert.match( | ||
| 590 | + lineBadgeAssetUrl("TRANSILIEN", line) ?? "", | ||
| 591 | + new RegExp(`/transilien-${line.toLowerCase()}\\.png$`, "u"), | ||
| 592 | + ); | ||
| 376 | } | 593 | } |
| 377 | }); | 594 | }); |
| 595 | + | ||
| 596 | +test("legacy favorites resolve no official image and use an accessible neutral fallback", () => { | ||
| 597 | + const legacy = legacyFavorite("legacy-badge", 0); | ||
| 598 | + const controller = readFileSync(join(here, "../src/config-page.js"), "utf8"); | ||
| 599 | + | ||
| 600 | + assert.equal(lineBadgeAssetUrl(legacy.lineMode, legacy.lineLabel), undefined); | ||
| 601 | + assert.match( | ||
| 602 | + controller, | ||
| 603 | + /const assetUrl = service\.lineMode === undefined\s+\? undefined\s+:\s+lineBadgeAssetUrl\(service\.lineMode, service\.lineLabel\)/u, | ||
| 604 | + ); | ||
| 605 | + assert.match(controller, /const NEUTRAL_LINE_BACKGROUND = "#52616f"/u); | ||
| 606 | + assert.match(controller, /const NEUTRAL_LINE_TEXT = "#ffffff"/u); | ||
| 607 | + assert.match(controller, /const backgroundColor = service\.lineColor \?\? NEUTRAL_LINE_BACKGROUND/u); | ||
| 608 | + assert.match(controller, /const textColor = service\.lineTextColor \?\? NEUTRAL_LINE_TEXT/u); | ||
| 609 | + assert.match(controller, /svg\.setAttribute\("role", "img"\)/u); | ||
| 610 | + assert.match(controller, /svg\.setAttribute\("aria-label", service\.lineLabel\)/u); | ||
| 611 | +}); | ||
| 612 | + | ||
| 613 | +test("the first-load gzip metric includes the eagerly imported badge resolver", () => { | ||
| 614 | + const measurement = readFileSync(join(here, "../../../scripts/measure-config-page.mjs"), "utf8"); | ||
| 615 | + assert.match(measurement, /packages\/config-page\/src\/line-badge-assets\.js/u); | ||
| 616 | +}); | ||
modified
packages/contracts/src/index.ts +35 -5 | @@ -109,7 +109,7 @@ export type ApiKeyAction = (typeof API_KEY_ACTION)[number]; | ||
| 109 | 109 | export type AppMessageKey = (typeof APP_MESSAGE_KEY_ORDER)[number]; |
| 110 | 110 | export type WireLanguage = (typeof WIRE_LANGUAGE)[keyof typeof WIRE_LANGUAGE]; |
| 111 | 111 | |
| 112 | -export interface Favorite { | |
| 112 | +export type Favorite = { | |
| 113 | 113 | schemaVersion: typeof SCHEMA_VERSION; |
| 114 | 114 | id: string; |
| 115 | 115 | serviceId: string; |
| @@ -118,7 +118,18 @@ export interface Favorite { | ||
| 118 | 118 | lineLabel: string; |
| 119 | 119 | destinationLabel: string; |
| 120 | 120 | sortOrder: number; |
| 121 | -} | |
| 121 | +} & ( | |
| 122 | + | { | |
| 123 | + lineMode: TransportMode; | |
| 124 | + lineColor: string; | |
| 125 | + lineTextColor: string; | |
| 126 | + } | |
| 127 | + | { | |
| 128 | + lineMode?: never; | |
| 129 | + lineColor?: never; | |
| 130 | + lineTextColor?: never; | |
| 131 | + } | |
| 132 | +); | |
| 122 | 133 | |
| 123 | 134 | export interface Departure { |
| 124 | 135 | expectedAt: number; |
| @@ -163,6 +174,9 @@ export interface ServiceOption { | ||
| 163 | 174 | stopLabel: string; |
| 164 | 175 | lineLabel: string; |
| 165 | 176 | destinationLabel: string; |
| 177 | + lineMode: TransportMode; | |
| 178 | + lineColor: string; | |
| 179 | + lineTextColor: string; | |
| 166 | 180 | } |
| 167 | 181 | |
| 168 | 182 | export interface ServiceOptionsResult { |
| @@ -187,14 +201,14 @@ export type AppMessage = Partial<Record<AppMessageKey, AppMessageValue>>; | ||
| 187 | 201 | const encoder = new TextEncoder(); |
| 188 | 202 | const UINT32_MAX = 0xffff_ffff; |
| 189 | 203 | const domainKeys = { |
| 190 | - favorite: ["schemaVersion", "id", "serviceId", "displayName", "stopLabel", "lineLabel", "destinationLabel", "sortOrder"], | |
| 204 | + favorite: ["schemaVersion", "id", "serviceId", "displayName", "stopLabel", "lineLabel", "destinationLabel", "lineMode", "lineColor", "lineTextColor", "sortOrder"], | |
| 191 | 205 | departure: ["expectedAt", "aimedAt", "minutes", "status", "nextIntervalMinutes"], |
| 192 | 206 | result: ["schemaVersion", "requestId", "favoriteId", "fetchedAt", "sourceUpdatedAt", "freshness", "departures"], |
| 193 | 207 | error: ["schemaVersion", "requestId", "favoriteId", "code", "occurredAt", "retryAfterSeconds"], |
| 194 | 208 | keyUpdate: ["schemaVersion", "action", "value"], |
| 195 | 209 | placeSearchItem: ["placeId", "stopLabel", "localityLabel", "mode"], |
| 196 | 210 | placeSearchResult: ["schemaVersion", "places"], |
| 197 | - serviceOption: ["serviceId", "stopLabel", "lineLabel", "destinationLabel"], | |
| 211 | + serviceOption: ["serviceId", "stopLabel", "lineLabel", "destinationLabel", "lineMode", "lineColor", "lineTextColor"], | |
| 198 | 212 | serviceOptionsResult: ["schemaVersion", "placeId", "services"], |
| 199 | 213 | catalogError: ["schemaVersion", "code"], |
| 200 | 214 | } as const; |
| @@ -215,6 +229,10 @@ function boundedString(value: unknown, maximum: number): value is string { | ||
| 215 | 229 | return typeof value === "string" && utf8Bytes(value) >= 1 && utf8Bytes(value) <= maximum; |
| 216 | 230 | } |
| 217 | 231 | |
| 232 | +export function isLineColor(value: unknown): value is string { | |
| 233 | + return typeof value === "string" && /^#[0-9a-f]{6}$/u.test(value); | |
| 234 | +} | |
| 235 | + | |
| 218 | 236 | function uint32(value: unknown): value is number { |
| 219 | 237 | return Number.isInteger(value) && Number(value) >= 0 && Number(value) <= UINT32_MAX; |
| 220 | 238 | } |
| @@ -229,6 +247,10 @@ function optionalBoundedString(value: unknown, maximum: number): boolean { | ||
| 229 | 247 | |
| 230 | 248 | export function isFavorite(value: unknown): value is Favorite { |
| 231 | 249 | if (!object(value) || !exactKeys(value, domainKeys.favorite)) return false; |
| 250 | + const hasLineMode = Object.hasOwn(value, "lineMode"); | |
| 251 | + const hasLineColor = Object.hasOwn(value, "lineColor"); | |
| 252 | + const hasLineTextColor = Object.hasOwn(value, "lineTextColor"); | |
| 253 | + if (hasLineMode !== hasLineColor || hasLineMode !== hasLineTextColor) return false; | |
| 232 | 254 | return value.schemaVersion === SCHEMA_VERSION |
| 233 | 255 | && boundedString(value.id, LIMITS.idUtf8Bytes) |
| 234 | 256 | && boundedString(value.serviceId, LIMITS.idUtf8Bytes) |
| @@ -236,6 +258,11 @@ export function isFavorite(value: unknown): value is Favorite { | ||
| 236 | 258 | && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes) |
| 237 | 259 | && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes) |
| 238 | 260 | && boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes) |
| 261 | + && (!hasLineMode || ( | |
| 262 | + TRANSPORT_MODE.includes(value.lineMode as TransportMode) | |
| 263 | + && isLineColor(value.lineColor) | |
| 264 | + && isLineColor(value.lineTextColor) | |
| 265 | + )) | |
| 239 | 266 | && Number.isInteger(value.sortOrder) |
| 240 | 267 | && Number(value.sortOrder) >= 0 |
| 241 | 268 | && Number(value.sortOrder) < LIMITS.favorites; |
| @@ -297,7 +324,10 @@ export function isServiceOption(value: unknown): value is ServiceOption { | ||
| 297 | 324 | return boundedString(value.serviceId, LIMITS.idUtf8Bytes) |
| 298 | 325 | && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes) |
| 299 | 326 | && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes) |
| 300 | - && boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes); | |
| 327 | + && boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes) | |
| 328 | + && TRANSPORT_MODE.includes(value.lineMode as TransportMode) | |
| 329 | + && isLineColor(value.lineColor) | |
| 330 | + && isLineColor(value.lineTextColor); | |
| 301 | 331 | } |
| 302 | 332 | |
| 303 | 333 | export function isServiceOptionsResult(value: unknown): value is ServiceOptionsResult { |
| @@ -109,7 +109,7 @@ export type ApiKeyAction = (typeof API_KEY_ACTION)[number]; | |||
| 109 | export type AppMessageKey = (typeof APP_MESSAGE_KEY_ORDER)[number]; | 109 | export type AppMessageKey = (typeof APP_MESSAGE_KEY_ORDER)[number]; |
| 110 | export type WireLanguage = (typeof WIRE_LANGUAGE)[keyof typeof WIRE_LANGUAGE]; | 110 | export type WireLanguage = (typeof WIRE_LANGUAGE)[keyof typeof WIRE_LANGUAGE]; |
| 111 | 111 | ||
| 112 | -export interface Favorite { | 112 | +export type Favorite = { |
| 113 | schemaVersion: typeof SCHEMA_VERSION; | 113 | schemaVersion: typeof SCHEMA_VERSION; |
| 114 | id: string; | 114 | id: string; |
| 115 | serviceId: string; | 115 | serviceId: string; |
| @@ -118,7 +118,18 @@ export interface Favorite { | |||
| 118 | lineLabel: string; | 118 | lineLabel: string; |
| 119 | destinationLabel: string; | 119 | destinationLabel: string; |
| 120 | sortOrder: number; | 120 | sortOrder: number; |
| 121 | -} | 121 | +} & ( |
| 122 | + | { | ||
| 123 | + lineMode: TransportMode; | ||
| 124 | + lineColor: string; | ||
| 125 | + lineTextColor: string; | ||
| 126 | + } | ||
| 127 | + | { | ||
| 128 | + lineMode?: never; | ||
| 129 | + lineColor?: never; | ||
| 130 | + lineTextColor?: never; | ||
| 131 | + } | ||
| 132 | +); | ||
| 122 | 133 | ||
| 123 | export interface Departure { | 134 | export interface Departure { |
| 124 | expectedAt: number; | 135 | expectedAt: number; |
| @@ -163,6 +174,9 @@ export interface ServiceOption { | |||
| 163 | stopLabel: string; | 174 | stopLabel: string; |
| 164 | lineLabel: string; | 175 | lineLabel: string; |
| 165 | destinationLabel: string; | 176 | destinationLabel: string; |
| 177 | + lineMode: TransportMode; | ||
| 178 | + lineColor: string; | ||
| 179 | + lineTextColor: string; | ||
| 166 | } | 180 | } |
| 167 | 181 | ||
| 168 | export interface ServiceOptionsResult { | 182 | export interface ServiceOptionsResult { |
| @@ -187,14 +201,14 @@ export type AppMessage = Partial<Record<AppMessageKey, AppMessageValue>>; | |||
| 187 | const encoder = new TextEncoder(); | 201 | const encoder = new TextEncoder(); |
| 188 | const UINT32_MAX = 0xffff_ffff; | 202 | const UINT32_MAX = 0xffff_ffff; |
| 189 | const domainKeys = { | 203 | const domainKeys = { |
| 190 | - favorite: ["schemaVersion", "id", "serviceId", "displayName", "stopLabel", "lineLabel", "destinationLabel", "sortOrder"], | 204 | + favorite: ["schemaVersion", "id", "serviceId", "displayName", "stopLabel", "lineLabel", "destinationLabel", "lineMode", "lineColor", "lineTextColor", "sortOrder"], |
| 191 | departure: ["expectedAt", "aimedAt", "minutes", "status", "nextIntervalMinutes"], | 205 | departure: ["expectedAt", "aimedAt", "minutes", "status", "nextIntervalMinutes"], |
| 192 | result: ["schemaVersion", "requestId", "favoriteId", "fetchedAt", "sourceUpdatedAt", "freshness", "departures"], | 206 | result: ["schemaVersion", "requestId", "favoriteId", "fetchedAt", "sourceUpdatedAt", "freshness", "departures"], |
| 193 | error: ["schemaVersion", "requestId", "favoriteId", "code", "occurredAt", "retryAfterSeconds"], | 207 | error: ["schemaVersion", "requestId", "favoriteId", "code", "occurredAt", "retryAfterSeconds"], |
| 194 | keyUpdate: ["schemaVersion", "action", "value"], | 208 | keyUpdate: ["schemaVersion", "action", "value"], |
| 195 | placeSearchItem: ["placeId", "stopLabel", "localityLabel", "mode"], | 209 | placeSearchItem: ["placeId", "stopLabel", "localityLabel", "mode"], |
| 196 | placeSearchResult: ["schemaVersion", "places"], | 210 | placeSearchResult: ["schemaVersion", "places"], |
| 197 | - serviceOption: ["serviceId", "stopLabel", "lineLabel", "destinationLabel"], | 211 | + serviceOption: ["serviceId", "stopLabel", "lineLabel", "destinationLabel", "lineMode", "lineColor", "lineTextColor"], |
| 198 | serviceOptionsResult: ["schemaVersion", "placeId", "services"], | 212 | serviceOptionsResult: ["schemaVersion", "placeId", "services"], |
| 199 | catalogError: ["schemaVersion", "code"], | 213 | catalogError: ["schemaVersion", "code"], |
| 200 | } as const; | 214 | } as const; |
| @@ -215,6 +229,10 @@ function boundedString(value: unknown, maximum: number): value is string { | |||
| 215 | return typeof value === "string" && utf8Bytes(value) >= 1 && utf8Bytes(value) <= maximum; | 229 | return typeof value === "string" && utf8Bytes(value) >= 1 && utf8Bytes(value) <= maximum; |
| 216 | } | 230 | } |
| 217 | 231 | ||
| 232 | +export function isLineColor(value: unknown): value is string { | ||
| 233 | + return typeof value === "string" && /^#[0-9a-f]{6}$/u.test(value); | ||
| 234 | +} | ||
| 235 | + | ||
| 218 | function uint32(value: unknown): value is number { | 236 | function uint32(value: unknown): value is number { |
| 219 | return Number.isInteger(value) && Number(value) >= 0 && Number(value) <= UINT32_MAX; | 237 | return Number.isInteger(value) && Number(value) >= 0 && Number(value) <= UINT32_MAX; |
| 220 | } | 238 | } |
| @@ -229,6 +247,10 @@ function optionalBoundedString(value: unknown, maximum: number): boolean { | |||
| 229 | 247 | ||
| 230 | export function isFavorite(value: unknown): value is Favorite { | 248 | export function isFavorite(value: unknown): value is Favorite { |
| 231 | if (!object(value) || !exactKeys(value, domainKeys.favorite)) return false; | 249 | if (!object(value) || !exactKeys(value, domainKeys.favorite)) return false; |
| 250 | + const hasLineMode = Object.hasOwn(value, "lineMode"); | ||
| 251 | + const hasLineColor = Object.hasOwn(value, "lineColor"); | ||
| 252 | + const hasLineTextColor = Object.hasOwn(value, "lineTextColor"); | ||
| 253 | + if (hasLineMode !== hasLineColor || hasLineMode !== hasLineTextColor) return false; | ||
| 232 | return value.schemaVersion === SCHEMA_VERSION | 254 | return value.schemaVersion === SCHEMA_VERSION |
| 233 | && boundedString(value.id, LIMITS.idUtf8Bytes) | 255 | && boundedString(value.id, LIMITS.idUtf8Bytes) |
| 234 | && boundedString(value.serviceId, LIMITS.idUtf8Bytes) | 256 | && boundedString(value.serviceId, LIMITS.idUtf8Bytes) |
| @@ -236,6 +258,11 @@ export function isFavorite(value: unknown): value is Favorite { | |||
| 236 | && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes) | 258 | && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes) |
| 237 | && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes) | 259 | && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes) |
| 238 | && boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes) | 260 | && boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes) |
| 261 | + && (!hasLineMode || ( | ||
| 262 | + TRANSPORT_MODE.includes(value.lineMode as TransportMode) | ||
| 263 | + && isLineColor(value.lineColor) | ||
| 264 | + && isLineColor(value.lineTextColor) | ||
| 265 | + )) | ||
| 239 | && Number.isInteger(value.sortOrder) | 266 | && Number.isInteger(value.sortOrder) |
| 240 | && Number(value.sortOrder) >= 0 | 267 | && Number(value.sortOrder) >= 0 |
| 241 | && Number(value.sortOrder) < LIMITS.favorites; | 268 | && Number(value.sortOrder) < LIMITS.favorites; |
| @@ -297,7 +324,10 @@ export function isServiceOption(value: unknown): value is ServiceOption { | |||
| 297 | return boundedString(value.serviceId, LIMITS.idUtf8Bytes) | 324 | return boundedString(value.serviceId, LIMITS.idUtf8Bytes) |
| 298 | && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes) | 325 | && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes) |
| 299 | && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes) | 326 | && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes) |
| 300 | - && boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes); | 327 | + && boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes) |
| 328 | + && TRANSPORT_MODE.includes(value.lineMode as TransportMode) | ||
| 329 | + && isLineColor(value.lineColor) | ||
| 330 | + && isLineColor(value.lineTextColor); | ||
| 301 | } | 331 | } |
| 302 | 332 | ||
| 303 | export function isServiceOptionsResult(value: unknown): value is ServiceOptionsResult { | 333 | export function isServiceOptionsResult(value: unknown): value is ServiceOptionsResult { |
modified
packages/contracts/test/contracts.test.ts +32 -0 | @@ -41,6 +41,9 @@ const favorite = { | ||
| 41 | 41 | stopLabel: "Châtelet", |
| 42 | 42 | lineLabel: "Métro 1", |
| 43 | 43 | destinationLabel: "La Défense", |
| 44 | + lineMode: "METRO", | |
| 45 | + lineColor: "#ffbe00", | |
| 46 | + lineTextColor: "#000000", | |
| 44 | 47 | sortOrder: 0, |
| 45 | 48 | }; |
| 46 | 49 | |
| @@ -92,6 +95,25 @@ test("domain validators enforce exact fields, versions, bounds, and secret rules | ||
| 92 | 95 | assert.equal(isFavorite({ ...favorite, unknown: true }), false); |
| 93 | 96 | assert.equal(isFavorite({ ...favorite, schemaVersion: 2 }), false); |
| 94 | 97 | assert.equal(isFavorite({ ...favorite, stopLabel: "é".repeat(49) }), false); |
| 98 | + assert.equal(isFavorite({ ...favorite, lineMode: "metro" }), false); | |
| 99 | + assert.equal(isFavorite({ ...favorite, lineColor: "#FFBE00" }), false); | |
| 100 | + assert.equal(isFavorite({ ...favorite, lineTextColor: "000000" }), false); | |
| 101 | + const { | |
| 102 | + lineMode: _legacyLineMode, | |
| 103 | + lineColor: _legacyLineColor, | |
| 104 | + lineTextColor: _legacyLineTextColor, | |
| 105 | + ...legacyFavorite | |
| 106 | + } = favorite; | |
| 107 | + assert.equal(isFavorite(legacyFavorite), true); | |
| 108 | + assert.equal(isFavorite({ ...legacyFavorite, lineMode: favorite.lineMode }), false); | |
| 109 | + assert.equal(isFavorite({ | |
| 110 | + ...legacyFavorite, | |
| 111 | + lineMode: favorite.lineMode, | |
| 112 | + lineColor: favorite.lineColor, | |
| 113 | + }), false); | |
| 114 | + assert.equal(isFavorite({ ...legacyFavorite, lineMode: undefined }), false); | |
| 115 | + const { lineColor: _favoriteLineColor, ...favoriteWithoutColor } = favorite; | |
| 116 | + assert.equal(isFavorite(favoriteWithoutColor), false); | |
| 95 | 117 | assert.equal(utf8Bytes("é".repeat(48)), LIMITS.labelUtf8Bytes); |
| 96 | 118 | assert.equal(isDepartureResult({ |
| 97 | 119 | ...result, |
| @@ -259,6 +281,9 @@ test("catalog validators enforce exact public fields, bounds, and no raw source | ||
| 259 | 281 | stopLabel: "Châtelet", |
| 260 | 282 | lineLabel: "Métro 1", |
| 261 | 283 | destinationLabel: "La Défense", |
| 284 | + lineMode: "METRO", | |
| 285 | + lineColor: "#ffbe00", | |
| 286 | + lineTextColor: "#000000", | |
| 262 | 287 | }; |
| 263 | 288 | const requiredPlace = { placeId: placeItem.placeId, stopLabel: placeItem.stopLabel, mode: placeItem.mode }; |
| 264 | 289 | |
| @@ -297,6 +322,13 @@ test("catalog validators enforce exact public fields, bounds, and no raw source | ||
| 297 | 322 | |
| 298 | 323 | assert.equal(isServiceOption({ ...serviceOption, serviceId: opaque64 }), true); |
| 299 | 324 | assert.equal(isServiceOption({ ...serviceOption, destinationLabel: "" }), false); |
| 325 | + assert.equal(isServiceOption({ ...serviceOption, lineMode: "metro" }), false); | |
| 326 | + assert.equal(isServiceOption({ ...serviceOption, lineMode: "TER" }), false); | |
| 327 | + assert.equal(isServiceOption({ ...serviceOption, lineColor: "#FFBE00" }), false); | |
| 328 | + assert.equal(isServiceOption({ ...serviceOption, lineColor: "#12345" }), false); | |
| 329 | + assert.equal(isServiceOption({ ...serviceOption, lineTextColor: "transparent" }), false); | |
| 330 | + const { lineTextColor: _lineTextColor, ...serviceWithoutTextColor } = serviceOption; | |
| 331 | + assert.equal(isServiceOption(serviceWithoutTextColor), false); | |
| 300 | 332 | assert.equal(isServiceOption({ ...serviceOption, directionId: 1 }), false); |
| 301 | 333 | assert.equal(isServiceOption({ ...serviceOption, destinationRef: "Q-1" }), false); |
| 302 | 334 | assert.equal(isServiceOption({ serviceId: opaque64, stopLabel: "Châtelet", lineLabel: "1" }), false); |
| @@ -41,6 +41,9 @@ const favorite = { | |||
| 41 | stopLabel: "Châtelet", | 41 | stopLabel: "Châtelet", |
| 42 | lineLabel: "Métro 1", | 42 | lineLabel: "Métro 1", |
| 43 | destinationLabel: "La Défense", | 43 | destinationLabel: "La Défense", |
| 44 | + lineMode: "METRO", | ||
| 45 | + lineColor: "#ffbe00", | ||
| 46 | + lineTextColor: "#000000", | ||
| 44 | sortOrder: 0, | 47 | sortOrder: 0, |
| 45 | }; | 48 | }; |
| 46 | 49 | ||
| @@ -92,6 +95,25 @@ test("domain validators enforce exact fields, versions, bounds, and secret rules | |||
| 92 | assert.equal(isFavorite({ ...favorite, unknown: true }), false); | 95 | assert.equal(isFavorite({ ...favorite, unknown: true }), false); |
| 93 | assert.equal(isFavorite({ ...favorite, schemaVersion: 2 }), false); | 96 | assert.equal(isFavorite({ ...favorite, schemaVersion: 2 }), false); |
| 94 | assert.equal(isFavorite({ ...favorite, stopLabel: "é".repeat(49) }), false); | 97 | assert.equal(isFavorite({ ...favorite, stopLabel: "é".repeat(49) }), false); |
| 98 | + assert.equal(isFavorite({ ...favorite, lineMode: "metro" }), false); | ||
| 99 | + assert.equal(isFavorite({ ...favorite, lineColor: "#FFBE00" }), false); | ||
| 100 | + assert.equal(isFavorite({ ...favorite, lineTextColor: "000000" }), false); | ||
| 101 | + const { | ||
| 102 | + lineMode: _legacyLineMode, | ||
| 103 | + lineColor: _legacyLineColor, | ||
| 104 | + lineTextColor: _legacyLineTextColor, | ||
| 105 | + ...legacyFavorite | ||
| 106 | + } = favorite; | ||
| 107 | + assert.equal(isFavorite(legacyFavorite), true); | ||
| 108 | + assert.equal(isFavorite({ ...legacyFavorite, lineMode: favorite.lineMode }), false); | ||
| 109 | + assert.equal(isFavorite({ | ||
| 110 | + ...legacyFavorite, | ||
| 111 | + lineMode: favorite.lineMode, | ||
| 112 | + lineColor: favorite.lineColor, | ||
| 113 | + }), false); | ||
| 114 | + assert.equal(isFavorite({ ...legacyFavorite, lineMode: undefined }), false); | ||
| 115 | + const { lineColor: _favoriteLineColor, ...favoriteWithoutColor } = favorite; | ||
| 116 | + assert.equal(isFavorite(favoriteWithoutColor), false); | ||
| 95 | assert.equal(utf8Bytes("é".repeat(48)), LIMITS.labelUtf8Bytes); | 117 | assert.equal(utf8Bytes("é".repeat(48)), LIMITS.labelUtf8Bytes); |
| 96 | assert.equal(isDepartureResult({ | 118 | assert.equal(isDepartureResult({ |
| 97 | ...result, | 119 | ...result, |
| @@ -259,6 +281,9 @@ test("catalog validators enforce exact public fields, bounds, and no raw source | |||
| 259 | stopLabel: "Châtelet", | 281 | stopLabel: "Châtelet", |
| 260 | lineLabel: "Métro 1", | 282 | lineLabel: "Métro 1", |
| 261 | destinationLabel: "La Défense", | 283 | destinationLabel: "La Défense", |
| 284 | + lineMode: "METRO", | ||
| 285 | + lineColor: "#ffbe00", | ||
| 286 | + lineTextColor: "#000000", | ||
| 262 | }; | 287 | }; |
| 263 | const requiredPlace = { placeId: placeItem.placeId, stopLabel: placeItem.stopLabel, mode: placeItem.mode }; | 288 | const requiredPlace = { placeId: placeItem.placeId, stopLabel: placeItem.stopLabel, mode: placeItem.mode }; |
| 264 | 289 | ||
| @@ -297,6 +322,13 @@ test("catalog validators enforce exact public fields, bounds, and no raw source | |||
| 297 | 322 | ||
| 298 | assert.equal(isServiceOption({ ...serviceOption, serviceId: opaque64 }), true); | 323 | assert.equal(isServiceOption({ ...serviceOption, serviceId: opaque64 }), true); |
| 299 | assert.equal(isServiceOption({ ...serviceOption, destinationLabel: "" }), false); | 324 | assert.equal(isServiceOption({ ...serviceOption, destinationLabel: "" }), false); |
| 325 | + assert.equal(isServiceOption({ ...serviceOption, lineMode: "metro" }), false); | ||
| 326 | + assert.equal(isServiceOption({ ...serviceOption, lineMode: "TER" }), false); | ||
| 327 | + assert.equal(isServiceOption({ ...serviceOption, lineColor: "#FFBE00" }), false); | ||
| 328 | + assert.equal(isServiceOption({ ...serviceOption, lineColor: "#12345" }), false); | ||
| 329 | + assert.equal(isServiceOption({ ...serviceOption, lineTextColor: "transparent" }), false); | ||
| 330 | + const { lineTextColor: _lineTextColor, ...serviceWithoutTextColor } = serviceOption; | ||
| 331 | + assert.equal(isServiceOption(serviceWithoutTextColor), false); | ||
| 300 | assert.equal(isServiceOption({ ...serviceOption, directionId: 1 }), false); | 332 | assert.equal(isServiceOption({ ...serviceOption, directionId: 1 }), false); |
| 301 | assert.equal(isServiceOption({ ...serviceOption, destinationRef: "Q-1" }), false); | 333 | assert.equal(isServiceOption({ ...serviceOption, destinationRef: "Q-1" }), false); |
| 302 | assert.equal(isServiceOption({ serviceId: opaque64, stopLabel: "Châtelet", lineLabel: "1" }), false); | 334 | assert.equal(isServiceOption({ serviceId: opaque64, stopLabel: "Châtelet", lineLabel: "1" }), false); |
modified
packages/watch/.lock-waf_linux_build +1 -1 | @@ -1,6 +1,6 @@ | ||
| 1 | 1 | argv = ['/home/anthodev/.local/share/pebble-sdk/SDKs/current/sdk-core/pebble/waf', 'configure'] |
| 2 | 2 | config_cmd = 'configure' |
| 3 | -environ = {'IMSETTINGS_INTEGRATE_DESKTOP': 'yes', 'npm_command': 'run', 'SESSION_MANAGER': 'local/unix:@/tmp/.ICE-unix/2942,unix/unix:/tmp/.ICE-unix/2942', 'npm_config_yes': 'true', 'GHOSTTY_BIN_DIR': '/usr/bin', 'npm_config_userconfig': '/home/anthodev/.npmrc', 'COLORTERM': 'truecolor', 'HERDR_PANE_ID': 'w6:p2', 'XDG_CONFIG_DIRS': '/home/anthodev/.config/kdedefaults:/etc/xdg:/usr/share/kde-settings/kde-profile/default/xdg', 'npm_config_cache': '/home/anthodev/.npm', 'PYTHONUNBUFFERED': '1', 'PIP_NO_INPUT': '1', 'LESS': 'FRX', 'XDG_SESSION_PATH': '/org/freedesktop/DisplayManager/Session1', 'HISTCONTROL': 'ignoredups', 'XDG_MENU_PREFIX': 'plasma-', 'TERM_PROGRAM_VERSION': '1.3.1-4.fc44', 'COMPOSER_NO_INTERACTION': '1', 'OMPCODE': '1', 'HISTSIZE': '1000', 'TF_IN_AUTOMATION': '1', 'ICEAUTHORITY': '/run/user/1000/iceauth_KXfteX', 'PNPM_UPDATE_NOTIFIER': 'false', 'NODE': '/usr/bin/node-24', 'GLAB_PAGER': 'cat', 'SSH_AUTH_SOCK': '/home/anthodev/.1password/agent.sock', 'CLOUDSDK_CORE_DISABLE_PROMPTS': '1', 'DOCKER_USER': '1000:1000', '__ABBR_TIPS_VALUES': 'git rebase -i HEAD~2 git rebase --continue git rebase develop gitmoji -c helium-browser --disable-features=WaylandWpColorManagerV1 just eza -al --icons', 'AGENT': '1', 'MEMORY_PRESSURE_WRITE': 'c29tZSAyMDAwMDAgMjAwMDAwMAA=', 'COLOR': '0', 'npm_config_local_prefix': '/home/anthodev/dev/perso/js/lapin-fute', 'XMODIFIERS': '@im=none', 'HOMEBREW_PREFIX': '/home/linuxbrew/.linuxbrew', 'DESKTOP_SESSION': '/usr/share/wayland-sessions/plasma.desktop', 'GTK_RC_FILES': '/etc/gtk/gtkrc:/home/anthodev/.gtkrc:/home/anthodev/.config/gtkrc', 'GDK_CORE_DEVICE_EVENTS': '1', 'npm_config_globalconfig': '/etc/npmrc', 'GPG_TTY': 'not a tty', 'EDITOR': 'true', 'GH_PAGER': 'cat', 'XDG_SEAT': 'seat0', 'GH_PROMPT_DISABLED': '1', 'PWD': '/home/anthodev/dev/perso/js/lapin-fute', 'XDG_SESSION_DESKTOP': 'KDE', 'LOGNAME': 'anthodev', 'XDG_SESSION_TYPE': 'wayland', 'PNPM_HOME': '/home/anthodev/.local/share/pnpm', 'npm_config_init_module': '/home/anthodev/.npm-init.js', 'SYSTEMD_EXEC_PID': '6424', '_': '/usr/bin/node', 'XAUTHORITY': '/run/user/1000/xauth_XpcTHG', 'KWIN_DISABLE_TONEMAPPING': '1', 'SDL_VIDEO_MINIMIZE_ON_FOCUS_LOSS': '0', 'POSH_SHELL': 'fish', 'ABBR_TIPS_PROMPT': '\\n\U0001f4a1 \\e[1m{{ .abbr }}\\e[0m => {{ .cmd }}', 'CLAUDECODE': '1', 'XKB_DEFAULT_MODEL': 'pc105', 'GTK2_RC_FILES': '/etc/gtk-2.0/gtkrc:/home/anthodev/.gtkrc-2.0:/home/anthodev/.config/gtkrc-2.0', '__ABBR_TIPS_KEYS': 'a__gr2 a__grc a__grd a__gtc a__helium-browser a__j a__xl', 'GHOSTTY_SHELL_FEATURES': 'path,sudo,title', 'HOME': '/home/anthodev', '__PI_NATIVE_VARIANT_CACHE': 'modern', 'DELTA_PAGER': 'cat', 'SSH_ASKPASS': '/usr/bin/false', 'LANG': 'fr_FR.UTF-8', '_JAVA_AWT_WM_NONREPARENTING': '1', 'LS_COLORS': '', 'XDG_CURRENT_DESKTOP': 'KDE', 'POSH_SHELL_VERSION': '4.8.1', 'npm_package_version': '0.1.0', 'POSH_SESSION_ID': 'aed4658f-12ea-4aca-a036-894bd3ef6bd0', 'MEMORY_PRESSURE_WATCH': '/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/app-com.mitchellh.ghostty.service/memory.pressure', 'WAYLAND_DISPLAY': 'wayland-0', 'AWS_PAGER': '', 'YARN_ENABLE_PROGRESS_BARS': '0', '_fish_env_initialized': '1', 'CONDA_PROMPT_MODIFIER': 'false', 'XDG_SEAT_PATH': '/org/freedesktop/DisplayManager/Seat0', 'MANAGERPID': '2300', 'IMSETTINGS_MODULE': 'none', 'INIT_CWD': '/home/anthodev/dev/perso/js/lapin-fute', 'POSH_CONFIG': '/home/anthodev/.config/.zsh/anthodev.omp.json', 'STEAM_FRAME_FORCE_CLOSE': '1', 'KDE_SESSION_UID': '1000', 'PSQL_PAGER': 'cat', 'INFOPATH': '/home/linuxbrew/.linuxbrew/share/info:', 'npm_lifecycle_script': 'node scripts/build.mjs', 'NVM_DIR': '/home/anthodev/.nvm', 'MOZ_GMP_PATH': '/usr/lib64/mozilla/plugins/gmp-gmpopenh264/system-installed', 'XKB_DEFAULT_LAYOUT': 'us', 'npm_config_npm_version': '11.16.0', 'GHOSTTY_RESOURCES_DIR': '/usr/share/ghostty', 'XDG_SESSION_CLASS': 'user', 'TERMINFO': '/usr/share/terminfo', 'TERM': 'dumb', 'npm_package_name': 'lapin-fute', 'BAT_PAGER': 'cat', 'npm_config_prefix': '/home/anthodev/.npm-global', 'LESSOPEN': '||/usr/bin/lesspipe.sh %s', 'USER': 'anthodev', 'YARN_ENABLE_TELEMETRY': '0', 'GIT_PAGER': 'cat', 'PIP_DISABLE_PIP_VERSION_CHECK': '1', 'HERDR_BIN_PATH': '/home/linuxbrew/.linuxbrew/Cellar/herdr/0.8.2/bin/herdr', 'GID': '1000', 'HOMEBREW_CELLAR': '/home/linuxbrew/.linuxbrew/Cellar', 'QT_WAYLAND_RECONNECT': '1', 'KDE_SESSION_VERSION': '6', 'PAM_KWALLET5_LOGIN': '/run/user/1000/kwallet5.socket', 'MANPAGER': 'cat', 'VISUAL': 'true', 'DISPLAY': ':0', 'npm_lifecycle_event': 'build', 'SHLVL': '1', 'NVM_CD_FLAGS': '', 'GIT_EDITOR': 'true', 'PAGER': 'cat', 'HOMEBREW_REPOSITORY': '/home/linuxbrew/.linuxbrew/Homebrew', 'npm_config_fund': 'false', 'SYSTEMD_PAGER': 'cat', 'XDG_VTNR': '2', 'XDG_SESSION_ID': '2', 'ATUIN_SESSION': '01a0523b089c70c29608f76da5ef3ae9', 'MANAGERPIDFDID': '8555', 'npm_config_user_agent': 'npm/11.16.0 node/v24.18.0 linux x64 workspaces/false', 'NO_COLOR': '1', 'TF_INPUT': '0', 'npm_execpath': '/usr/lib/node_modules_24/npm/bin/npm-cli.js', 'npm_config_progress': 'false', 'XDG_RUNTIME_DIR': '/run/user/1000', 'MYSQL_PAGER': 'cat', 'HOMEBREW_PAGER': 'cat', 'PNPM_DISABLE_SELF_UPDATE_CHECK': 'true', 'DEBUGINFOD_URLS': 'ima:enforcing https://debuginfod.fedoraproject.org/ ima:ignore ', 'npm_package_json': '/home/anthodev/dev/perso/js/lapin-fute/package.json', 'DEBUGINFOD_IMA_CERT_PATH': '/etc/keys/ima:', 'LC_ALL': 'C.UTF-8', 'KDEDIRS': '/usr', 'XKB_DEFAULT_VARIANT': 'intl', 'CARGO_TERM_PROGRESS_WHEN': 'never', 'HERDR_ENV': '1', 'XDG_DATA_DIRS': '/home/anthodev/.local/share/flatpak/exports/share:/var/lib/flatpak/exports/share:/usr/local/share:/usr/share', 'KDE_FULL_SESSION': 'true', 'npm_config_allow_scripts': '', 'npm_config_noproxy': '', 'PATH': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable-tools:/home/anthodev/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/arm-none-eabi/bin:/home/anthodev/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages/pebble_tool/commands/sdk:/home/anthodev/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/bin:/home/anthodev/dev/perso/js/lapin-fute/node_modules/.bin:/home/anthodev/dev/perso/js/node_modules/.bin:/home/anthodev/dev/perso/node_modules/.bin:/home/anthodev/dev/node_modules/.bin:/home/anthodev/node_modules/.bin:/home/node_modules/.bin:/node_modules/.bin:/usr/lib/node_modules_24/npm/node_modules/@npmcli/run-script/lib/node-gyp-bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/anthodev/.local/bin:/home/anthodev/bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/anthodev/.atuin/bin:/home/anthodev/.local/bin:/home/anthodev/.bun/bin:/home/anthodev/go/bin:/usr/local/bin:/usr/bin:/sbin:/home/anthodev/.npm-global/bin:/home/anthodev/.local/share/pnpm/bin:/home/anthodev/.cargo/bin:/usr/lib64/ccache:/bin', 'HERDR_TAB_ID': 'w6:t2', 'npm_config_node_gyp': '/usr/lib/node_modules_24/npm/node_modules/node-gyp/bin/node-gyp.js', 'CI': 'true', 'DBUS_SESSION_BUS_ADDRESS': 'unix:path=/run/user/1000/bus', 'npm_config_global_prefix': '/home/anthodev/.npm-global', 'npm_config_update_notifier': 'false', 'KDE_APPLICATIONS_AS_SCOPE': '1', 'HERDR_SOCKET_PATH': '/home/anthodev/.config/herdr/herdr.sock', 'HERDR_WORKSPACE_ID': 'w6', 'MAIL': '/var/spool/mail/anthodev', 'POWERLINE_COMMAND': 'oh-my-posh', 'npm_config_audit': 'false', 'ABBR_TIPS_REGEXES': '(^(\\w+\\s+)+(-{1,2})\\w+)(\\s\\S+) (^(\\s?(\\w-?)+){3}).* (^(\\s?(\\w-?)+){2}).* (^(\\s?(\\w-?)+){1}).*', 'DEBIAN_FRONTEND': 'noninteractive', 'GIT_TERMINAL_PROMPT': '0', 'npm_node_execpath': '/usr/bin/node-24', 'npm_package_engines_node': '24.18.0', 'TERM_PROGRAM': 'ghostty', 'MODDABLE': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable', 'PYTHONHOME': '/home/anthodev/.local/share/pebble-sdk/SDKs/current/.venv', 'PYTHONPATH': '/home/anthodev/.local/share/uv/tools/pebble-tool/bin:/usr/lib64/python314.zip:/usr/lib64/python3.14:/usr/lib64/python3.14/lib-dynload:/home/anthodev/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages:/home/anthodev/.local/share/uv/tools/pebble-tool/lib/python3.14/site-packages', 'NODE_PATH': '/home/anthodev/.local/share/pebble-sdk/SDKs/current/sdk-core/../node_modules', 'NOCLIMB': '1'} | |
| 3 | +environ = {'IMSETTINGS_INTEGRATE_DESKTOP': 'yes', 'npm_command': 'run', 'SESSION_MANAGER': 'local/unix:@/tmp/.ICE-unix/2937,unix/unix:/tmp/.ICE-unix/2937', 'npm_config_yes': 'true', 'GHOSTTY_BIN_DIR': '/usr/bin', 'npm_config_userconfig': '/home/anthodev/.npmrc', 'COLORTERM': 'truecolor', 'HERDR_PANE_ID': 'w6:p2', 'XDG_CONFIG_DIRS': '/home/anthodev/.config/kdedefaults:/etc/xdg:/usr/share/kde-settings/kde-profile/default/xdg', 'npm_config_cache': '/home/anthodev/.npm', 'PYTHONUNBUFFERED': '1', 'PIP_NO_INPUT': '1', 'LESS': 'FRX', 'XDG_SESSION_PATH': '/org/freedesktop/DisplayManager/Session1', 'HISTCONTROL': 'ignoredups', 'XDG_MENU_PREFIX': 'plasma-', 'TERM_PROGRAM_VERSION': '1.3.1-4.fc44', 'COMPOSER_NO_INTERACTION': '1', 'OMPCODE': '1', 'HISTSIZE': '1000', 'TF_IN_AUTOMATION': '1', 'ICEAUTHORITY': '/run/user/1000/iceauth_aLtUGr', 'PNPM_UPDATE_NOTIFIER': 'false', 'NODE': '/usr/bin/node-24', 'GLAB_PAGER': 'cat', 'SSH_AUTH_SOCK': '/home/anthodev/.1password/agent.sock', 'CLOUDSDK_CORE_DISABLE_PROMPTS': '1', 'DOCKER_USER': '1000:1000', '__ABBR_TIPS_VALUES': 'git rebase -i HEAD~2 git rebase --continue git rebase develop gitmoji -c helium-browser --disable-features=WaylandWpColorManagerV1 just eza -al --icons', 'AGENT': '1', 'MEMORY_PRESSURE_WRITE': 'c29tZSAyMDAwMDAgMjAwMDAwMAA=', 'COLOR': '0', 'npm_config_local_prefix': '/home/anthodev/dev/perso/js/lapin-fute', 'XMODIFIERS': '@im=none', 'HOMEBREW_PREFIX': '/home/linuxbrew/.linuxbrew', 'DESKTOP_SESSION': '/usr/share/wayland-sessions/plasma.desktop', 'GTK_RC_FILES': '/etc/gtk/gtkrc:/home/anthodev/.gtkrc:/home/anthodev/.config/gtkrc', 'GDK_CORE_DEVICE_EVENTS': '1', 'npm_config_globalconfig': '/etc/npmrc', 'GPG_TTY': 'not a tty', 'EDITOR': 'true', 'GH_PAGER': 'cat', 'XDG_SEAT': 'seat0', 'GH_PROMPT_DISABLED': '1', 'PWD': '/home/anthodev/dev/perso/js/lapin-fute', 'XDG_SESSION_DESKTOP': 'KDE', 'LOGNAME': 'anthodev', 'XDG_SESSION_TYPE': 'wayland', 'PNPM_HOME': '/home/anthodev/.local/share/pnpm', 'npm_config_init_module': '/home/anthodev/.npm-init.js', 'SYSTEMD_EXEC_PID': '14866', '_': '/usr/bin/node', 'XAUTHORITY': '/run/user/1000/xauth_qgtLXn', 'KWIN_DISABLE_TONEMAPPING': '1', 'SDL_VIDEO_MINIMIZE_ON_FOCUS_LOSS': '0', 'POSH_SHELL': 'fish', 'ABBR_TIPS_PROMPT': '\\n\U0001f4a1 \\e[1m{{ .abbr }}\\e[0m => {{ .cmd }}', 'CLAUDECODE': '1', 'XKB_DEFAULT_MODEL': 'pc105', 'GTK2_RC_FILES': '/etc/gtk-2.0/gtkrc:/home/anthodev/.gtkrc-2.0:/home/anthodev/.config/gtkrc-2.0', '__ABBR_TIPS_KEYS': 'a__gr2 a__grc a__grd a__gtc a__helium-browser a__j a__xl', 'GHOSTTY_SHELL_FEATURES': 'path,sudo,title', 'HOME': '/home/anthodev', '__PI_NATIVE_VARIANT_CACHE': 'modern', 'DELTA_PAGER': 'cat', 'SSH_ASKPASS': '/usr/bin/false', 'LANG': 'fr_FR.UTF-8', '_JAVA_AWT_WM_NONREPARENTING': '1', 'LS_COLORS': '', 'XDG_CURRENT_DESKTOP': 'KDE', 'POSH_SHELL_VERSION': '4.8.1', 'npm_package_version': '0.1.0', 'POSH_SESSION_ID': 'aed4658f-12ea-4aca-a036-894bd3ef6bd0', 'MEMORY_PRESSURE_WATCH': '/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/app-com.mitchellh.ghostty.service/memory.pressure', 'WAYLAND_DISPLAY': 'wayland-0', 'AWS_PAGER': '', 'YARN_ENABLE_PROGRESS_BARS': '0', '_fish_env_initialized': '1', 'CONDA_PROMPT_MODIFIER': 'false', 'XDG_SEAT_PATH': '/org/freedesktop/DisplayManager/Seat0', 'MANAGERPID': '2302', 'IMSETTINGS_MODULE': 'none', 'INIT_CWD': '/home/anthodev/dev/perso/js/lapin-fute', 'POSH_CONFIG': '/home/anthodev/.config/.zsh/anthodev.omp.json', 'STEAM_FRAME_FORCE_CLOSE': '1', 'KDE_SESSION_UID': '1000', 'PSQL_PAGER': 'cat', 'INFOPATH': '/home/linuxbrew/.linuxbrew/share/info:', 'npm_lifecycle_script': 'node scripts/build.mjs', 'NVM_DIR': '/home/anthodev/.nvm', 'MOZ_GMP_PATH': '/usr/lib64/mozilla/plugins/gmp-gmpopenh264/system-installed', 'XKB_DEFAULT_LAYOUT': 'us', 'npm_config_npm_version': '11.16.0', 'HERDR_STARTUP_CWD': '/home/anthodev', 'GHOSTTY_RESOURCES_DIR': '/usr/share/ghostty', 'XDG_SESSION_CLASS': 'user', 'TERMINFO': '/usr/share/terminfo', 'TERM': 'dumb', 'npm_package_name': 'lapin-fute', 'BAT_PAGER': 'cat', 'npm_config_prefix': '/home/anthodev/.npm-global', 'LESSOPEN': '||/usr/bin/lesspipe.sh %s', 'USER': 'anthodev', 'YARN_ENABLE_TELEMETRY': '0', 'GIT_PAGER': 'cat', 'PIP_DISABLE_PIP_VERSION_CHECK': '1', 'HERDR_BIN_PATH': '/home/linuxbrew/.linuxbrew/Cellar/herdr/0.8.2/bin/herdr', 'GID': '1000', 'HOMEBREW_CELLAR': '/home/linuxbrew/.linuxbrew/Cellar', 'QT_WAYLAND_RECONNECT': '1', 'KDE_SESSION_VERSION': '6', 'PAM_KWALLET5_LOGIN': '/run/user/1000/kwallet5.socket', 'MANPAGER': 'cat', 'VISUAL': 'true', 'DISPLAY': ':0', 'npm_lifecycle_event': 'build', 'SHLVL': '1', 'NVM_CD_FLAGS': '', 'GIT_EDITOR': 'true', 'PAGER': 'cat', 'HOMEBREW_REPOSITORY': '/home/linuxbrew/.linuxbrew/Homebrew', 'npm_config_fund': 'false', 'SYSTEMD_PAGER': 'cat', 'XDG_VTNR': '2', 'XDG_SESSION_ID': '2', 'ATUIN_SESSION': '01a05e273bfb7d2196e6386c6401cc46', 'MANAGERPIDFDID': '393', 'npm_config_user_agent': 'npm/11.16.0 node/v24.18.0 linux x64 workspaces/false', 'NO_COLOR': '1', 'TF_INPUT': '0', 'npm_execpath': '/usr/lib/node_modules_24/npm/bin/npm-cli.js', 'npm_config_progress': 'false', 'XDG_RUNTIME_DIR': '/run/user/1000', 'MYSQL_PAGER': 'cat', 'HOMEBREW_PAGER': 'cat', 'PNPM_DISABLE_SELF_UPDATE_CHECK': 'true', 'DEBUGINFOD_URLS': 'ima:enforcing https://debuginfod.fedoraproject.org/ ima:ignore ', 'npm_package_json': '/home/anthodev/dev/perso/js/lapin-fute/package.json', 'DEBUGINFOD_IMA_CERT_PATH': '/etc/keys/ima:', 'LC_ALL': 'C.UTF-8', 'KDEDIRS': '/usr', 'XKB_DEFAULT_VARIANT': 'intl', 'CARGO_TERM_PROGRESS_WHEN': 'never', 'HERDR_ENV': '1', 'XDG_DATA_DIRS': '/home/anthodev/.local/share/flatpak/exports/share:/var/lib/flatpak/exports/share:/usr/local/share:/usr/share', 'KDE_FULL_SESSION': 'true', 'npm_config_allow_scripts': '', 'npm_config_noproxy': '', 'PATH': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable-tools:/home/anthodev/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/arm-none-eabi/bin:/home/anthodev/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages/pebble_tool/commands/sdk:/home/anthodev/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/bin:/home/anthodev/dev/perso/js/lapin-fute/node_modules/.bin:/home/anthodev/dev/perso/js/node_modules/.bin:/home/anthodev/dev/perso/node_modules/.bin:/home/anthodev/dev/node_modules/.bin:/home/anthodev/node_modules/.bin:/home/node_modules/.bin:/node_modules/.bin:/usr/lib/node_modules_24/npm/node_modules/@npmcli/run-script/lib/node-gyp-bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/anthodev/.local/bin:/home/anthodev/bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/anthodev/.atuin/bin:/home/anthodev/.local/bin:/home/anthodev/.bun/bin:/home/anthodev/go/bin:/usr/local/bin:/usr/bin:/sbin:/home/anthodev/.npm-global/bin:/home/anthodev/.local/share/pnpm/bin:/home/anthodev/.cargo/bin:/usr/lib64/ccache:/bin', 'HERDR_TAB_ID': 'w6:t2', 'npm_config_node_gyp': '/usr/lib/node_modules_24/npm/node_modules/node-gyp/bin/node-gyp.js', 'CI': 'true', 'DBUS_SESSION_BUS_ADDRESS': 'unix:path=/run/user/1000/bus', 'npm_config_global_prefix': '/home/anthodev/.npm-global', 'npm_config_update_notifier': 'false', 'KDE_APPLICATIONS_AS_SCOPE': '1', 'HERDR_SOCKET_PATH': '/home/anthodev/.config/herdr/herdr.sock', 'HERDR_WORKSPACE_ID': 'w6', 'MAIL': '/var/spool/mail/anthodev', 'POWERLINE_COMMAND': 'oh-my-posh', 'npm_config_audit': 'false', 'ABBR_TIPS_REGEXES': '(^(\\w+\\s+)+(-{1,2})\\w+)(\\s\\S+) (^(\\s?(\\w-?)+){3}).* (^(\\s?(\\w-?)+){2}).* (^(\\s?(\\w-?)+){1}).*', 'DEBIAN_FRONTEND': 'noninteractive', 'GIT_TERMINAL_PROMPT': '0', 'npm_node_execpath': '/usr/bin/node-24', 'npm_package_engines_node': '24.18.0', 'TERM_PROGRAM': 'ghostty', 'MODDABLE': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable', 'PYTHONHOME': '/home/anthodev/.local/share/pebble-sdk/SDKs/current/.venv', 'PYTHONPATH': '/home/anthodev/.local/share/uv/tools/pebble-tool/bin:/usr/lib64/python314.zip:/usr/lib64/python3.14:/usr/lib64/python3.14/lib-dynload:/home/anthodev/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages:/home/anthodev/.local/share/uv/tools/pebble-tool/lib/python3.14/site-packages', 'NODE_PATH': '/home/anthodev/.local/share/pebble-sdk/SDKs/current/sdk-core/../node_modules', 'NOCLIMB': '1'} | |
| 4 | 4 | files = ['/home/anthodev/dev/perso/js/lapin-fute/packages/watch/wscript'] |
| 5 | 5 | hash = b'\xc6\xb0\xec\xd9\xadu[\xb0m\xd2\xe0\x84N\xf01%' |
| 6 | 6 | launch_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch' |
| @@ -1,6 +1,6 @@ | |||
| 1 | argv = ['/home/anthodev/.local/share/pebble-sdk/SDKs/current/sdk-core/pebble/waf', 'configure'] | 1 | argv = ['/home/anthodev/.local/share/pebble-sdk/SDKs/current/sdk-core/pebble/waf', 'configure'] |
| 2 | config_cmd = 'configure' | 2 | config_cmd = 'configure' |
| 3 | -environ = {'IMSETTINGS_INTEGRATE_DESKTOP': 'yes', 'npm_command': 'run', 'SESSION_MANAGER': 'local/unix:@/tmp/.ICE-unix/2942,unix/unix:/tmp/.ICE-unix/2942', 'npm_config_yes': 'true', 'GHOSTTY_BIN_DIR': '/usr/bin', 'npm_config_userconfig': '/home/anthodev/.npmrc', 'COLORTERM': 'truecolor', 'HERDR_PANE_ID': 'w6:p2', 'XDG_CONFIG_DIRS': '/home/anthodev/.config/kdedefaults:/etc/xdg:/usr/share/kde-settings/kde-profile/default/xdg', 'npm_config_cache': '/home/anthodev/.npm', 'PYTHONUNBUFFERED': '1', 'PIP_NO_INPUT': '1', 'LESS': 'FRX', 'XDG_SESSION_PATH': '/org/freedesktop/DisplayManager/Session1', 'HISTCONTROL': 'ignoredups', 'XDG_MENU_PREFIX': 'plasma-', 'TERM_PROGRAM_VERSION': '1.3.1-4.fc44', 'COMPOSER_NO_INTERACTION': '1', 'OMPCODE': '1', 'HISTSIZE': '1000', 'TF_IN_AUTOMATION': '1', 'ICEAUTHORITY': '/run/user/1000/iceauth_KXfteX', 'PNPM_UPDATE_NOTIFIER': 'false', 'NODE': '/usr/bin/node-24', 'GLAB_PAGER': 'cat', 'SSH_AUTH_SOCK': '/home/anthodev/.1password/agent.sock', 'CLOUDSDK_CORE_DISABLE_PROMPTS': '1', 'DOCKER_USER': '1000:1000', '__ABBR_TIPS_VALUES': 'git rebase -i HEAD~2 git rebase --continue git rebase develop gitmoji -c helium-browser --disable-features=WaylandWpColorManagerV1 just eza -al --icons', 'AGENT': '1', 'MEMORY_PRESSURE_WRITE': 'c29tZSAyMDAwMDAgMjAwMDAwMAA=', 'COLOR': '0', 'npm_config_local_prefix': '/home/anthodev/dev/perso/js/lapin-fute', 'XMODIFIERS': '@im=none', 'HOMEBREW_PREFIX': '/home/linuxbrew/.linuxbrew', 'DESKTOP_SESSION': '/usr/share/wayland-sessions/plasma.desktop', 'GTK_RC_FILES': '/etc/gtk/gtkrc:/home/anthodev/.gtkrc:/home/anthodev/.config/gtkrc', 'GDK_CORE_DEVICE_EVENTS': '1', 'npm_config_globalconfig': '/etc/npmrc', 'GPG_TTY': 'not a tty', 'EDITOR': 'true', 'GH_PAGER': 'cat', 'XDG_SEAT': 'seat0', 'GH_PROMPT_DISABLED': '1', 'PWD': '/home/anthodev/dev/perso/js/lapin-fute', 'XDG_SESSION_DESKTOP': 'KDE', 'LOGNAME': 'anthodev', 'XDG_SESSION_TYPE': 'wayland', 'PNPM_HOME': '/home/anthodev/.local/share/pnpm', 'npm_config_init_module': '/home/anthodev/.npm-init.js', 'SYSTEMD_EXEC_PID': '6424', '_': '/usr/bin/node', 'XAUTHORITY': '/run/user/1000/xauth_XpcTHG', 'KWIN_DISABLE_TONEMAPPING': '1', 'SDL_VIDEO_MINIMIZE_ON_FOCUS_LOSS': '0', 'POSH_SHELL': 'fish', 'ABBR_TIPS_PROMPT': '\\n\U0001f4a1 \\e[1m{{ .abbr }}\\e[0m => {{ .cmd }}', 'CLAUDECODE': '1', 'XKB_DEFAULT_MODEL': 'pc105', 'GTK2_RC_FILES': '/etc/gtk-2.0/gtkrc:/home/anthodev/.gtkrc-2.0:/home/anthodev/.config/gtkrc-2.0', '__ABBR_TIPS_KEYS': 'a__gr2 a__grc a__grd a__gtc a__helium-browser a__j a__xl', 'GHOSTTY_SHELL_FEATURES': 'path,sudo,title', 'HOME': '/home/anthodev', '__PI_NATIVE_VARIANT_CACHE': 'modern', 'DELTA_PAGER': 'cat', 'SSH_ASKPASS': '/usr/bin/false', 'LANG': 'fr_FR.UTF-8', '_JAVA_AWT_WM_NONREPARENTING': '1', 'LS_COLORS': '', 'XDG_CURRENT_DESKTOP': 'KDE', 'POSH_SHELL_VERSION': '4.8.1', 'npm_package_version': '0.1.0', 'POSH_SESSION_ID': 'aed4658f-12ea-4aca-a036-894bd3ef6bd0', 'MEMORY_PRESSURE_WATCH': '/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/app-com.mitchellh.ghostty.service/memory.pressure', 'WAYLAND_DISPLAY': 'wayland-0', 'AWS_PAGER': '', 'YARN_ENABLE_PROGRESS_BARS': '0', '_fish_env_initialized': '1', 'CONDA_PROMPT_MODIFIER': 'false', 'XDG_SEAT_PATH': '/org/freedesktop/DisplayManager/Seat0', 'MANAGERPID': '2300', 'IMSETTINGS_MODULE': 'none', 'INIT_CWD': '/home/anthodev/dev/perso/js/lapin-fute', 'POSH_CONFIG': '/home/anthodev/.config/.zsh/anthodev.omp.json', 'STEAM_FRAME_FORCE_CLOSE': '1', 'KDE_SESSION_UID': '1000', 'PSQL_PAGER': 'cat', 'INFOPATH': '/home/linuxbrew/.linuxbrew/share/info:', 'npm_lifecycle_script': 'node scripts/build.mjs', 'NVM_DIR': '/home/anthodev/.nvm', 'MOZ_GMP_PATH': '/usr/lib64/mozilla/plugins/gmp-gmpopenh264/system-installed', 'XKB_DEFAULT_LAYOUT': 'us', 'npm_config_npm_version': '11.16.0', 'GHOSTTY_RESOURCES_DIR': '/usr/share/ghostty', 'XDG_SESSION_CLASS': 'user', 'TERMINFO': '/usr/share/terminfo', 'TERM': 'dumb', 'npm_package_name': 'lapin-fute', 'BAT_PAGER': 'cat', 'npm_config_prefix': '/home/anthodev/.npm-global', 'LESSOPEN': '||/usr/bin/lesspipe.sh %s', 'USER': 'anthodev', 'YARN_ENABLE_TELEMETRY': '0', 'GIT_PAGER': 'cat', 'PIP_DISABLE_PIP_VERSION_CHECK': '1', 'HERDR_BIN_PATH': '/home/linuxbrew/.linuxbrew/Cellar/herdr/0.8.2/bin/herdr', 'GID': '1000', 'HOMEBREW_CELLAR': '/home/linuxbrew/.linuxbrew/Cellar', 'QT_WAYLAND_RECONNECT': '1', 'KDE_SESSION_VERSION': '6', 'PAM_KWALLET5_LOGIN': '/run/user/1000/kwallet5.socket', 'MANPAGER': 'cat', 'VISUAL': 'true', 'DISPLAY': ':0', 'npm_lifecycle_event': 'build', 'SHLVL': '1', 'NVM_CD_FLAGS': '', 'GIT_EDITOR': 'true', 'PAGER': 'cat', 'HOMEBREW_REPOSITORY': '/home/linuxbrew/.linuxbrew/Homebrew', 'npm_config_fund': 'false', 'SYSTEMD_PAGER': 'cat', 'XDG_VTNR': '2', 'XDG_SESSION_ID': '2', 'ATUIN_SESSION': '01a0523b089c70c29608f76da5ef3ae9', 'MANAGERPIDFDID': '8555', 'npm_config_user_agent': 'npm/11.16.0 node/v24.18.0 linux x64 workspaces/false', 'NO_COLOR': '1', 'TF_INPUT': '0', 'npm_execpath': '/usr/lib/node_modules_24/npm/bin/npm-cli.js', 'npm_config_progress': 'false', 'XDG_RUNTIME_DIR': '/run/user/1000', 'MYSQL_PAGER': 'cat', 'HOMEBREW_PAGER': 'cat', 'PNPM_DISABLE_SELF_UPDATE_CHECK': 'true', 'DEBUGINFOD_URLS': 'ima:enforcing https://debuginfod.fedoraproject.org/ ima:ignore ', 'npm_package_json': '/home/anthodev/dev/perso/js/lapin-fute/package.json', 'DEBUGINFOD_IMA_CERT_PATH': '/etc/keys/ima:', 'LC_ALL': 'C.UTF-8', 'KDEDIRS': '/usr', 'XKB_DEFAULT_VARIANT': 'intl', 'CARGO_TERM_PROGRESS_WHEN': 'never', 'HERDR_ENV': '1', 'XDG_DATA_DIRS': '/home/anthodev/.local/share/flatpak/exports/share:/var/lib/flatpak/exports/share:/usr/local/share:/usr/share', 'KDE_FULL_SESSION': 'true', 'npm_config_allow_scripts': '', 'npm_config_noproxy': '', 'PATH': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable-tools:/home/anthodev/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/arm-none-eabi/bin:/home/anthodev/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages/pebble_tool/commands/sdk:/home/anthodev/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/bin:/home/anthodev/dev/perso/js/lapin-fute/node_modules/.bin:/home/anthodev/dev/perso/js/node_modules/.bin:/home/anthodev/dev/perso/node_modules/.bin:/home/anthodev/dev/node_modules/.bin:/home/anthodev/node_modules/.bin:/home/node_modules/.bin:/node_modules/.bin:/usr/lib/node_modules_24/npm/node_modules/@npmcli/run-script/lib/node-gyp-bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/anthodev/.local/bin:/home/anthodev/bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/anthodev/.atuin/bin:/home/anthodev/.local/bin:/home/anthodev/.bun/bin:/home/anthodev/go/bin:/usr/local/bin:/usr/bin:/sbin:/home/anthodev/.npm-global/bin:/home/anthodev/.local/share/pnpm/bin:/home/anthodev/.cargo/bin:/usr/lib64/ccache:/bin', 'HERDR_TAB_ID': 'w6:t2', 'npm_config_node_gyp': '/usr/lib/node_modules_24/npm/node_modules/node-gyp/bin/node-gyp.js', 'CI': 'true', 'DBUS_SESSION_BUS_ADDRESS': 'unix:path=/run/user/1000/bus', 'npm_config_global_prefix': '/home/anthodev/.npm-global', 'npm_config_update_notifier': 'false', 'KDE_APPLICATIONS_AS_SCOPE': '1', 'HERDR_SOCKET_PATH': '/home/anthodev/.config/herdr/herdr.sock', 'HERDR_WORKSPACE_ID': 'w6', 'MAIL': '/var/spool/mail/anthodev', 'POWERLINE_COMMAND': 'oh-my-posh', 'npm_config_audit': 'false', 'ABBR_TIPS_REGEXES': '(^(\\w+\\s+)+(-{1,2})\\w+)(\\s\\S+) (^(\\s?(\\w-?)+){3}).* (^(\\s?(\\w-?)+){2}).* (^(\\s?(\\w-?)+){1}).*', 'DEBIAN_FRONTEND': 'noninteractive', 'GIT_TERMINAL_PROMPT': '0', 'npm_node_execpath': '/usr/bin/node-24', 'npm_package_engines_node': '24.18.0', 'TERM_PROGRAM': 'ghostty', 'MODDABLE': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable', 'PYTHONHOME': '/home/anthodev/.local/share/pebble-sdk/SDKs/current/.venv', 'PYTHONPATH': '/home/anthodev/.local/share/uv/tools/pebble-tool/bin:/usr/lib64/python314.zip:/usr/lib64/python3.14:/usr/lib64/python3.14/lib-dynload:/home/anthodev/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages:/home/anthodev/.local/share/uv/tools/pebble-tool/lib/python3.14/site-packages', 'NODE_PATH': '/home/anthodev/.local/share/pebble-sdk/SDKs/current/sdk-core/../node_modules', 'NOCLIMB': '1'} | 3 | +environ = {'IMSETTINGS_INTEGRATE_DESKTOP': 'yes', 'npm_command': 'run', 'SESSION_MANAGER': 'local/unix:@/tmp/.ICE-unix/2937,unix/unix:/tmp/.ICE-unix/2937', 'npm_config_yes': 'true', 'GHOSTTY_BIN_DIR': '/usr/bin', 'npm_config_userconfig': '/home/anthodev/.npmrc', 'COLORTERM': 'truecolor', 'HERDR_PANE_ID': 'w6:p2', 'XDG_CONFIG_DIRS': '/home/anthodev/.config/kdedefaults:/etc/xdg:/usr/share/kde-settings/kde-profile/default/xdg', 'npm_config_cache': '/home/anthodev/.npm', 'PYTHONUNBUFFERED': '1', 'PIP_NO_INPUT': '1', 'LESS': 'FRX', 'XDG_SESSION_PATH': '/org/freedesktop/DisplayManager/Session1', 'HISTCONTROL': 'ignoredups', 'XDG_MENU_PREFIX': 'plasma-', 'TERM_PROGRAM_VERSION': '1.3.1-4.fc44', 'COMPOSER_NO_INTERACTION': '1', 'OMPCODE': '1', 'HISTSIZE': '1000', 'TF_IN_AUTOMATION': '1', 'ICEAUTHORITY': '/run/user/1000/iceauth_aLtUGr', 'PNPM_UPDATE_NOTIFIER': 'false', 'NODE': '/usr/bin/node-24', 'GLAB_PAGER': 'cat', 'SSH_AUTH_SOCK': '/home/anthodev/.1password/agent.sock', 'CLOUDSDK_CORE_DISABLE_PROMPTS': '1', 'DOCKER_USER': '1000:1000', '__ABBR_TIPS_VALUES': 'git rebase -i HEAD~2 git rebase --continue git rebase develop gitmoji -c helium-browser --disable-features=WaylandWpColorManagerV1 just eza -al --icons', 'AGENT': '1', 'MEMORY_PRESSURE_WRITE': 'c29tZSAyMDAwMDAgMjAwMDAwMAA=', 'COLOR': '0', 'npm_config_local_prefix': '/home/anthodev/dev/perso/js/lapin-fute', 'XMODIFIERS': '@im=none', 'HOMEBREW_PREFIX': '/home/linuxbrew/.linuxbrew', 'DESKTOP_SESSION': '/usr/share/wayland-sessions/plasma.desktop', 'GTK_RC_FILES': '/etc/gtk/gtkrc:/home/anthodev/.gtkrc:/home/anthodev/.config/gtkrc', 'GDK_CORE_DEVICE_EVENTS': '1', 'npm_config_globalconfig': '/etc/npmrc', 'GPG_TTY': 'not a tty', 'EDITOR': 'true', 'GH_PAGER': 'cat', 'XDG_SEAT': 'seat0', 'GH_PROMPT_DISABLED': '1', 'PWD': '/home/anthodev/dev/perso/js/lapin-fute', 'XDG_SESSION_DESKTOP': 'KDE', 'LOGNAME': 'anthodev', 'XDG_SESSION_TYPE': 'wayland', 'PNPM_HOME': '/home/anthodev/.local/share/pnpm', 'npm_config_init_module': '/home/anthodev/.npm-init.js', 'SYSTEMD_EXEC_PID': '14866', '_': '/usr/bin/node', 'XAUTHORITY': '/run/user/1000/xauth_qgtLXn', 'KWIN_DISABLE_TONEMAPPING': '1', 'SDL_VIDEO_MINIMIZE_ON_FOCUS_LOSS': '0', 'POSH_SHELL': 'fish', 'ABBR_TIPS_PROMPT': '\\n\U0001f4a1 \\e[1m{{ .abbr }}\\e[0m => {{ .cmd }}', 'CLAUDECODE': '1', 'XKB_DEFAULT_MODEL': 'pc105', 'GTK2_RC_FILES': '/etc/gtk-2.0/gtkrc:/home/anthodev/.gtkrc-2.0:/home/anthodev/.config/gtkrc-2.0', '__ABBR_TIPS_KEYS': 'a__gr2 a__grc a__grd a__gtc a__helium-browser a__j a__xl', 'GHOSTTY_SHELL_FEATURES': 'path,sudo,title', 'HOME': '/home/anthodev', '__PI_NATIVE_VARIANT_CACHE': 'modern', 'DELTA_PAGER': 'cat', 'SSH_ASKPASS': '/usr/bin/false', 'LANG': 'fr_FR.UTF-8', '_JAVA_AWT_WM_NONREPARENTING': '1', 'LS_COLORS': '', 'XDG_CURRENT_DESKTOP': 'KDE', 'POSH_SHELL_VERSION': '4.8.1', 'npm_package_version': '0.1.0', 'POSH_SESSION_ID': 'aed4658f-12ea-4aca-a036-894bd3ef6bd0', 'MEMORY_PRESSURE_WATCH': '/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/app-com.mitchellh.ghostty.service/memory.pressure', 'WAYLAND_DISPLAY': 'wayland-0', 'AWS_PAGER': '', 'YARN_ENABLE_PROGRESS_BARS': '0', '_fish_env_initialized': '1', 'CONDA_PROMPT_MODIFIER': 'false', 'XDG_SEAT_PATH': '/org/freedesktop/DisplayManager/Seat0', 'MANAGERPID': '2302', 'IMSETTINGS_MODULE': 'none', 'INIT_CWD': '/home/anthodev/dev/perso/js/lapin-fute', 'POSH_CONFIG': '/home/anthodev/.config/.zsh/anthodev.omp.json', 'STEAM_FRAME_FORCE_CLOSE': '1', 'KDE_SESSION_UID': '1000', 'PSQL_PAGER': 'cat', 'INFOPATH': '/home/linuxbrew/.linuxbrew/share/info:', 'npm_lifecycle_script': 'node scripts/build.mjs', 'NVM_DIR': '/home/anthodev/.nvm', 'MOZ_GMP_PATH': '/usr/lib64/mozilla/plugins/gmp-gmpopenh264/system-installed', 'XKB_DEFAULT_LAYOUT': 'us', 'npm_config_npm_version': '11.16.0', 'HERDR_STARTUP_CWD': '/home/anthodev', 'GHOSTTY_RESOURCES_DIR': '/usr/share/ghostty', 'XDG_SESSION_CLASS': 'user', 'TERMINFO': '/usr/share/terminfo', 'TERM': 'dumb', 'npm_package_name': 'lapin-fute', 'BAT_PAGER': 'cat', 'npm_config_prefix': '/home/anthodev/.npm-global', 'LESSOPEN': '||/usr/bin/lesspipe.sh %s', 'USER': 'anthodev', 'YARN_ENABLE_TELEMETRY': '0', 'GIT_PAGER': 'cat', 'PIP_DISABLE_PIP_VERSION_CHECK': '1', 'HERDR_BIN_PATH': '/home/linuxbrew/.linuxbrew/Cellar/herdr/0.8.2/bin/herdr', 'GID': '1000', 'HOMEBREW_CELLAR': '/home/linuxbrew/.linuxbrew/Cellar', 'QT_WAYLAND_RECONNECT': '1', 'KDE_SESSION_VERSION': '6', 'PAM_KWALLET5_LOGIN': '/run/user/1000/kwallet5.socket', 'MANPAGER': 'cat', 'VISUAL': 'true', 'DISPLAY': ':0', 'npm_lifecycle_event': 'build', 'SHLVL': '1', 'NVM_CD_FLAGS': '', 'GIT_EDITOR': 'true', 'PAGER': 'cat', 'HOMEBREW_REPOSITORY': '/home/linuxbrew/.linuxbrew/Homebrew', 'npm_config_fund': 'false', 'SYSTEMD_PAGER': 'cat', 'XDG_VTNR': '2', 'XDG_SESSION_ID': '2', 'ATUIN_SESSION': '01a05e273bfb7d2196e6386c6401cc46', 'MANAGERPIDFDID': '393', 'npm_config_user_agent': 'npm/11.16.0 node/v24.18.0 linux x64 workspaces/false', 'NO_COLOR': '1', 'TF_INPUT': '0', 'npm_execpath': '/usr/lib/node_modules_24/npm/bin/npm-cli.js', 'npm_config_progress': 'false', 'XDG_RUNTIME_DIR': '/run/user/1000', 'MYSQL_PAGER': 'cat', 'HOMEBREW_PAGER': 'cat', 'PNPM_DISABLE_SELF_UPDATE_CHECK': 'true', 'DEBUGINFOD_URLS': 'ima:enforcing https://debuginfod.fedoraproject.org/ ima:ignore ', 'npm_package_json': '/home/anthodev/dev/perso/js/lapin-fute/package.json', 'DEBUGINFOD_IMA_CERT_PATH': '/etc/keys/ima:', 'LC_ALL': 'C.UTF-8', 'KDEDIRS': '/usr', 'XKB_DEFAULT_VARIANT': 'intl', 'CARGO_TERM_PROGRESS_WHEN': 'never', 'HERDR_ENV': '1', 'XDG_DATA_DIRS': '/home/anthodev/.local/share/flatpak/exports/share:/var/lib/flatpak/exports/share:/usr/local/share:/usr/share', 'KDE_FULL_SESSION': 'true', 'npm_config_allow_scripts': '', 'npm_config_noproxy': '', 'PATH': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable-tools:/home/anthodev/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/arm-none-eabi/bin:/home/anthodev/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages/pebble_tool/commands/sdk:/home/anthodev/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/bin:/home/anthodev/dev/perso/js/lapin-fute/node_modules/.bin:/home/anthodev/dev/perso/js/node_modules/.bin:/home/anthodev/dev/perso/node_modules/.bin:/home/anthodev/dev/node_modules/.bin:/home/anthodev/node_modules/.bin:/home/node_modules/.bin:/node_modules/.bin:/usr/lib/node_modules_24/npm/node_modules/@npmcli/run-script/lib/node-gyp-bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/anthodev/.local/bin:/home/anthodev/bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/anthodev/.atuin/bin:/home/anthodev/.local/bin:/home/anthodev/.bun/bin:/home/anthodev/go/bin:/usr/local/bin:/usr/bin:/sbin:/home/anthodev/.npm-global/bin:/home/anthodev/.local/share/pnpm/bin:/home/anthodev/.cargo/bin:/usr/lib64/ccache:/bin', 'HERDR_TAB_ID': 'w6:t2', 'npm_config_node_gyp': '/usr/lib/node_modules_24/npm/node_modules/node-gyp/bin/node-gyp.js', 'CI': 'true', 'DBUS_SESSION_BUS_ADDRESS': 'unix:path=/run/user/1000/bus', 'npm_config_global_prefix': '/home/anthodev/.npm-global', 'npm_config_update_notifier': 'false', 'KDE_APPLICATIONS_AS_SCOPE': '1', 'HERDR_SOCKET_PATH': '/home/anthodev/.config/herdr/herdr.sock', 'HERDR_WORKSPACE_ID': 'w6', 'MAIL': '/var/spool/mail/anthodev', 'POWERLINE_COMMAND': 'oh-my-posh', 'npm_config_audit': 'false', 'ABBR_TIPS_REGEXES': '(^(\\w+\\s+)+(-{1,2})\\w+)(\\s\\S+) (^(\\s?(\\w-?)+){3}).* (^(\\s?(\\w-?)+){2}).* (^(\\s?(\\w-?)+){1}).*', 'DEBIAN_FRONTEND': 'noninteractive', 'GIT_TERMINAL_PROMPT': '0', 'npm_node_execpath': '/usr/bin/node-24', 'npm_package_engines_node': '24.18.0', 'TERM_PROGRAM': 'ghostty', 'MODDABLE': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable', 'PYTHONHOME': '/home/anthodev/.local/share/pebble-sdk/SDKs/current/.venv', 'PYTHONPATH': '/home/anthodev/.local/share/uv/tools/pebble-tool/bin:/usr/lib64/python314.zip:/usr/lib64/python3.14:/usr/lib64/python3.14/lib-dynload:/home/anthodev/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages:/home/anthodev/.local/share/uv/tools/pebble-tool/lib/python3.14/site-packages', 'NODE_PATH': '/home/anthodev/.local/share/pebble-sdk/SDKs/current/sdk-core/../node_modules', 'NOCLIMB': '1'} |
| 4 | files = ['/home/anthodev/dev/perso/js/lapin-fute/packages/watch/wscript'] | 4 | files = ['/home/anthodev/dev/perso/js/lapin-fute/packages/watch/wscript'] |
| 5 | hash = b'\xc6\xb0\xec\xd9\xadu[\xb0m\xd2\xe0\x84N\xf01%' | 5 | hash = b'\xc6\xb0\xec\xd9\xadu[\xb0m\xd2\xe0\x84N\xf01%' |
| 6 | launch_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch' | 6 | launch_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch' |
added
scripts/measure-config-page.mjs +25 -0 | new file mode 100644 | ||
| @@ -0,0 +1,25 @@ | ||
| 1 | +import { gzipSync } from "node:zlib"; | |
| 2 | +import { readFileSync } from "node:fs"; | |
| 3 | +import { fileURLToPath } from "node:url"; | |
| 4 | + | |
| 5 | +const BUDGET_BYTES = 200 * 1024; | |
| 6 | +const assets = [ | |
| 7 | + "../packages/config-page/src/config-core.js", | |
| 8 | + "../packages/config-page/src/catalog-client.js", | |
| 9 | + "../packages/config-page/src/preview-fixture.js", | |
| 10 | + "../packages/config-page/src/line-badge-assets.js", | |
| 11 | + "../packages/config-page/src/config-page.js", | |
| 12 | + "../packages/config-page/styles/config-page.css", | |
| 13 | +]; | |
| 14 | + | |
| 15 | +const measurements = assets.map((relativePath) => { | |
| 16 | + const path = fileURLToPath(new URL(relativePath, import.meta.url)); | |
| 17 | + return { relativePath, bytes: gzipSync(readFileSync(path), { level: 9 }).byteLength }; | |
| 18 | +}); | |
| 19 | +const totalBytes = measurements.reduce((total, entry) => total + entry.bytes, 0); | |
| 20 | + | |
| 21 | +for (const entry of measurements) { | |
| 22 | + process.stdout.write(`${entry.relativePath}: ${entry.bytes} gzip bytes\n`); | |
| 23 | +} | |
| 24 | +process.stdout.write(`config page JS+CSS: ${totalBytes} / ${BUDGET_BYTES} gzip bytes\n`); | |
| 25 | +if (totalBytes > BUDGET_BYTES) process.exitCode = 1; | |
| new file mode 100644 | |||
| @@ -0,0 +1,25 @@ | |||
| 1 | +import { gzipSync } from "node:zlib"; | ||
| 2 | +import { readFileSync } from "node:fs"; | ||
| 3 | +import { fileURLToPath } from "node:url"; | ||
| 4 | + | ||
| 5 | +const BUDGET_BYTES = 200 * 1024; | ||
| 6 | +const assets = [ | ||
| 7 | + "../packages/config-page/src/config-core.js", | ||
| 8 | + "../packages/config-page/src/catalog-client.js", | ||
| 9 | + "../packages/config-page/src/preview-fixture.js", | ||
| 10 | + "../packages/config-page/src/line-badge-assets.js", | ||
| 11 | + "../packages/config-page/src/config-page.js", | ||
| 12 | + "../packages/config-page/styles/config-page.css", | ||
| 13 | +]; | ||
| 14 | + | ||
| 15 | +const measurements = assets.map((relativePath) => { | ||
| 16 | + const path = fileURLToPath(new URL(relativePath, import.meta.url)); | ||
| 17 | + return { relativePath, bytes: gzipSync(readFileSync(path), { level: 9 }).byteLength }; | ||
| 18 | +}); | ||
| 19 | +const totalBytes = measurements.reduce((total, entry) => total + entry.bytes, 0); | ||
| 20 | + | ||
| 21 | +for (const entry of measurements) { | ||
| 22 | + process.stdout.write(`${entry.relativePath}: ${entry.bytes} gzip bytes\n`); | ||
| 23 | +} | ||
| 24 | +process.stdout.write(`config page JS+CSS: ${totalBytes} / ${BUDGET_BYTES} gzip bytes\n`); | ||
| 25 | +if (totalBytes > BUDGET_BYTES) process.exitCode = 1; | ||
added
scripts/sync-line-badges.mjs +173 -0 | new file mode 100644 | ||
| @@ -0,0 +1,173 @@ | ||
| 1 | +import { createHash } from "node:crypto"; | |
| 2 | +import { mkdir, rename, rm, writeFile } from "node:fs/promises"; | |
| 3 | +import { dirname, join, resolve } from "node:path"; | |
| 4 | +import { fileURLToPath } from "node:url"; | |
| 5 | + | |
| 6 | +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); | |
| 7 | +const ASSET_ROOT = join(ROOT, "packages/config-page/assets"); | |
| 8 | +const OUTPUT = join(ASSET_ROOT, "line-badges"); | |
| 9 | +const RESOLVER = join(ROOT, "packages/config-page/src/line-badge-assets.js"); | |
| 10 | +const ORIGIN = "https://data.iledefrance-mobilites.fr"; | |
| 11 | +const DATASET = "referentiel-des-lignes"; | |
| 12 | +const DATASET_URL = `${ORIGIN}/explore/dataset/${DATASET}/`; | |
| 13 | +const DATASET_LICENSE_URL = "https://opendatacommons.org/licenses/odbl/1-0/"; | |
| 14 | +const ILICO_TERMS_URL = "https://prim.iledefrance-mobilites.fr/fr/conditions-utilisation"; | |
| 15 | +const TRUSTED_SVG_SHA256 = new Set([ | |
| 16 | + "7c79d62ae30950fe638667f63b0ec62bec9bc18a171b1309ca996e670d3d9e25", | |
| 17 | +]); | |
| 18 | +const MAX_BODY_BYTES = 2 * 1024 * 1024; | |
| 19 | +const TIMEOUT_MS = 15_000; | |
| 20 | + | |
| 21 | +function fail(message) { | |
| 22 | + throw new Error(message); | |
| 23 | +} | |
| 24 | + | |
| 25 | +function safeLabel(value) { | |
| 26 | + if (typeof value !== "string" || value.trim() === "") fail("Line label is missing"); | |
| 27 | + return value.trim(); | |
| 28 | +} | |
| 29 | + | |
| 30 | +function modeFor(record) { | |
| 31 | + const mode = String(record.transportmode ?? "").trim().toLowerCase(); | |
| 32 | + const submode = String(record.transportsubmode ?? "").trim().toLowerCase(); | |
| 33 | + if (mode === "metro") return "METRO"; | |
| 34 | + if (mode === "tram") return "TRAM"; | |
| 35 | + if (mode === "rail" && submode === "local") return "RER"; | |
| 36 | + if (mode === "rail" && submode === "suburbanrailway") return "TRANSILIEN"; | |
| 37 | + return undefined; | |
| 38 | +} | |
| 39 | + | |
| 40 | +function slug(value) { | |
| 41 | + const normalized = value.normalize("NFKD").replace(/[\u0300-\u036f]/gu, "").toLowerCase(); | |
| 42 | + const result = normalized.replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, ""); | |
| 43 | + if (!/^[a-z0-9](?:[a-z0-9-]{0,62})$/u.test(result)) fail(`Unsafe generated label: ${value}`); | |
| 44 | + return result; | |
| 45 | +} | |
| 46 | + | |
| 47 | +async function boundedFetch(url, expectedType) { | |
| 48 | + const target = new URL(url); | |
| 49 | + if (target.origin !== ORIGIN) fail(`Unexpected asset origin: ${target.origin}`); | |
| 50 | + if (!target.pathname.startsWith(`/api/explore/v2.1/catalog/datasets/${DATASET}/`)) { | |
| 51 | + fail(`Unexpected asset path: ${target.pathname}`); | |
| 52 | + } | |
| 53 | + const controller = new AbortController(); | |
| 54 | + const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); | |
| 55 | + try { | |
| 56 | + const response = await fetch(target, { | |
| 57 | + redirect: "manual", | |
| 58 | + credentials: "omit", | |
| 59 | + cache: "no-store", | |
| 60 | + headers: { accept: expectedType }, | |
| 61 | + signal: controller.signal, | |
| 62 | + }); | |
| 63 | + if (response.status !== 200 || response.body === null) fail(`${target.pathname} returned HTTP ${response.status}`); | |
| 64 | + const chunks = []; | |
| 65 | + let length = 0; | |
| 66 | + for await (const chunk of response.body) { | |
| 67 | + length += chunk.byteLength; | |
| 68 | + if (length > MAX_BODY_BYTES) fail(`${target.pathname} exceeds 2 MiB`); | |
| 69 | + chunks.push(chunk); | |
| 70 | + } | |
| 71 | + return { body: Buffer.concat(chunks), type: (response.headers.get("content-type") ?? "").split(";", 1)[0].trim().toLowerCase() }; | |
| 72 | + } finally { | |
| 73 | + clearTimeout(timeout); | |
| 74 | + } | |
| 75 | +} | |
| 76 | + | |
| 77 | +function validateImage(body, type, sha256) { | |
| 78 | + if (type === "image/png") { | |
| 79 | + if (body.length < 8 || !body.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) fail("Invalid PNG signature"); | |
| 80 | + return "png"; | |
| 81 | + } | |
| 82 | + if (type !== "image/svg+xml") fail(`Unsupported badge media type: ${type}`); | |
| 83 | + if (!TRUSTED_SVG_SHA256.has(sha256)) { | |
| 84 | + fail(`Unreviewed SVG badge ${sha256}; review and pin it before publication`); | |
| 85 | + } | |
| 86 | + const source = new TextDecoder("utf-8", { fatal: true }).decode(body); | |
| 87 | + if (!/<svg\b/iu.test(source) | |
| 88 | + || /<\s*(?:script|foreignObject|iframe|object|embed)\b/iu.test(source) | |
| 89 | + || /\son[a-z]+\s*=/iu.test(source) | |
| 90 | + || /(?:href|src)\s*=\s*["']\s*(?:javascript:|data:|https?:|\/\/)/iu.test(source) | |
| 91 | + || /url\(\s*["']?\s*(?:javascript:|data:|https?:|\/\/)/iu.test(source)) { | |
| 92 | + fail("Unsafe pinned SVG badge"); | |
| 93 | + } | |
| 94 | + return "svg"; | |
| 95 | +} | |
| 96 | + | |
| 97 | +async function records() { | |
| 98 | + const query = new URL(`/api/explore/v2.1/catalog/datasets/${DATASET}/records`, ORIGIN); | |
| 99 | + query.searchParams.set("select", "name_line,shortname_line,transportmode,transportsubmode,status,picto"); | |
| 100 | + query.searchParams.set("where", "status = 'active' and (transportmode in ('metro','tram') or (transportmode = 'rail' and transportsubmode in ('local','suburbanRailway')))"); | |
| 101 | + query.searchParams.set("limit", "100"); | |
| 102 | + const { body, type } = await boundedFetch(query, "application/json"); | |
| 103 | + if (type !== "application/json") fail(`Unexpected records media type: ${type}`); | |
| 104 | + const payload = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body)); | |
| 105 | + if (!Number.isSafeInteger(payload.total_count) || !Array.isArray(payload.results) || payload.results.length !== payload.total_count) { | |
| 106 | + fail("Incomplete line badge result set"); | |
| 107 | + } | |
| 108 | + return payload.results; | |
| 109 | +} | |
| 110 | + | |
| 111 | +async function main() { | |
| 112 | + const selected = (await records()).map((record) => { | |
| 113 | + const mode = modeFor(record); | |
| 114 | + const label = safeLabel(record.shortname_line || record.name_line); | |
| 115 | + const picto = record.picto; | |
| 116 | + if (mode === undefined || picto === null || typeof picto !== "object" || typeof picto.url !== "string") return undefined; | |
| 117 | + return { key: `${mode}:${label}`, mode, label, url: picto.url }; | |
| 118 | + }).filter(Boolean).sort((a, b) => a.key.localeCompare(b.key, "en")); | |
| 119 | + | |
| 120 | + const keys = new Set(); | |
| 121 | + for (const item of selected) { | |
| 122 | + if (keys.has(item.key)) fail(`Duplicate public badge key: ${item.key}`); | |
| 123 | + keys.add(item.key); | |
| 124 | + } | |
| 125 | + | |
| 126 | + await mkdir(ASSET_ROOT, { recursive: true }); | |
| 127 | + const staging = `${OUTPUT}.tmp-${process.pid}`; | |
| 128 | + const backup = `${OUTPUT}.bak-${process.pid}`; | |
| 129 | + await rm(staging, { recursive: true, force: true }); | |
| 130 | + await mkdir(staging); | |
| 131 | + const manifest = []; | |
| 132 | + const resolverEntries = []; | |
| 133 | + try { | |
| 134 | + for (const item of selected) { | |
| 135 | + const { body, type } = await boundedFetch(item.url, "image/png,image/svg+xml"); | |
| 136 | + const sha256 = createHash("sha256").update(body).digest("hex"); | |
| 137 | + const extension = validateImage(body, type, sha256); | |
| 138 | + const filename = `${item.mode.toLowerCase()}-${slug(item.label)}.${extension}`; | |
| 139 | + await writeFile(join(staging, filename), body, { flag: "wx" }); | |
| 140 | + manifest.push({ key: item.key, file: filename, mediaType: type, bytes: body.length, sha256, source: item.url }); | |
| 141 | + resolverEntries.push([item.key, filename]); | |
| 142 | + } | |
| 143 | + | |
| 144 | + const retrievedAt = new Date().toISOString(); | |
| 145 | + const attribution = { | |
| 146 | + publisher: "Île-de-France Mobilités", | |
| 147 | + dataset: DATASET, | |
| 148 | + datasetUrl: DATASET_URL, | |
| 149 | + datasetLicense: "ODbL 1.0", | |
| 150 | + datasetLicenseUrl: DATASET_LICENSE_URL, | |
| 151 | + pictogramReuseTermsUrl: ILICO_TERMS_URL, | |
| 152 | + retrievedAt, | |
| 153 | + assets: manifest, | |
| 154 | + }; | |
| 155 | + await writeFile(join(staging, "attribution.json"), `${JSON.stringify(attribution, null, 2)}\n`); | |
| 156 | + const resolver = `// Generated by scripts/sync-line-badges.mjs. Do not edit.\nconst ASSETS = new Map(${JSON.stringify(resolverEntries, null, 2)});\n\nexport function lineBadgeAssetUrl(lineMode, lineLabel) {\n const file = ASSETS.get(\`${"${lineMode}:${lineLabel}"}\`);\n return file === undefined ? undefined : new URL(\`../assets/line-badges/${"${file}"}\`, import.meta.url).href;\n}\n`; | |
| 157 | + const resolverTemp = `${RESOLVER}.tmp-${process.pid}`; | |
| 158 | + await writeFile(resolverTemp, resolver); | |
| 159 | + await rm(backup, { recursive: true, force: true }); | |
| 160 | + try { await rename(OUTPUT, backup); } catch (error) { if (error?.code !== "ENOENT") throw error; } | |
| 161 | + await rename(staging, OUTPUT); | |
| 162 | + await rename(resolverTemp, RESOLVER); | |
| 163 | + await rm(backup, { recursive: true, force: true }); | |
| 164 | + const bytes = manifest.reduce((sum, asset) => sum + asset.bytes, 0); | |
| 165 | + process.stdout.write(`${manifest.length} official badges synchronized (${bytes} bytes)\n`); | |
| 166 | + } catch (error) { | |
| 167 | + await rm(staging, { recursive: true, force: true }); | |
| 168 | + try { await rename(backup, OUTPUT); } catch (restoreError) { if (restoreError?.code !== "ENOENT") throw restoreError; } | |
| 169 | + throw error; | |
| 170 | + } | |
| 171 | +} | |
| 172 | + | |
| 173 | +await main(); | |
| new file mode 100644 | |||
| @@ -0,0 +1,173 @@ | |||
| 1 | +import { createHash } from "node:crypto"; | ||
| 2 | +import { mkdir, rename, rm, writeFile } from "node:fs/promises"; | ||
| 3 | +import { dirname, join, resolve } from "node:path"; | ||
| 4 | +import { fileURLToPath } from "node:url"; | ||
| 5 | + | ||
| 6 | +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); | ||
| 7 | +const ASSET_ROOT = join(ROOT, "packages/config-page/assets"); | ||
| 8 | +const OUTPUT = join(ASSET_ROOT, "line-badges"); | ||
| 9 | +const RESOLVER = join(ROOT, "packages/config-page/src/line-badge-assets.js"); | ||
| 10 | +const ORIGIN = "https://data.iledefrance-mobilites.fr"; | ||
| 11 | +const DATASET = "referentiel-des-lignes"; | ||
| 12 | +const DATASET_URL = `${ORIGIN}/explore/dataset/${DATASET}/`; | ||
| 13 | +const DATASET_LICENSE_URL = "https://opendatacommons.org/licenses/odbl/1-0/"; | ||
| 14 | +const ILICO_TERMS_URL = "https://prim.iledefrance-mobilites.fr/fr/conditions-utilisation"; | ||
| 15 | +const TRUSTED_SVG_SHA256 = new Set([ | ||
| 16 | + "7c79d62ae30950fe638667f63b0ec62bec9bc18a171b1309ca996e670d3d9e25", | ||
| 17 | +]); | ||
| 18 | +const MAX_BODY_BYTES = 2 * 1024 * 1024; | ||
| 19 | +const TIMEOUT_MS = 15_000; | ||
| 20 | + | ||
| 21 | +function fail(message) { | ||
| 22 | + throw new Error(message); | ||
| 23 | +} | ||
| 24 | + | ||
| 25 | +function safeLabel(value) { | ||
| 26 | + if (typeof value !== "string" || value.trim() === "") fail("Line label is missing"); | ||
| 27 | + return value.trim(); | ||
| 28 | +} | ||
| 29 | + | ||
| 30 | +function modeFor(record) { | ||
| 31 | + const mode = String(record.transportmode ?? "").trim().toLowerCase(); | ||
| 32 | + const submode = String(record.transportsubmode ?? "").trim().toLowerCase(); | ||
| 33 | + if (mode === "metro") return "METRO"; | ||
| 34 | + if (mode === "tram") return "TRAM"; | ||
| 35 | + if (mode === "rail" && submode === "local") return "RER"; | ||
| 36 | + if (mode === "rail" && submode === "suburbanrailway") return "TRANSILIEN"; | ||
| 37 | + return undefined; | ||
| 38 | +} | ||
| 39 | + | ||
| 40 | +function slug(value) { | ||
| 41 | + const normalized = value.normalize("NFKD").replace(/[\u0300-\u036f]/gu, "").toLowerCase(); | ||
| 42 | + const result = normalized.replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, ""); | ||
| 43 | + if (!/^[a-z0-9](?:[a-z0-9-]{0,62})$/u.test(result)) fail(`Unsafe generated label: ${value}`); | ||
| 44 | + return result; | ||
| 45 | +} | ||
| 46 | + | ||
| 47 | +async function boundedFetch(url, expectedType) { | ||
| 48 | + const target = new URL(url); | ||
| 49 | + if (target.origin !== ORIGIN) fail(`Unexpected asset origin: ${target.origin}`); | ||
| 50 | + if (!target.pathname.startsWith(`/api/explore/v2.1/catalog/datasets/${DATASET}/`)) { | ||
| 51 | + fail(`Unexpected asset path: ${target.pathname}`); | ||
| 52 | + } | ||
| 53 | + const controller = new AbortController(); | ||
| 54 | + const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); | ||
| 55 | + try { | ||
| 56 | + const response = await fetch(target, { | ||
| 57 | + redirect: "manual", | ||
| 58 | + credentials: "omit", | ||
| 59 | + cache: "no-store", | ||
| 60 | + headers: { accept: expectedType }, | ||
| 61 | + signal: controller.signal, | ||
| 62 | + }); | ||
| 63 | + if (response.status !== 200 || response.body === null) fail(`${target.pathname} returned HTTP ${response.status}`); | ||
| 64 | + const chunks = []; | ||
| 65 | + let length = 0; | ||
| 66 | + for await (const chunk of response.body) { | ||
| 67 | + length += chunk.byteLength; | ||
| 68 | + if (length > MAX_BODY_BYTES) fail(`${target.pathname} exceeds 2 MiB`); | ||
| 69 | + chunks.push(chunk); | ||
| 70 | + } | ||
| 71 | + return { body: Buffer.concat(chunks), type: (response.headers.get("content-type") ?? "").split(";", 1)[0].trim().toLowerCase() }; | ||
| 72 | + } finally { | ||
| 73 | + clearTimeout(timeout); | ||
| 74 | + } | ||
| 75 | +} | ||
| 76 | + | ||
| 77 | +function validateImage(body, type, sha256) { | ||
| 78 | + if (type === "image/png") { | ||
| 79 | + if (body.length < 8 || !body.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) fail("Invalid PNG signature"); | ||
| 80 | + return "png"; | ||
| 81 | + } | ||
| 82 | + if (type !== "image/svg+xml") fail(`Unsupported badge media type: ${type}`); | ||
| 83 | + if (!TRUSTED_SVG_SHA256.has(sha256)) { | ||
| 84 | + fail(`Unreviewed SVG badge ${sha256}; review and pin it before publication`); | ||
| 85 | + } | ||
| 86 | + const source = new TextDecoder("utf-8", { fatal: true }).decode(body); | ||
| 87 | + if (!/<svg\b/iu.test(source) | ||
| 88 | + || /<\s*(?:script|foreignObject|iframe|object|embed)\b/iu.test(source) | ||
| 89 | + || /\son[a-z]+\s*=/iu.test(source) | ||
| 90 | + || /(?:href|src)\s*=\s*["']\s*(?:javascript:|data:|https?:|\/\/)/iu.test(source) | ||
| 91 | + || /url\(\s*["']?\s*(?:javascript:|data:|https?:|\/\/)/iu.test(source)) { | ||
| 92 | + fail("Unsafe pinned SVG badge"); | ||
| 93 | + } | ||
| 94 | + return "svg"; | ||
| 95 | +} | ||
| 96 | + | ||
| 97 | +async function records() { | ||
| 98 | + const query = new URL(`/api/explore/v2.1/catalog/datasets/${DATASET}/records`, ORIGIN); | ||
| 99 | + query.searchParams.set("select", "name_line,shortname_line,transportmode,transportsubmode,status,picto"); | ||
| 100 | + query.searchParams.set("where", "status = 'active' and (transportmode in ('metro','tram') or (transportmode = 'rail' and transportsubmode in ('local','suburbanRailway')))"); | ||
| 101 | + query.searchParams.set("limit", "100"); | ||
| 102 | + const { body, type } = await boundedFetch(query, "application/json"); | ||
| 103 | + if (type !== "application/json") fail(`Unexpected records media type: ${type}`); | ||
| 104 | + const payload = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body)); | ||
| 105 | + if (!Number.isSafeInteger(payload.total_count) || !Array.isArray(payload.results) || payload.results.length !== payload.total_count) { | ||
| 106 | + fail("Incomplete line badge result set"); | ||
| 107 | + } | ||
| 108 | + return payload.results; | ||
| 109 | +} | ||
| 110 | + | ||
| 111 | +async function main() { | ||
| 112 | + const selected = (await records()).map((record) => { | ||
| 113 | + const mode = modeFor(record); | ||
| 114 | + const label = safeLabel(record.shortname_line || record.name_line); | ||
| 115 | + const picto = record.picto; | ||
| 116 | + if (mode === undefined || picto === null || typeof picto !== "object" || typeof picto.url !== "string") return undefined; | ||
| 117 | + return { key: `${mode}:${label}`, mode, label, url: picto.url }; | ||
| 118 | + }).filter(Boolean).sort((a, b) => a.key.localeCompare(b.key, "en")); | ||
| 119 | + | ||
| 120 | + const keys = new Set(); | ||
| 121 | + for (const item of selected) { | ||
| 122 | + if (keys.has(item.key)) fail(`Duplicate public badge key: ${item.key}`); | ||
| 123 | + keys.add(item.key); | ||
| 124 | + } | ||
| 125 | + | ||
| 126 | + await mkdir(ASSET_ROOT, { recursive: true }); | ||
| 127 | + const staging = `${OUTPUT}.tmp-${process.pid}`; | ||
| 128 | + const backup = `${OUTPUT}.bak-${process.pid}`; | ||
| 129 | + await rm(staging, { recursive: true, force: true }); | ||
| 130 | + await mkdir(staging); | ||
| 131 | + const manifest = []; | ||
| 132 | + const resolverEntries = []; | ||
| 133 | + try { | ||
| 134 | + for (const item of selected) { | ||
| 135 | + const { body, type } = await boundedFetch(item.url, "image/png,image/svg+xml"); | ||
| 136 | + const sha256 = createHash("sha256").update(body).digest("hex"); | ||
| 137 | + const extension = validateImage(body, type, sha256); | ||
| 138 | + const filename = `${item.mode.toLowerCase()}-${slug(item.label)}.${extension}`; | ||
| 139 | + await writeFile(join(staging, filename), body, { flag: "wx" }); | ||
| 140 | + manifest.push({ key: item.key, file: filename, mediaType: type, bytes: body.length, sha256, source: item.url }); | ||
| 141 | + resolverEntries.push([item.key, filename]); | ||
| 142 | + } | ||
| 143 | + | ||
| 144 | + const retrievedAt = new Date().toISOString(); | ||
| 145 | + const attribution = { | ||
| 146 | + publisher: "Île-de-France Mobilités", | ||
| 147 | + dataset: DATASET, | ||
| 148 | + datasetUrl: DATASET_URL, | ||
| 149 | + datasetLicense: "ODbL 1.0", | ||
| 150 | + datasetLicenseUrl: DATASET_LICENSE_URL, | ||
| 151 | + pictogramReuseTermsUrl: ILICO_TERMS_URL, | ||
| 152 | + retrievedAt, | ||
| 153 | + assets: manifest, | ||
| 154 | + }; | ||
| 155 | + await writeFile(join(staging, "attribution.json"), `${JSON.stringify(attribution, null, 2)}\n`); | ||
| 156 | + const resolver = `// Generated by scripts/sync-line-badges.mjs. Do not edit.\nconst ASSETS = new Map(${JSON.stringify(resolverEntries, null, 2)});\n\nexport function lineBadgeAssetUrl(lineMode, lineLabel) {\n const file = ASSETS.get(\`${"${lineMode}:${lineLabel}"}\`);\n return file === undefined ? undefined : new URL(\`../assets/line-badges/${"${file}"}\`, import.meta.url).href;\n}\n`; | ||
| 157 | + const resolverTemp = `${RESOLVER}.tmp-${process.pid}`; | ||
| 158 | + await writeFile(resolverTemp, resolver); | ||
| 159 | + await rm(backup, { recursive: true, force: true }); | ||
| 160 | + try { await rename(OUTPUT, backup); } catch (error) { if (error?.code !== "ENOENT") throw error; } | ||
| 161 | + await rename(staging, OUTPUT); | ||
| 162 | + await rename(resolverTemp, RESOLVER); | ||
| 163 | + await rm(backup, { recursive: true, force: true }); | ||
| 164 | + const bytes = manifest.reduce((sum, asset) => sum + asset.bytes, 0); | ||
| 165 | + process.stdout.write(`${manifest.length} official badges synchronized (${bytes} bytes)\n`); | ||
| 166 | + } catch (error) { | ||
| 167 | + await rm(staging, { recursive: true, force: true }); | ||
| 168 | + try { await rename(backup, OUTPUT); } catch (restoreError) { if (restoreError?.code !== "ENOENT") throw restoreError; } | ||
| 169 | + throw error; | ||
| 170 | + } | ||
| 171 | +} | ||
| 172 | + | ||
| 173 | +await main(); | ||