diff --git a/feeds/de.json b/feeds/de.json index 807726942..d91b7b00d 100644 --- a/feeds/de.json +++ b/feeds/de.json @@ -85,6 +85,7 @@ "http-options": { "fetch-interval-days": 1 }, + "script": "de-VBN.lua", "license": { "spdx-identifier": "CC-BY-SA-4.0" } diff --git a/scripts/ch-opentransport-colors.lua b/scripts/ch-opentransport-colors.lua new file mode 100644 index 000000000..981fc6266 --- /dev/null +++ b/scripts/ch-opentransport-colors.lua @@ -0,0 +1,251 @@ +-- autogenerated by generate_colors.py +-- SPDX-FileCopyrightText: contributors of https://github.com/Traewelling/line-colors +-- SPDX-License-Identifier: CC0-1.0 + +local colors = { + ["Appenzeller Bahnen"] = { + ["S15"] = { color = 0x845534, text_color = 0xffffff }, + ["S20"] = { color = 0xb6522b, text_color = 0xffffff }, + ["S21"] = { color = 0x00908c, text_color = 0xffffff }, + ["S22"] = { color = 0xb80e80, text_color = 0xffffff }, + ["S23"] = { color = 0xc99115, text_color = 0xffffff }, + ["S24"] = { color = 0x3aaa35, text_color = 0xffffff }, + ["S25"] = { color = 0x72a8db, text_color = 0xffffff }, + ["S26"] = { color = 0xb85f5c, text_color = 0xffffff }, + }, + ["Autobus AG Liestal"] = { + ["70"] = { color = 0xffdd00, text_color = 0x000000 }, + ["71"] = { color = 0x95c11f, text_color = 0xffffff }, + ["72"] = { color = 0x5a6f85, text_color = 0xffffff }, + ["74"] = { color = 0xa36009, text_color = 0xffffff }, + ["75"] = { color = 0x7b77b6, text_color = 0xffffff }, + ["76"] = { color = 0x00a7a6, text_color = 0xffffff }, + ["78"] = { color = 0xf39200, text_color = 0x000000 }, + ["80"] = { color = 0xe4032e, text_color = 0xffffff }, + ["81"] = { color = 0xf088b6, text_color = 0x000000 }, + ["82"] = { color = 0x13a538, text_color = 0xffffff }, + ["83"] = { color = 0x0094d2, text_color = 0xffffff }, + }, + ["Automobildienst Appenzeller Bahnen"] = { + ["841"] = { color = 0x000000, text_color = 0xffffff }, + }, + ["BLS AG (bls)"] = { + ["S1"] = { color = 0x50b447, text_color = 0xffffff }, + ["S2"] = { color = 0x1cb1e6, text_color = 0xffffff }, + ["S3"] = { color = 0x8868b3, text_color = 0xffffff }, + ["S31"] = { color = 0xb0aa38, text_color = 0xffffff }, + ["S4"] = { color = 0x53c4af, text_color = 0xffffff }, + ["S44"] = { color = 0x646026, text_color = 0xffffff }, + ["S5"] = { color = 0x881c3f, text_color = 0xffffff }, + ["S51"] = { color = 0x9cc843, text_color = 0xffffff }, + ["S52"] = { color = 0xf7cf39, text_color = 0xffffff }, + ["S6"] = { color = 0x0165b6, text_color = 0xffffff }, + ["S7"] = { color = 0x73c0e8, text_color = 0xffffff }, + ["S77"] = { color = 0x7a84c4, text_color = 0xffffff }, + }, + ["Baselland Transport"] = { + ["10"] = { color = 0xfec800, text_color = 0x000000 }, + ["105"] = { color = 0xbec2c3, text_color = 0x000000 }, + ["106"] = { color = 0xbec2c3, text_color = 0x000000 }, + ["107"] = { color = 0xbec2c3, text_color = 0x000000 }, + ["108"] = { color = 0xbec2c3, text_color = 0x000000 }, + ["109"] = { color = 0xbec2c3, text_color = 0x000000 }, + ["11"] = { color = 0xe7000e, text_color = 0xffffff }, + ["110"] = { color = 0xbec2c3, text_color = 0x000000 }, + ["17"] = { color = 0x00a1e5, text_color = 0xffffff }, + ["19"] = { color = 0x7c217d, text_color = 0xffffff }, + ["37"] = { color = 0xcfd0d0, text_color = 0x000000 }, + ["47"] = { color = 0xadafaf, text_color = 0x000000 }, + ["58"] = { color = 0xadafaf, text_color = 0x000000 }, + ["59"] = { color = 0xadafaf, text_color = 0x000000 }, + ["60"] = { color = 0xcfd0d0, text_color = 0x000000 }, + ["61"] = { color = 0xadafaf, text_color = 0x000000 }, + ["62"] = { color = 0xadafaf, text_color = 0x000000 }, + ["64"] = { color = 0xcfd0d0, text_color = 0x000000 }, + ["65"] = { color = 0xadafaf, text_color = 0x000000 }, + ["66"] = { color = 0xadafaf, text_color = 0x000000 }, + ["92"] = { color = 0xbec2c3, text_color = 0x000000 }, + ["93"] = { color = 0xbec2c3, text_color = 0x000000 }, + ["E11"] = { color = 0xe7000e, text_color = 0xffffff }, + }, + ["Basler Verkehrsbetriebe"] = { + ["1"] = { color = 0x7c4425, text_color = 0xffffff }, + ["14"] = { color = 0xf17900, text_color = 0xffffff }, + ["15"] = { color = 0x00943d, text_color = 0xffffff }, + ["16"] = { color = 0xa6c700, text_color = 0x000000 }, + ["2"] = { color = 0xa47e42, text_color = 0xffffff }, + ["21"] = { color = 0x00a192, text_color = 0xffffff }, + ["3"] = { color = 0x364093, text_color = 0xffffff }, + ["30"] = { color = 0xadafaf, text_color = 0x000000 }, + ["31"] = { color = 0xadafaf, text_color = 0x000000 }, + ["32"] = { color = 0xadafaf, text_color = 0x000000 }, + ["33"] = { color = 0xcfd0d0, text_color = 0x000000 }, + ["34"] = { color = 0xadafaf, text_color = 0x000000 }, + ["35"] = { color = 0xcfd0d0, text_color = 0x000000 }, + ["36"] = { color = 0xadafaf, text_color = 0x000000 }, + ["38"] = { color = 0xcfd0d0, text_color = 0x000000 }, + ["42"] = { color = 0xcfd0d0, text_color = 0x000000 }, + ["45"] = { color = 0xadafaf, text_color = 0x000000 }, + ["46"] = { color = 0xcfd0d0, text_color = 0x000000 }, + ["47"] = { color = 0xcfd0d0, text_color = 0x000000 }, + ["48"] = { color = 0xadafaf, text_color = 0x000000 }, + ["50"] = { color = 0xcfd0d0, text_color = 0x000000 }, + ["6"] = { color = 0x0068b2, text_color = 0xffffff }, + ["8"] = { color = 0xed6aa4, text_color = 0xffffff }, + }, + ["Funiculaire Neuchâtel-Université - Neuchâtel-Gare"] = { + ["110"] = { color = 0x7dcdbe, text_color = 0xffffff }, + }, + ["PostAuto AG"] = { + ["100"] = { color = 0xffcc00, text_color = 0x000000 }, + ["101"] = { color = 0xffcc00, text_color = 0x000000 }, + ["102"] = { color = 0xffcc00, text_color = 0x000000 }, + ["103"] = { color = 0xffcc00, text_color = 0x000000 }, + ["104"] = { color = 0xffcc00, text_color = 0x000000 }, + ["111"] = { color = 0xffcc00, text_color = 0x000000 }, + ["112"] = { color = 0xffcc00, text_color = 0x000000 }, + ["113"] = { color = 0xffcc00, text_color = 0x000000 }, + ["114"] = { color = 0xffcc00, text_color = 0x000000 }, + ["115"] = { color = 0xffcc00, text_color = 0x000000 }, + ["116"] = { color = 0xffcc00, text_color = 0x000000 }, + ["117"] = { color = 0xffcc00, text_color = 0x000000 }, + ["118"] = { color = 0xffcc00, text_color = 0x000000 }, + ["119"] = { color = 0xffcc00, text_color = 0x000000 }, + ["133"] = { color = 0xffcc00, text_color = 0x000000 }, + ["134"] = { color = 0xffcc00, text_color = 0x000000 }, + ["135"] = { color = 0xffcc00, text_color = 0x000000 }, + ["136"] = { color = 0xffcc00, text_color = 0x000000 }, + ["137"] = { color = 0xffcc00, text_color = 0x000000 }, + ["139"] = { color = 0xffcc00, text_color = 0x000000 }, + ["141"] = { color = 0xffcc00, text_color = 0x000000 }, + ["142"] = { color = 0xffcc00, text_color = 0x000000 }, + ["143"] = { color = 0xffcc00, text_color = 0x000000 }, + ["144"] = { color = 0xffcc00, text_color = 0x000000 }, + ["149"] = { color = 0xffcc00, text_color = 0x000000 }, + ["67"] = { color = 0xffcc00, text_color = 0x000000 }, + ["68"] = { color = 0xffcc00, text_color = 0x000000 }, + ["69"] = { color = 0xffcc00, text_color = 0x000000 }, + ["73"] = { color = 0xffcc00, text_color = 0x000000 }, + ["77"] = { color = 0xffcc00, text_color = 0x000000 }, + ["84"] = { color = 0xffcc00, text_color = 0x000000 }, + ["85"] = { color = 0xffcc00, text_color = 0x000000 }, + ["86"] = { color = 0xffcc00, text_color = 0x000000 }, + ["88"] = { color = 0xffcc00, text_color = 0x000000 }, + ["89"] = { color = 0xffcc00, text_color = 0x000000 }, + ["90"] = { color = 0xffcc00, text_color = 0x000000 }, + ["94"] = { color = 0xffcc00, text_color = 0x000000 }, + ["99"] = { color = 0xffcc00, text_color = 0x000000 }, + }, + ["Rhätische Bahn"] = { + ["IR38"] = { color = 0xea8c00, text_color = 0xffffff }, + ["R15"] = { color = 0xc80205, text_color = 0xffffff }, + ["R16"] = { color = 0x438bc3, text_color = 0xffffff }, + ["R19"] = { color = 0xa2bb04, text_color = 0xffffff }, + ["RE1"] = { color = 0x25a9aa, text_color = 0xffffff }, + ["RE13"] = { color = 0x25a9aa, text_color = 0xffffff }, + ["RE2"] = { color = 0xa97400, text_color = 0xffffff }, + ["RE24"] = { color = 0xa97400, text_color = 0xffffff }, + ["RE3"] = { color = 0x25a9aa, text_color = 0xffffff }, + ["RE4"] = { color = 0xa97400, text_color = 0xffffff }, + ["RE6"] = { color = 0x00549d, text_color = 0xffffff }, + ["RE7"] = { color = 0x941680, text_color = 0xffffff }, + ["RE8"] = { color = 0x683419, text_color = 0xffffff }, + ["RE9"] = { color = 0x008a39, text_color = 0xffffff }, + ["S1"] = { color = 0xd40074, text_color = 0xffffff }, + ["S2"] = { color = 0xe8ac00, text_color = 0xffffff }, + }, + ["Schweizerische Bundesbahnen SBB"] = { + ["S1"] = { color = 0x1bb04d, text_color = 0xffffff }, + ["S2"] = { color = 0xf93f27, text_color = 0xffffff }, + ["S3"] = { color = 0xfe7d25, text_color = 0xffffff }, + ["S9"] = { color = 0xa3cf45, text_color = 0xffffff }, + ["S99"] = { color = 0x95b15e, text_color = 0xffffff }, + }, + ["Service d'automobiles TRN (vr)"] = { + ["422"] = { color = 0xd682b5, text_color = 0xffffff }, + ["423"] = { color = 0x00854c, text_color = 0xffffff }, + ["425"] = { color = 0xe6007e, text_color = 0xffffff }, + ["426"] = { color = 0x4696d2, text_color = 0xffffff }, + ["590"] = { color = 0xe30613, text_color = 0xffffff }, + ["591"] = { color = 0x00854c, text_color = 0xffffff }, + }, + ["Transports Publics Neuchâtelois SA"] = { + ["111"] = { color = 0xd7e141, text_color = 0x000000 }, + ["112"] = { color = 0x009155, text_color = 0xffffff }, + }, + ["Transports Publics Neuchâtelois SA (cmn)"] = { + ["R15"] = { color = 0x7dcdbe, text_color = 0x000000 }, + ["R22"] = { color = 0x78af41, text_color = 0xffffff }, + ["R24"] = { color = 0xd282b4, text_color = 0xffffff }, + }, + ["Transports Publics Neuchâtelois SA (rvt)"] = { + ["R21"] = { color = 0x912891, text_color = 0xffffff }, + }, + ["Transports Publics Neuchâtelois SA (tc)"] = { + ["301"] = { color = 0x312783, text_color = 0xffffff }, + ["302"] = { color = 0xe30613, text_color = 0xffffff }, + ["303"] = { color = 0xdedd26, text_color = 0x000000 }, + ["304"] = { color = 0x00854c, text_color = 0xffffff }, + ["310"] = { color = 0xd682b5, text_color = 0xffffff }, + ["311"] = { color = 0xef7d00, text_color = 0xffffff }, + ["312"] = { color = 0x951b81, text_color = 0xffffff }, + ["341"] = { color = 0x423e8d, text_color = 0xffffff }, + ["342"] = { color = 0xe30613, text_color = 0xffffff }, + ["343"] = { color = 0x912891, text_color = 0xffffff }, + ["352"] = { color = 0xe30613, text_color = 0xffffff }, + ["353"] = { color = 0xdedd26, text_color = 0x000000 }, + ["354"] = { color = 0x00854c, text_color = 0xffffff }, + ["360"] = { color = 0x4a96d2, text_color = 0xffffff }, + ["361"] = { color = 0x8acbc1, text_color = 0x000000 }, + ["370"] = { color = 0xe6007e, text_color = 0xffffff }, + }, + ["Transports Publics Neuchâtelois SA (tn)"] = { + ["101"] = { color = 0x312783, text_color = 0xffffff }, + ["102"] = { color = 0x4a96d2, text_color = 0xffffff }, + ["106"] = { color = 0xd682b5, text_color = 0xffffff }, + ["107"] = { color = 0xe30613, text_color = 0xffffff }, + ["108"] = { color = 0x00854c, text_color = 0xffffff }, + ["109"] = { color = 0x951b81, text_color = 0xffffff }, + ["120"] = { color = 0xef7d00, text_color = 0xffffff }, + ["121"] = { color = 0x95c11f, text_color = 0xffffff }, + ["122"] = { color = 0x8acbc1, text_color = 0x000000 }, + ["421"] = { color = 0xdedd26, text_color = 0x000000 }, + }, + ["Verkehrsbetrieb LIECHTENSTEINmobil"] = { + ["11"] = { color = 0xfdc300, text_color = 0x000000 }, + ["12"] = { color = 0x283583, text_color = 0xffffff }, + ["12E"] = { color = 0x6e6ba9, text_color = 0xffffff }, + ["13"] = { color = 0x3fa535, text_color = 0xffffff }, + ["13E"] = { color = 0x3fa535, text_color = 0xffffff }, + ["14"] = { color = 0xcd1719, text_color = 0xffffff }, + ["21"] = { color = 0x007a8c, text_color = 0xffffff }, + ["22"] = { color = 0x6fa8b3, text_color = 0xffffff }, + ["24"] = { color = 0x0095db, text_color = 0xffffff }, + ["26"] = { color = 0xf39200, text_color = 0xffffff }, + ["31"] = { color = 0xed6a5b, text_color = 0xffffff }, + ["32"] = { color = 0xa41753, text_color = 0xffffff }, + ["33"] = { color = 0x944127, text_color = 0xffffff }, + ["35"] = { color = 0xc7b649, text_color = 0xffffff }, + ["36E"] = { color = 0x9e1981, text_color = 0xffffff }, + ["37"] = { color = 0x4a5da7, text_color = 0xffffff }, + ["38"] = { color = 0x4d3078, text_color = 0xffffff }, + ["40"] = { color = 0x7b6a58, text_color = 0xffffff }, + ["44"] = { color = 0xbdbcbc, text_color = 0xffffff }, + ["46"] = { color = 0x35434f, text_color = 0xffffff }, + ["48"] = { color = 0x8b696b, text_color = 0xffffff }, + ["B"] = { color = 0xe64d33, text_color = 0xffffff }, + ["C"] = { color = 0x666666, text_color = 0xffffff }, + ["N1"] = { color = 0x6f6352, text_color = 0xffffff }, + ["N2"] = { color = 0xe64d33, text_color = 0xffffff }, + ["N3"] = { color = 0x80b333, text_color = 0xffffff }, + ["N4"] = { color = 0x666666, text_color = 0xffffff }, + }, + ["Zentralbahn"] = { + ["S4"] = { color = 0xaa2a3f, text_color = 0xffffff }, + ["S41"] = { color = 0x055d80, text_color = 0xffffff }, + ["S44"] = { color = 0xd35d68, text_color = 0xffffff }, + ["S5"] = { color = 0xfb47a3, text_color = 0xffffff }, + ["S55"] = { color = 0xfc87bf, text_color = 0xffffff }, + }, +} +return colors diff --git a/scripts/de-VBN-colors.lua b/scripts/de-VBN-colors.lua new file mode 100644 index 000000000..e64aa14d8 --- /dev/null +++ b/scripts/de-VBN-colors.lua @@ -0,0 +1,18 @@ +-- autogenerated by generate_colors.py +-- SPDX-FileCopyrightText: contributors of https://github.com/Traewelling/line-colors +-- SPDX-License-Identifier: CC0-1.0 + +local colors = { + ["Braunschweiger Verkehrs-GmbH"] = { + ["1"] = { color = 0xe2000a, text_color = 0xffffff }, + ["10"] = { color = 0xe2000a, text_color = 0xffffff }, + ["1E"] = { color = 0xe2000a, text_color = 0xffffff }, + ["2"] = { color = 0x93c019, text_color = 0xffffff }, + ["2E"] = { color = 0x93c019, text_color = 0xffffff }, + ["3"] = { color = 0x006430, text_color = 0xffffff }, + ["3E"] = { color = 0x006430, text_color = 0xffffff }, + ["4"] = { color = 0x009ee2, text_color = 0xffffff }, + ["411"] = { color = 0xb1a85c, text_color = 0xffffff }, + }, +} +return colors diff --git a/scripts/de-VBN.lua b/scripts/de-VBN.lua new file mode 100644 index 000000000..444948b75 --- /dev/null +++ b/scripts/de-VBN.lua @@ -0,0 +1,73 @@ +-- SPDX-FileCopyrightText: Felix Gündling +-- SPDX-License-Identifier: AGPL-3.0-or-later + +local colors = require 'scripts.de-VBN-colors' + +function is_number(str) + return not (str == "" or str:find("%D")) +end + +function remove_leading_zeros(str) + return string.format("%d", tonumber(str)) +end + +-- routes from international operators that need their display names fixed +-- { source route type, route name } + +-- i reckon, the intl_name_map thing could be deleted, couldn't see that in VBN data +--local intl_name_map = { +-- { 102, "EC" }, +-- { 102, "IC" }, +-- { 101, "ICE" }, +-- { 102, "NJ" }, +-- { 101, "RJ" }, +-- { 102, "RJX" }, +-- { 101, "THA" }, +--} + +--not sure about that but i don't think leaving it uncommented breaks anything + +function process_trip(trip) + if trip:get_route():get_agency():get_name() == 'DB Fernverkehr AG' and is_number(trip:get_short_name()) then + -- Format trip_short_name=`00123` to train number 123 + if trip:get_route():get_route_type() == 101 then + trip:set_short_name('ICE ' .. remove_leading_zeros(trip:get_short_name())) + trip:set_display_name(trip:get_short_name()) + elseif trip:get_route():get_route_type() == 102 then + trip:set_short_name('IC ' .. remove_leading_zeros(trip:get_short_name())) + trip:set_display_name(trip:get_short_name()) + end + else + -- international operators without line names in the route short name + for _,m in ipairs(intl_name_map) do + if trip:get_route():get_route_type() == m[1] and trip:get_route():get_short_name() == m[2] then + trip:set_short_name(trip:get_route():get_short_name() .. ' ' .. remove_leading_zeros(trip:get_short_name())) + trip:set_display_name(trip:get_short_name()) + end + end + end + + if trip:get_route():get_route_type() == 106 and is_number(trip:get_short_name()) then + trip:set_display_name(trip:get_route():get_short_name() .. ' (' .. remove_leading_zeros(trip:get_short_name()) .. ')') + end +end + +function process_agency(agency) + if agency:get_url() == "https://www.delfi.de" then + agency:set_url("") + end +end + +function process_route(route) + local agency_name = route:get_agency():get_name() + local route_name = route:get_short_name() + -- remove spaces from route name for matching + route_name = route_name:gsub("%s+", "") + local original_route_color = route:get_color() + local original_route_text_color = route:get_text_color() + if (original_route_color == 0 or original_route_text_color == 0) and colors[agency_name] and colors[agency_name][route_name] then + local colors = colors[agency_name][route_name] + route:set_color(colors.color) + route:set_text_color(colors.text_color) + end +end diff --git a/src/generate_all_DACH_colours.py b/src/generate_all_DACH_colours.py new file mode 100644 index 000000000..1d4d7d68c --- /dev/null +++ b/src/generate_all_DACH_colours.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Levin Baumann +# +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# generate_colors.py - Generate Lua table of line colors from Traewelling line-colors CSV + +import sys + +# Import already written scripts as modules +import generate_colors +import generate_colors_CH +import generate_colors_VBN + +# Telling Python to do these sequentially. +def main(): + generate_colors.main() + generate_colors_CH.main() + generate_colors_VBN.main() + +# Don't know what that does, but it makes it work. +if __name__ == "__main__": + main() diff --git a/src/generate_colors_CH.py b/src/generate_colors_CH.py new file mode 100644 index 000000000..4198ed919 --- /dev/null +++ b/src/generate_colors_CH.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Levin Baumann +# +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# generate_colors.py - Generate Lua table of line colors from Traewelling line-colors CSV +# adapted for VBN GTFS feed by luna-cant-code (GitHub) + + +import csv +import logging +import os +import re +import sys +import requests +from typing import Dict, Any + +URL = "https://raw.githubusercontent.com/luna-cant-code/line-colors/refs/heads/feed-transition/line-colors-CH.csv" +OUTPUT = "scripts/ch-opentransport-colors.lua" + +HEX_RE = re.compile(r"^[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$") + + +def fetch_csv(url: str) -> str: + logging.info("Fetching CSV from %s", url) + try: + resp = requests.get(url, timeout=15) + resp.raise_for_status() + return resp.text + except requests.RequestException: + logging.exception("Error while fetching CSV from %s", url) + raise + + +def _find_key(row_keys, desired: str): + """Return the actual key in row_keys whose stripped form equals desired. + + This makes the parser tolerant to CSVs that have leading/trailing spaces + in header names (e.g. ' delfiAgencyName'). + """ + for k in row_keys: + if k is not None and k.strip() == desired: + return k + return None + + +def normalize_hex(s: str) -> str: + if s is None: + return "" + s = s.strip().lstrip('#') + if s == "": + return "" + if len(s) == 3 and HEX_RE.match(s): + # expand short form like 'f0c' -> 'ff00cc' + return ''.join([c * 2 for c in s]).lower() + if len(s) == 6 and HEX_RE.match(s): + return s.lower() + # invalid value + logging.warning("Invalid hex color encountered: %r", s) + return "" + + +def parse_colors(csv_text: str) -> Dict[str, Dict[str, Dict[str, Any]]]: + lines = csv_text.splitlines() + reader = csv.DictReader(lines, skipinitialspace=False) + + # Identify header keys we need (tolerate leading/trailing whitespace) + header = reader.fieldnames or [] +## key_agency = _find_key(header, 'delfiAgencyName') or _find_key(header, ' delfiAgencyName') // Left for transition purposes + key_agency = _find_key(header, 'GTFSAgencyName') or _find_key(header, ' GTFSAgencyName') + key_line = _find_key(header, 'lineName') + key_bg = _find_key(header, 'backgroundColor') + key_text = _find_key(header, 'textColor') + key_border = _find_key(header, 'borderColor') + + if not (key_agency and key_line and key_bg and key_text): + logging.error("CSV is missing expected headers. Found: %r", header) + raise ValueError("CSV missing required headers") + + colors: Dict[str, Dict[str, Dict[str, Any]]] = {} + + for row in reader: + agency = (row.get(key_agency) or "").strip() + line_name = (row.get(key_line) or "").strip() + line_name = line_name.replace(" ", "") + background = normalize_hex(row.get(key_bg) or "") + text = normalize_hex(row.get(key_text) or "") + border = normalize_hex(row.get(key_border) or "") + + # Skip rows without a line name or delfi agency + if line_name == "" or agency == "": + continue + + # If background is white or missing, use border color if available, + # since motis only supports background and text colors + if border and (background == "" or background.lower() == "ffffff"): + background = border + text = "ffffff" + + # If any color is still missing, skip this entry + if background == "" or text == "": + logging.warning("Skipping entry for agency %r, line %r due to missing colors", agency, line_name) + continue + + colors.setdefault(agency, {})[line_name] = { + 'color': background, + 'textColor': text + } + + return colors + + +def _escape_lua_string(s: str) -> str: + return s.replace('\\', '\\\\').replace('"', '\\"') + + +def write_lua_table(colors: Dict[str, Dict[str, Dict[str, Any]]], out_path: str) -> None: + logging.info("Writing Lua table to %s", out_path) + dirpath = os.path.dirname(out_path) + if dirpath and not os.path.exists(dirpath): + os.makedirs(dirpath) + + with open(out_path, 'w', encoding='utf-8') as f: + f.write('-- autogenerated by generate_colors.py\n') + f.write('-- SPDX-FileCopyrightText: contributors of https://github.com/Traewelling/line-colors\n') + f.write('-- SPDX-License-Identifier: CC0-1.0\n\n') + f.write('local colors = {\n') + + # Sort agencies for deterministic output + for agency in sorted(colors.keys()): + safe_agency = _escape_lua_string(agency) + f.write(f' ["{safe_agency}"] = {{\n') + for line_name in sorted(colors[agency].keys()): + safe_line = _escape_lua_string(line_name) + info = colors[agency][line_name] + # convert hex string to 0x... numeric literal + bg = info.get('color', '000000') + text = info.get('textColor', '000000') + # ensure 6-digit hex + if len(bg) == 3: + bg = ''.join([c * 2 for c in bg]) + if len(text) == 3: + text = ''.join([c * 2 for c in text]) + + f.write(f' ["{safe_line}"] = {{ color = 0x{bg}, text_color = 0x{text} }},\n') + f.write(' },\n') + + f.write('}\n') + f.write('return colors\n') + + logging.info("Wrote %d agencies to %s", len(colors), out_path) + + +def main(): + logging.basicConfig(level=logging.WARNING, + format='%(levelname)s: %(message)s') + + try: + csv_text = fetch_csv(URL) + colors = parse_colors(csv_text) + write_lua_table(colors, OUTPUT) + except Exception as e: + logging.exception("Failed to generate colors: %s", e) + return 2 + + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/src/generate_colors_VBN.py b/src/generate_colors_VBN.py new file mode 100644 index 000000000..83e6dee81 --- /dev/null +++ b/src/generate_colors_VBN.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Levin Baumann +# +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# generate_colors.py - Generate Lua table of line colors from Traewelling line-colors CSV +# adapted for VBN GTFS feed by luna-cant-code (GitHub) + + +import csv +import logging +import os +import re +import sys +import requests +from typing import Dict, Any + +URL = "https://raw.githubusercontent.com/Traewelling/line-colors/refs/heads/main/line-colors-VBN.csv" +OUTPUT = "scripts/de-VBN-colors.lua" + +HEX_RE = re.compile(r"^[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$") + +# EVERYTHING UNDER THIS COMMENT IS JUST STRAIGHT UP COPIED FROM THE REGULAR generate_colors.py FILE (don't know if this works tbh) + +def fetch_csv(url: str) -> str: + logging.info("Fetching CSV from %s", url) + try: + resp = requests.get(url, timeout=15) + resp.raise_for_status() + return resp.text + except requests.RequestException: + logging.exception("Error while fetching CSV from %s", url) + raise + + +def _find_key(row_keys, desired: str): + """Return the actual key in row_keys whose stripped form equals desired. + + This makes the parser tolerant to CSVs that have leading/trailing spaces + in header names (e.g. ' delfiAgencyName'). + """ + for k in row_keys: + if k is not None and k.strip() == desired: + return k + return None + + +def normalize_hex(s: str) -> str: + if s is None: + return "" + s = s.strip().lstrip('#') + if s == "": + return "" + if len(s) == 3 and HEX_RE.match(s): + # expand short form like 'f0c' -> 'ff00cc' + return ''.join([c * 2 for c in s]).lower() + if len(s) == 6 and HEX_RE.match(s): + return s.lower() + # invalid value + logging.warning("Invalid hex color encountered: %r", s) + return "" + + +def parse_colors(csv_text: str) -> Dict[str, Dict[str, Dict[str, Any]]]: + lines = csv_text.splitlines() + reader = csv.DictReader(lines, skipinitialspace=False) + + # Identify header keys we need (tolerate leading/trailing whitespace) + header = reader.fieldnames or [] +## key_agency = _find_key(header, 'delfiAgencyName') or _find_key(header, ' delfiAgencyName') // left for transition purposes + key_agency = _find_key(header, 'GTFSAgencyName') or _find_key(header, ' GTFSAgencyName') + key_line = _find_key(header, 'lineName') + key_bg = _find_key(header, 'backgroundColor') + key_text = _find_key(header, 'textColor') + key_border = _find_key(header, 'borderColor') + + if not (key_agency and key_line and key_bg and key_text): + logging.error("CSV is missing expected headers. Found: %r", header) + raise ValueError("CSV missing required headers") + + colors: Dict[str, Dict[str, Dict[str, Any]]] = {} + + for row in reader: + agency = (row.get(key_agency) or "").strip() + line_name = (row.get(key_line) or "").strip() + line_name = line_name.replace(" ", "") + background = normalize_hex(row.get(key_bg) or "") + text = normalize_hex(row.get(key_text) or "") + border = normalize_hex(row.get(key_border) or "") + + # Skip rows without a line name or delfi agency + if line_name == "" or agency == "": + continue + + # If background is white or missing, use border color if available, + # since motis only supports background and text colors + if border and (background == "" or background.lower() == "ffffff"): + background = border + text = "ffffff" + + # If any color is still missing, skip this entry + if background == "" or text == "": + logging.warning("Skipping entry for agency %r, line %r due to missing colors", agency, line_name) + continue + + colors.setdefault(agency, {})[line_name] = { + 'color': background, + 'textColor': text + } + + return colors + + +def _escape_lua_string(s: str) -> str: + return s.replace('\\', '\\\\').replace('"', '\\"') + + +def write_lua_table(colors: Dict[str, Dict[str, Dict[str, Any]]], out_path: str) -> None: + logging.info("Writing Lua table to %s", out_path) + dirpath = os.path.dirname(out_path) + if dirpath and not os.path.exists(dirpath): + os.makedirs(dirpath) + + with open(out_path, 'w', encoding='utf-8') as f: + f.write('-- autogenerated by generate_colors.py\n') + f.write('-- SPDX-FileCopyrightText: contributors of https://github.com/Traewelling/line-colors\n') + f.write('-- SPDX-License-Identifier: CC0-1.0\n\n') + f.write('local colors = {\n') + + # Sort agencies for deterministic output + for agency in sorted(colors.keys()): + safe_agency = _escape_lua_string(agency) + f.write(f' ["{safe_agency}"] = {{\n') + for line_name in sorted(colors[agency].keys()): + safe_line = _escape_lua_string(line_name) + info = colors[agency][line_name] + # convert hex string to 0x... numeric literal + bg = info.get('color', '000000') + text = info.get('textColor', '000000') + # ensure 6-digit hex + if len(bg) == 3: + bg = ''.join([c * 2 for c in bg]) + if len(text) == 3: + text = ''.join([c * 2 for c in text]) + + f.write(f' ["{safe_line}"] = {{ color = 0x{bg}, text_color = 0x{text} }},\n') + f.write(' },\n') + + f.write('}\n') + f.write('return colors\n') + + logging.info("Wrote %d agencies to %s", len(colors), out_path) + + +def main(): + logging.basicConfig(level=logging.WARNING, + format='%(levelname)s: %(message)s') + + try: + csv_text = fetch_csv(URL) + colors = parse_colors(csv_text) + write_lua_table(colors, OUTPUT) + except Exception as e: + logging.exception("Failed to generate colors: %s", e) + return 2 + + return 0 + + +if __name__ == '__main__': + sys.exit(main())