swiss-netex 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 47374355daf2b7133d4cdd64c44bc6a2574b3a1f77fc7ede6ed3dec582c85e67
4
+ data.tar.gz: 6e8dc725d90ae6c5e19a8e1cb7dad5113c20603994f648712d9d169b73a0760e
5
+ SHA512:
6
+ metadata.gz: 4a5c0a1aca546bac12a3196cc0b52d4822caeb5665bde3586b65678eb5a635cdbb2896cb6c4c7eead48e082ca8ec14a37b5f0762e43f47f039b05be171cf3f87
7
+ data.tar.gz: 6c51f8a53e717ef9f0be266b7859d9665afae4d1c099bf79af9a17015bb1b4370d78bb59cb046bb6e1b3ca5e34c6b6117695d3425898691103bfae645bee1f3b
data/CHANGELOG.md ADDED
@@ -0,0 +1,77 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [1.0.0] — unreleased
11
+
12
+ First public release. Download, inspect, and filter Swiss national NeTEx
13
+ timetable packages from [opentransportdata.swiss](https://opentransportdata.swiss).
14
+
15
+ Marked **1.0.0** (not 0.x): the CLI surface is complete for the intended
16
+ scope and treated as a stable contract. Tag/publish waits on team review.
17
+
18
+ ### Added
19
+
20
+ - CLI binary `swiss-netex` with `help`, `version`, `download`, `operators`,
21
+ `lines`, `filter`, and `extract`
22
+ - `download` — resolve the latest (or year-pinned) national NeTEx zip via the
23
+ public catalog + dataset page HTML, stream to disk, cache under
24
+ `~/.cache/swiss-netex` (override with `--cache-dir` / `--force`)
25
+ - `operators` / `lines` — discovery tables from RESOURCE / SERVICE only
26
+ (TSV when piped; aligned columns on a TTY). Optional `--operator` /
27
+ `--line` filters on `lines`. Omit `--from` to download/cache first
28
+ (`-y` / `-d` / `-c` / `-f`, same rules as `extract`)
29
+ - `filter` — operator/line filter for a local zip or directory package:
30
+ streaming SERVICE + TIMETABLE passes, transitive RESOURCE / SERVICE /
31
+ SITE / SERVICECALENDAR / COMMON rewrite, drop empty timetable shards,
32
+ `README.txt` in the output zip
33
+ - `extract` — download (when `--from` omitted) + filter in one command
34
+ - Operator aliases: GO number, `ch:1:Operator:*`, SBOID, ShortName
35
+ (repeatable `--operator` = union). Line aliases: PublicCode, id, SLNID,
36
+ ShortName, Name. With explicit `--line`, journeys must carry a matching
37
+ `LineRef` (operator-only fallback only when no line filter is set)
38
+ - COMMON connection entities (`JourneyMeeting`, `InterchangeRule`) kept only
39
+ when every tracked journey/stop/line ref still resolves in the package
40
+ - Bare `-o` stem without extension becomes `stem.zip` for filter/extract;
41
+ existing directories / trailing `/` still get the default `*_op-….zip`
42
+ name inside
43
+ - National-scale streaming: short-lived fork workers for TIMETABLE shards
44
+ and support frames, parent byte pre-scan to skip empty shards, streamed
45
+ kept-journey bodies, non-DOM COMMON scanner
46
+ - Env knobs: `SWISS_NETEX_TT_WORKERS` (default CPU count; `1` = serial),
47
+ `SWISS_NETEX_TT_BATCH` (default 1 shard/child), `SWISS_NETEX_NO_FORK=1`
48
+ - `SwissNetex::Package`, `ResourceFrame`, `Operators`, `Lines`, `Filter`,
49
+ `Download`, `Extract` library entry points
50
+ - Minitest suite (fixture packages only; no national zip in CI), GitHub
51
+ Actions CI + tag publish gated on test/lint, minimal RuboCop, RubyGems
52
+ trusted-publish on version tags
53
+
54
+ ### Fixed
55
+
56
+ - Filtered XML no longer keeps blank “holes” where dropped entities used to sit
57
+ (streaming whitespace collapse in SERVICE/SITE/RESOURCE/SERVICECALENDAR,
58
+ TIMETABLE, and COMMON writers — no full DOM pretty-print)
59
+
60
+ ### Notes
61
+
62
+ - Tool is MIT; timetable data remains under OpenTransportData terms
63
+ - Cache key is the remote filename (export timestamp embedded); a positive-size
64
+ file must also open as a zip before it counts as a hit. Use `--force` to
65
+ re-download
66
+ - Downloads request `Accept-Encoding: identity` so size checks compare
67
+ wire `Content-Length` to the bytes written on disk
68
+ - Filter/extract write the output zip via a same-directory temp file + rename
69
+ - TIMETABLE `TrainNumber` defs are kept whether they appear before or after
70
+ `vehicleJourneys` (defs-first inputs may emit `trainNumbers` after journeys)
71
+ - Filtered-package `README.txt` and public README document Swiss-profile
72
+ expectations: `ValidDayBits` calendars, closed COMMON connections, sparse
73
+ TIMETABLE shard indices
74
+ - Peak system RSS scales roughly with worker count × largest kept shard;
75
+ prefer a machine with several GB free RAM for full national filters
76
+ - `extract` / `operators` / `lines`: `--from` cannot be combined with
77
+ `--dataset` / `--year` / `--force` / `--cache-dir` (download-only flags)
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GateMedia
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,237 @@
1
+ # swiss-netex
2
+
3
+ CLI for Swiss national NeTEx timetable packages
4
+ ([opentransportdata.swiss](https://opentransportdata.swiss)): download, inspect,
5
+ and filter by operator/line.
6
+
7
+ This is **not** a full NeTEx model library. It prepares smaller operator-scoped
8
+ packages from the national SKI NeTEx feed.
9
+
10
+ ## Requirements
11
+
12
+ - Ruby ≥ 4.0
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ gem install swiss-netex
18
+ ```
19
+
20
+ Or from source:
21
+
22
+ ```bash
23
+ git clone https://github.com/gatemedia/swiss-netex.git
24
+ cd swiss-netex
25
+ mise run bootstrap # or: bundle install
26
+ bundle exec exe/swiss-netex version
27
+ ```
28
+
29
+ ## Quick start
30
+
31
+ Download (or reuse cache) and filter one operator in a single step:
32
+
33
+ ```bash
34
+ swiss-netex extract -y 2026 -O 151 -o tl.zip
35
+ ```
36
+
37
+ Stdout prints the output path. Progress goes to stderr.
38
+
39
+ ```bash
40
+ swiss-netex help
41
+ swiss-netex help extract # full flags for any command
42
+ swiss-netex version
43
+ ```
44
+
45
+ ## Commands
46
+
47
+ ### `download`
48
+
49
+ Resolve and stream the latest national NeTEx zip. Resource downloads are public
50
+ (no API token on the happy path). Prints the file path on stdout.
51
+
52
+ ```bash
53
+ swiss-netex download
54
+ swiss-netex download --year 2026
55
+ swiss-netex download -y 2026 -o ./cache
56
+ swiss-netex download --dataset timetablenetex_2026 --force
57
+ swiss-netex download --cache-dir ./cache
58
+ ```
59
+
60
+ - Default dataset: highest published `timetablenetex_YYYY` on the public catalog
61
+ - Override with `--year YYYY` or `--dataset` (slug or full page URL)
62
+ - Default cache: `~/.cache/swiss-netex`
63
+ - Cache key is the remote filename (export timestamp embedded). A positive-size
64
+ local file with that name that also opens as a zip is a cache hit — OTD signed
65
+ URLs reject HEAD, so remote size is not re-checked. Use `--force` to refresh.
66
+
67
+ ### `operators`
68
+
69
+ List operators from the RESOURCE frame. Scans RESOURCE only — not TIMETABLE
70
+ shards — so listing stays cheap even on a full national package. Omit `--from`
71
+ to download (or reuse cache) first — same flags as `download` / `extract`.
72
+
73
+ ```bash
74
+ swiss-netex operators -y 2026
75
+ swiss-netex operators --from ./cache/PROD_NETEX_….zip
76
+ swiss-netex operators -y 2026 | cut -f1,4
77
+ ```
78
+
79
+ Columns: `go`, `operator_id`, `sboid`, `short_name`, `name`.
80
+
81
+ ### `lines`
82
+
83
+ List lines from the SERVICE frame (SERVICE only — not TIMETABLE). Optional
84
+ `--operator` / `--line` narrow the list (same alias rules as `filter`). Omit
85
+ `--from` to download (or reuse cache) first.
86
+
87
+ ```bash
88
+ swiss-netex lines -y 2026
89
+ swiss-netex lines -y 2026 -O 151
90
+ swiss-netex lines --from ./cache/PROD_NETEX_….zip -O 151 -l m1
91
+ ```
92
+
93
+ Columns: `line_id`, `public_code`, `slnid`, `operator_ref`, `name`.
94
+
95
+ ### `filter`
96
+
97
+ Filter a local package (zip or extracted directory) by operator and optional
98
+ line. Writes a smaller zip with transitive frame contents and a `README.txt`.
99
+
100
+ ```bash
101
+ swiss-netex filter --from ./cache/PROD_NETEX_….zip -O 151 -o tl.zip
102
+ swiss-netex filter --from ./cache/PROD_NETEX_….zip \
103
+ -O 151 -O 55 -l 1 -o filtered.zip --verbose
104
+ swiss-netex filter --from ./package-dir/ -O 151 -o ./out/
105
+ ```
106
+
107
+ - `--operator` / `-O` is **repeatable** (union). Aliases: GO number,
108
+ `ch:1:Operator:*`, SBOID, ShortName
109
+ - Optional `--line` / `-l` (repeatable): PublicCode, id, SLNID, ShortName, Name
110
+ - Keeps matching SERVICE lines and TIMETABLE `ServiceJourney`s, then the
111
+ transitive closure in RESOURCE / SERVICE / SITE / SERVICECALENDAR / COMMON
112
+ - Empty timetable shards are dropped (original national shard indices kept)
113
+ - Without `--allow-empty`, exits non-zero when no lines or journeys match
114
+ - `--verbose` prints per-shard and per-frame keep/drop counters on stderr
115
+ - Output zip includes `README.txt` (filters, counts, Swiss-profile notes)
116
+
117
+ ### `extract`
118
+
119
+ Download (when `--from` is omitted) then filter. The common one-shot workflow.
120
+
121
+ ```bash
122
+ swiss-netex extract -O 151 -o tl.zip
123
+ swiss-netex extract -y 2026 -O 151 -o tl.zip
124
+ swiss-netex extract --from ./cache/PROD_NETEX_….zip -O 151 -o tl.zip
125
+ ```
126
+
127
+ Same filter flags as `filter`, plus download flags when no `--from` is given.
128
+ `operators` and `lines` share the same package-source rules: omit `--from` to
129
+ download/cache; with `--from`, `--dataset` / `--year` / `--force` /
130
+ `--cache-dir` are rejected.
131
+
132
+ ## Output and conventions
133
+
134
+ | Stream | Content |
135
+ |--------|---------|
136
+ | stdout | Machine-usable result (file path, or TSV/table for discovery) |
137
+ | stderr | Progress, status, verbose counters, errors |
138
+
139
+ Discovery commands (`operators`, `lines`): raw TSV when piped / non-TTY;
140
+ aligned columns on an interactive terminal (same cells, two-space padding).
141
+
142
+ **`-o` path rules** (`filter` / `extract`):
143
+
144
+ | `-o` value | Result |
145
+ |------------|--------|
146
+ | `tl.zip` | as-is |
147
+ | `tl` (no extension) | `tl.zip` |
148
+ | `tl.tar` | as-is (explicit non-zip extension left alone) |
149
+ | existing directory or trailing `/` | `dir/<source>_op-….zip` |
150
+
151
+ Exit codes: `0` ok, `1` runtime/domain error, `64` usage/parse error.
152
+
153
+ ## Memory and speed
154
+
155
+ National packages are large (~hundreds of MB compressed, tens of GB
156
+ uncompressed). `filter` / `extract` stream frames and never load the full
157
+ national set into a DOM:
158
+
159
+ - short-lived worker processes on platforms with `fork`
160
+ - parent byte pre-scan skips empty TIMETABLE shards
161
+ - process pool for matching shards and support frames
162
+ - non-DOM COMMON scanner
163
+
164
+ | Env | Effect |
165
+ |-----|--------|
166
+ | `SWISS_NETEX_TT_WORKERS=N` | Pool size (default: CPU count; `1` = serial) |
167
+ | `SWISS_NETEX_TT_BATCH` | Shards per child (default: `1`) |
168
+ | `SWISS_NETEX_NO_FORK=1` | Force in-process mode |
169
+
170
+ Prefer a machine with **≥4 GB free RAM**. Peak system RSS scales roughly with
171
+ worker count × largest kept shard.
172
+
173
+ ## Swiss profile notes (filtered packages)
174
+
175
+ These are normal for SKI national exports and operator-scoped subsets — not
176
+ filter bugs. The same points are written into each output `README.txt`.
177
+
178
+ **Calendar / `DayType`**
179
+
180
+ Journeys typically reference `AvailabilityCondition`s that carry
181
+ `FromDate` / `ToDate` / `ValidDayBits`. Many selections collect **zero**
182
+ `DayType` / `DayTypeRef` entities. Importers should read validity from the
183
+ kept ACs in SERVICECALENDAR, not assume a DayType model.
184
+
185
+ **COMMON connections**
186
+
187
+ `JourneyMeeting` and `InterchangeRule` are kept only when **every** tracked
188
+ journey, stop, and line ref still resolves inside the package (no dangling
189
+ partner `LineRef`). Cross-operator interchanges drop unless you include both
190
+ operators (e.g. `-O 151 -O 55`). An empty connection section is valid.
191
+
192
+ **TIMETABLE shard names**
193
+
194
+ Only shards with kept journeys are written. Filenames keep the national
195
+ `TIMETABLE_{i}_{n}_…` indices, so numbering can have gaps (e.g. 45–69 plus
196
+ 113). Consumers should glob timetable members, not assume `1…N` contiguous.
197
+
198
+ ## Data license
199
+
200
+ The **tool** is MIT. **Timetable data** remains under
201
+ [OpenTransportData terms](https://opentransportdata.swiss). Do not commit
202
+ national dumps into this repository.
203
+
204
+ ## Development
205
+
206
+ This project uses [mise](https://mise.jdx.dev/) for the local toolchain.
207
+
208
+ ```bash
209
+ mise trust # once, if prompted
210
+ mise install # Ruby from mise.toml / .ruby-version
211
+ mise run bootstrap # bundle install
212
+ mise run test # bundle exec rake test
213
+ mise run lint # bundle exec rake rubocop
214
+ mise run ci # tests + rubocop
215
+ mise run swiss-netex help
216
+ mise run swiss-netex version
217
+ mise run build # gem package into pkg/
218
+ ```
219
+
220
+ Without mise:
221
+
222
+ ```bash
223
+ bundle install
224
+ bundle exec rake # test + rubocop
225
+ bundle exec rake test
226
+ bundle exec rake rubocop
227
+ ```
228
+
229
+ ## Release
230
+
231
+ 1. Bump `SwissNetex::VERSION` and update `CHANGELOG.md`
232
+ 2. Tag `vX.Y.Z` and push the tag
233
+ 3. GitHub Actions builds and publishes to [RubyGems](https://rubygems.org/gems/swiss-netex)
234
+
235
+ ## License
236
+
237
+ MIT © GateMedia — see [LICENSE](LICENSE).
data/exe/swiss-netex ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "swiss_netex"
5
+
6
+ exit SwissNetex::CLI.start(ARGV)
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwissNetex
4
+ # Parses an opentransportdata.swiss catalog search HTML page and picks the
5
+ # highest Fahrplanjahr among national NeTEx dataset slugs
6
+ # (`timetablenetex_YYYY`).
7
+ #
8
+ # CKAN JSON APIs require a token on this host; the public HTML catalog does not.
9
+ class Catalog
10
+ DATASET_SLUG_RE = %r{
11
+ /(?:[a-z]{2}/)?dataset/(timetablenetex_(\d{4}))
12
+ (?:["'\s?#]|$)
13
+ }ix
14
+
15
+ def self.latest_slug(html)
16
+ new(html).latest_slug
17
+ end
18
+
19
+ def initialize(html)
20
+ @html = html.to_s
21
+ end
22
+
23
+ def slugs
24
+ seen = {}
25
+ @html.scan(DATASET_SLUG_RE).filter_map do |slug, year|
26
+ key = slug.downcase
27
+ next if seen[key]
28
+
29
+ seen[key] = true
30
+ { slug: key, year: Integer(year) }
31
+ end
32
+ end
33
+
34
+ def latest_slug
35
+ list = slugs
36
+ raise Error, "No timetablenetex_YYYY dataset found on catalog search page" if list.empty?
37
+
38
+ list.max_by { |entry| entry[:year] }[:slug]
39
+ end
40
+ end
41
+ end