oddb2xml 3.0.27 → 3.0.29

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4aa1328e4844d47543cea04ab6ab5832900bd1bdc1611b1a96d969a71632ff18
4
- data.tar.gz: e592e5253f0ff09a18e7a515b4b4689484ddc61ba2969a5221bdd3864ebafa74
3
+ metadata.gz: 4602599ed50439225ff6ce76000f8441a9b4963b02538adf3f46d6728beff6cc
4
+ data.tar.gz: 55f34b946cc7a0e75435459aa90169e23719ac44c4bbfdf1ba3d63c3162f894d
5
5
  SHA512:
6
- metadata.gz: 30aa032317390a02cd39b1b281897618bd59c51fffb7f793a30dd9caf97798866b8b2deb585e0e877fe0704a2b329c1b3526aaf3ef9e323adfa54e8dfdc05945
7
- data.tar.gz: 1aeb220381cfd66043a5ca4e695a5c24a349642ca28c44794908c73ec164d5c3b18c3fce92acae6227694c5e68b00fb58e5c292b08aaf1c68f4d81af6697b7f0
6
+ metadata.gz: cf98f9144a53cd62be8599168423c4c6d560b8474dc015d5281eccb84b2f80de6d46e3765b7ab96cc6271822361ea0a4a7b1f00bae1908e16f94f8fb77006908
7
+ data.tar.gz: 7ed225d5f4cd6f3c3293913cb40e01430839968d34e15551a2192a1f9069343dd98f7448179a0e433bc6485d8810510904add62632e6e5ddf6d03d50b8f41dc8
data/CLAUDE.md CHANGED
@@ -47,7 +47,7 @@ The system follows a **download → extract → build → compress** pipeline:
47
47
 
48
48
  6. **Compressor** (`lib/oddb2xml/compressor.rb`) — Optional ZIP/TAR.GZ output compression.
49
49
 
50
- 7. **FHIR support** (`lib/oddb2xml/fhir_support.rb`) — Self-contained module providing `FhirDownloader` and FHIR NDJSON parsing. Activated via `--fhir` (or `--fhir-url=<URL>`). Downloads per-language NDJSON files (`foph-sl-export-latest-{de,fr,it}.ndjson`) from `epl.bag.admin.ch` to populate French and Italian product names/descriptions. Maps legal status codes `756005022007` and `756005022008` to Swissmedic category D. Reads the BAG **Indikationscode** (`XXXXX.NN`) from the explicit `indicationCode` extension on each `RegulatedAuthorization.indication[].extension[regulatedAuthorization-limitation]` (BAG SL FHIR export >= v2.0.5; handled from 3.0.10). The BAG changelog states the limitation code (`ClinicalUseDefinition.id`) and the indication code are **independent** fields, so the older derivation — combining each indication CUD's `.NN` id-suffix with the reimbursement RA's `FOPHDossierNumber` — is kept only as a fallback for feeds lacking the extension. Exposed as `item[:indication_codes]` and per-package `:indication_codes` (each entry a `{code:, cud_id:, text:}` hash, where `cud_id` is the `limitationIndication` CUD reference used to resolve the text). From 3.0.7 onwards, `Builder#build_product` emits one `<INDICATION_CODE code="XXXXX.NN" cud_id="DRUG.NN">limitation text</INDICATION_CODE>` child per indication on every `<PRD>` in `oddb_product.xml`; live feed numbers: 539 products / 1,293 codes / 100 % with non-empty indication text. Mandatory on prescriptions/invoices for SL price-model drugs from 2026-07-01 — see issue [#113](https://github.com/zdavatz/oddb2xml/issues/113). **Limitation texts** (3.0.8 onwards): the `regulatedAuthorization-limitation` extension has no inline `limitationText` in the live BAG feed — it carries a `limitationIndication` reference to a `ClinicalUseDefinition` whose `indication.diseaseSymptomProcedure.concept.text` is the actual text. The parser stores the ref as `cud_ref` on each Limitation, `Bundle#cud_text_by_id` resolves DE, and `merge_language` propagates FR/IT from the per-language NDJSON files via the same CUD id. Coverage on the live feed jumped from 0 / 9'108 to 9'108 / 9'108 (issue [#116](https://github.com/zdavatz/oddb2xml/issues/116)). **Limitation code / LIMNAMEBAG** (3.0.12 onwards): FHIR has no native BAG limitation code (LIMCD), so `create_limitations_for_package` sets `LimitationCode = cud_ref` (the `limitationIndication` CUD id) instead of `""`. Without this, every FHIR limitation shared an empty `:code`; `Builder#build_artikelstamm` groups its `<LIMITATIONS>` section by code, so all of them collapsed into a single `<LIMITATION>` with an empty `<LIMNAMEBAG>` and only one text survived. Using the CUD id as the key makes each distinct limitation emit and be referenced from its `<PRODUCT>`. The downstream `bin/check_artikelstamm` (`semantic_check.rb`) also crashed on the lone-element output because Ox `:hash_no_attrs` collapses a one-child section into a Hash (and an empty one into nil) — `SemanticCheckXML#get_items` now normalises every section to an Array. **v6 Artikelstamm / per-article INDC (3.0.26 onwards):** `--artikelstamm` now emits the **Elexis Artikelstamm v6** format (namespace `http://elexis.ch/Elexis_Artikelstamm_v6`, file `artikelstamm_DDMMYYYY_v6.xml`/`.csv`, validated against the bundled `Elexis_Artikelstamm_v6.xsd`) — replacing v5. The new piece is a per-`<ITEM>` `<ARTSL>` block carrying the BAG Indikationscodes (issue [#113](https://github.com/zdavatz/oddb2xml/issues/113)): `<PM>true</PM>` plus one `<ARTLIM>` per limitation with `<LIMCD>` (= `cud_ref`, the BAG limitation code), `<INDCD>` (the `XXXXX.NN` indication code from the `indicationCode` extension), and `<VDAT>`/`<VTDAT>` (period start/end). To feed it, `create_limitations_for_package` now also carries `IndicationCode` (→ per-package `:indcd`) and `ValidThruDate` (→ `:vtdate`) on each limitation; `Builder#append_artsl`/`elexis_datetime` emit one `<ARTLIM>` per limitation that has a non-empty `:indcd` (so non-price-model items get no `<ARTSL>`). `PM` is always `true` here because the indication code is required only for SL price-model drugs, which is exactly the set of items that reach this block. The bundled `Elexis_Artikelstamm_v6.xsd` is the canonical MEDEVIT schema extended with oddb2xml's historical Italian elements (`DSCRI` on PRODUCT/LIMITATION/ITEM, `DOSAGE_FORMI` on ITEM) so the output still validates. The legacy `--no-fhir` path emits no `<ARTSL>` (no FHIR limitations). **Per-article INDC in the `-e`/`-b` feeds (3.0.27 onwards):** the same indication codes are also emitted per `<ART>` in `oddb_article.xml` via `Builder#append_indication_codes` — one `<INDICATION_CODE code="XXXXX.NN" cud_id="DRUG.NN" limcd="DRUG.NN" vdat=… vtdat=…>limitation text</INDICATION_CODE>` per limitation that carries an `:indcd` (sourced from the article's `pac[:limitations]`). This reuses the flat `<INDICATION_CODE>` element already emitted on `<PRD>` in `oddb_product.xml` (since 3.0.7), enriched with the limitation code and validity dates so it carries the full `<ARTSL>` payload. The bundled `oddb2xml.xsd` now defines `<INDICATION_CODE>` (simpleContent + `code`/`cud_id`/`limcd`/`vdat`/`vtdat` attributes) and references it from both `<ART>` and `<PRD>` — the `<PRD>` reference had been missing since the 3.0.7 addition, so the FHIR product feed only validated once this was added.
50
+ 7. **FHIR support** (`lib/oddb2xml/fhir_support.rb`) — Self-contained module providing `FhirDownloader` and FHIR NDJSON parsing. Activated via `--fhir` (or `--fhir-url=<URL>`). Downloads per-language NDJSON files (`foph-sl-export-latest-{de,fr,it}.ndjson`) from `epl.bag.admin.ch` to populate French and Italian product names/descriptions. Maps legal status codes `756005022007` and `756005022008` to Swissmedic category D. Reads the BAG **Indikationscode** (`XXXXX.NN`) from the explicit `indicationCode` extension on each `RegulatedAuthorization.indication[].extension[regulatedAuthorization-limitation]` (BAG SL FHIR export >= v2.0.5; handled from 3.0.10). The BAG changelog states the limitation code (`ClinicalUseDefinition.id`) and the indication code are **independent** fields, so the older derivation — combining each indication CUD's `.NN` id-suffix with the reimbursement RA's `FOPHDossierNumber` — is kept only as a fallback for feeds lacking the extension. Exposed as `item[:indication_codes]` and per-package `:indication_codes` (each entry a `{code:, cud_id:, text:}` hash, where `cud_id` is the `limitationIndication` CUD reference used to resolve the text). From 3.0.7 onwards, `Builder#build_product` emits one `<INDICATION_CODE code="XXXXX.NN" cud_id="DRUG.NN">limitation text</INDICATION_CODE>` child per indication on every `<PRD>` in `oddb_product.xml`; live feed numbers: 539 products / 1,293 codes / 100 % with non-empty indication text. Mandatory on prescriptions/invoices for SL price-model drugs from 2026-07-01 — see issue [#113](https://github.com/zdavatz/oddb2xml/issues/113). **Limitation texts** (3.0.8 onwards): the `regulatedAuthorization-limitation` extension has no inline `limitationText` in the live BAG feed — it carries a `limitationIndication` reference to a `ClinicalUseDefinition` whose `indication.diseaseSymptomProcedure.concept.text` is the actual text. The parser stores the ref as `cud_ref` on each Limitation, `Bundle#cud_text_by_id` resolves DE, and `merge_language` propagates FR/IT from the per-language NDJSON files via the same CUD id. Coverage on the live feed jumped from 0 / 9'108 to 9'108 / 9'108 (issue [#116](https://github.com/zdavatz/oddb2xml/issues/116)). **Limitation code / LIMNAMEBAG** (3.0.12 onwards): FHIR has no native BAG limitation code (LIMCD), so `create_limitations_for_package` sets `LimitationCode = cud_ref` (the `limitationIndication` CUD id) instead of `""`. Without this, every FHIR limitation shared an empty `:code`; `Builder#build_artikelstamm` groups its `<LIMITATIONS>` section by code, so all of them collapsed into a single `<LIMITATION>` with an empty `<LIMNAMEBAG>` and only one text survived. Using the CUD id as the key makes each distinct limitation emit and be referenced from its `<PRODUCT>`. The downstream `bin/check_artikelstamm` (`semantic_check.rb`) also crashed on the lone-element output because Ox `:hash_no_attrs` collapses a one-child section into a Hash (and an empty one into nil) — `SemanticCheckXML#get_items` now normalises every section to an Array. **v6 Artikelstamm / per-article INDC (3.0.26 onwards):** `--artikelstamm` now emits the **Elexis Artikelstamm v6** format (namespace `http://elexis.ch/Elexis_Artikelstamm_v6`, file `artikelstamm_DDMMYYYY_v6.xml`/`.csv`, validated against the bundled `Elexis_Artikelstamm_v6.xsd`) — replacing v5. The new piece is a per-`<ITEM>` `<ARTSL>` block carrying the BAG Indikationscodes (issue [#113](https://github.com/zdavatz/oddb2xml/issues/113)): `<PM>true</PM>` plus one `<ARTLIM>` per limitation with `<LIMCD>` (= `cud_ref`, the BAG limitation code), `<INDCD>` (the `XXXXX.NN` indication code from the `indicationCode` extension), and `<VDAT>`/`<VTDAT>` (period start/end). To feed it, `create_limitations_for_package` now also carries `IndicationCode` (→ per-package `:indcd`) and `ValidThruDate` (→ `:vtdate`) on each limitation; `Builder#append_artsl`/`elexis_datetime` emit one `<ARTLIM>` per limitation that has a non-empty `:indcd` (so non-price-model items get no `<ARTSL>`). `PM` is always `true` here because the indication code is required only for SL price-model drugs, which is exactly the set of items that reach this block. The bundled `Elexis_Artikelstamm_v6.xsd` is the canonical MEDEVIT schema extended with oddb2xml's historical Italian elements (`DSCRI` on PRODUCT/LIMITATION/ITEM, `DOSAGE_FORMI` on ITEM) so the output still validates. The legacy `--no-fhir` path emits no `<ARTSL>` (no FHIR limitations). **Per-article INDC in the `-e`/`-b` feeds (3.0.27 onwards):** the same indication codes are also emitted per `<ART>` in `oddb_article.xml` via `Builder#append_indication_codes` — one `<INDICATION_CODE code="XXXXX.NN" cud_id="DRUG.NN" limcd="DRUG.NN" vdat=… vtdat=…>limitation text</INDICATION_CODE>` per limitation that carries an `:indcd` (sourced from the article's `pac[:limitations]`). This reuses the flat `<INDICATION_CODE>` element already emitted on `<PRD>` in `oddb_product.xml` (since 3.0.7), enriched with the limitation code and validity dates so it carries the full `<ARTSL>` payload. The bundled `oddb2xml.xsd` now defines `<INDICATION_CODE>` (simpleContent + `code`/`cud_id`/`limcd`/`vdat`/`vtdat` attributes) and references it from both `<ART>` and `<PRD>` — the `<PRD>` reference had been missing since the 3.0.7 addition, so the FHIR product feed only validated once this was added. **Legacy v5 opt-in (3.0.28 onwards):** `--artikelstamm-v5` additionally emits the older Artikelstamm v5 file alongside the v6 one (for consumers not yet migrated). It implies `--artikelstamm`; `Options.parse` sets `@opts[:artikelstamm] = true`. `build_artikelstamm` takes a `version:` keyword (default `6`) that drives the namespace (`Elexis_Artikelstamm_v#{version}`) and CSV/XML filename suffix; `build_artikelstamm_v5` calls it with `version: 5`. The only content difference is that the v5 path skips `append_artsl` (the v5 schema has no `<ARTSL>` element). The CLI `files` map adds an `:artikelstamm_v5` entry (→ `build_artikelstamm_v5` via the `to_xml` subject dispatch) and validates it against the bundled `Elexis_Artikelstamm_v5.xsd`. Both files come from one build — the `@prepared` guard means the shared download/prepare phase runs once.
51
51
 
52
52
  8. **Refdata cleanup** (`lib/oddb2xml/refdata_cleanup.rb`) — Compensates for known data-quality issues in upstream Refdata.Articles.xml before they reach the output. Each fix is guarded by a Swissmedic-side heuristic (e.g. comma in `substance_swissmedic` to distinguish mono products from real combinations). Currently fixes (a) the doubled-dose template bug (`X mg / X mg / Stk`, `fix_double_dose`, guarded by `single_substance?`); (b) the spelled-out German galenic form `Retardtabletten` → house-style abbreviation `Ret Tabl` (`normalize_galenic_form` / `GALENIC_NORMALISATIONS`, issue #112 case #13, e.g. RINVOQ — a narrow word-boundary substitution that leaves legitimate brand suffixes like `TRAMAL retard` and Mepha's `Lactab` untouched); and (c) dose info Refdata dropped from `<FullName>`, sourced from the Swissmedic composition string `pack[:composition_swissmedic]` — `fix_missing_combo_dose` (#6, appends a combination's 2nd component strength), `fix_missing_dose` (#4, inserts a mono product's missing strength before the pack count), `fix_missing_volume` (#7, appends an injectable's per-pen volume); and (d) 50-char-truncation repairs — `fix_truncated_metoject` (#1, rebuilds METOJECT Autoinjektor names from the intact `<brand> Autoinjektor <dose>/<vol>` prefix + Swissmedic `size`, localised DE/FR/IT) and `fix_truncated_volume_unit` (#3, restores the cut `ml` of the VERACTIV Vitamin D3 drops). The (c) and (d) fixes are scoped to explicit IKSNR allow-lists (`COMBO_DOSE_IKSNR`/`MISSING_DOSE_IKSNR`/`MISSING_VOLUME_IKSNR`/`METOJECT_IKSNR`/`VERACTIV_VITD3_IKSNR`): a dry run proved a blanket heuristic mis-fires on hundreds of legitimate names (sodium counter-ion doses, strength-less phyto/powder products, concentration names like `CIMZIA 200 mg/ml`), so only catalogued registrations are touched — add an IKSNR to grow coverage. Called from `Builder#apply_refdata_description_cleanups!` at the start of `prepare_articles`. See GitHub issue #112 for the catalogue.
53
53
 
@@ -68,7 +68,7 @@ YAML files in `data/` provide manual overrides and mappings: `article_overrides.
68
68
 
69
69
  These scripts run the public download server at `https://mediupdatexml.oddb.org` (Apache on this host) and are **not** part of the gem itself.
70
70
 
71
- - **`run_oddb2xml.sh`** — nightly build driver (cron: `0 1 * * * zdavatz`). Downloads the upstream sources **once**, then builds the `-b`/firstbase feed at price increments `45/50/55` plus `default` (no increment) into `$OUT_DIR` (`/home/zdavatz/oddb2xml`, one subdir each). The shared `downloads/` cache and transient zip live in `$BUILD_DIR` (`<OUT_DIR>-build`), **outside** `$OUT_DIR` so the transfer never uploads the multi-hundred-MB cache. Final step ("2b") regenerates the landing page. Each `oddb2xml` invocation is wrapped in `run_with_retry` (default **3 attempts, 120 s apart**, tunable via `ODDB2XML_RETRIES`/`ODDB2XML_RETRY_DELAY`): a transient upstream download failure (e.g. Swissmedic resetting the connection, `Errno::ECONNRESET`) previously aborted the whole `set -e` run 14 s in and left the feeds a day stale, so it now retries before giving up; a genuine repeated failure still stops the run.
71
+ - **`run_oddb2xml.sh`** — nightly build driver (cron: `0 1 * * * zdavatz`). Downloads the upstream sources **once**, then builds the `-b`/firstbase feed at price increments `45/50/55` plus `default` (no increment) into `$OUT_DIR` (`/home/zdavatz/oddb2xml`, one subdir each). The shared `downloads/` cache and transient zip live in `$BUILD_DIR` (`<OUT_DIR>-build`), **outside** `$OUT_DIR` so the transfer never uploads the multi-hundred-MB cache. Final step ("2b") regenerates the landing page. Each `oddb2xml` invocation is wrapped in `run_with_retry` (default **3 attempts, 120 s apart**, tunable via `ODDB2XML_RETRIES`/`ODDB2XML_RETRY_DELAY`): a transient upstream download failure (e.g. Swissmedic resetting the connection, `Errno::ECONNRESET`) previously aborted the whole `set -e` run 14 s in and left the feeds a day stale, so it now retries before giving up; a genuine repeated failure still stops the run. **Firstbase (GS1 NONPHARMA) last-good fallback (3.0.29 onwards):** the GS1 `GetFirstbaseHealthcare` export (`id.gs1.ch/01/07612345000961` → `apitools.gs1.ch`) has been answering `403 - Forbidden`, which blanked `firstbase.csv` and dropped **every** NONPHARMA article from the `-b` feed (landing page then showed `NONPHARMA = 0 − 1 = −1`). The script keeps the last successful `firstbase.csv` in a persistent cache `$FIRSTBASE_CACHE` (default `<OUT_DIR>-state/firstbase.csv`) **outside** `$BUILD_DIR` so it survives the nightly `rm -rf`, seeds it into `downloads/` before the build, and refreshes it after a successful download. The gem side (`FirstbaseDownloader#download`, rewritten in 3.0.29) makes the seed usable: it still attempts the live GS1 fetch on the first (downloading) build, but only overwrites `firstbase.csv` when the response is a real non-empty CSV (`firstbase_csv?` rejects HTML/`403 - Forbidden`/empty bodies and open-uri exceptions), otherwise it **keeps the existing seeded file** instead of the old `"w+"` truncate-to-zero. A recovered GS1 therefore refreshes the data automatically; while GS1 is down the feed serves yesterday's (last-good) NONPHARMA rather than nothing. `generate_index_html.sh` also guards the NONPHARMA count so an empty CSV renders `—` (not `−1`).
72
72
  - **`generate_index_html.sh DOCROOT [FIRSTBASE_CSV]`** — single source of truth for the landing page. Writes `index.html` + a self-contained `logo.svg` **atomically** (temp + `mv`, so either owner — root from setup, `zdavatz` from cron — can refresh it). Computes live counts: PHARMA = `<SMNO>` count in `default/oddb_article.xml`, NONPHARMA = firstbase CSV rows − 1, total ART = `<ART ` count. Also runs **`visitor_stats.py`** and embeds its graph. Re-run standalone any time (it only reads already-built files); a separate cron line refreshes it **hourly** (`5 * * * * zdavatz`) so counts + graph stay current between nightly builds.
73
73
  - **`visitor_stats.py LOG_GLOB CACHE_DIR [DAYS]`** — emits the visitors/sessions/region graph as an inline-SVG HTML **fragment** (last `DAYS`, default 14): Besucher = distinct IPs/day, Sitzungen = 30-min-inactivity sessions per `(IP, User-Agent)`, plus a top-6 country breakdown by IP. Bots are filtered by User-Agent. Region lookup is **fully self-contained** — pure Python stdlib + the free **DB-IP country-lite CSV** (CC-BY, no licence key) cached in the build `downloads/` dir and refreshed monthly; **no apt package, no gem, no system GeoIP DB**. Prints nothing (page degrades to omitting the section) when the Apache log is unreadable or empty. Reading `/var/log/apache2` requires the cron user to be in the **`adm`** group (`sudo usermod -aG adm zdavatz`).
74
74
  - **`swissmedic_watch.sh`** — outage/block auto-recovery (cron: `*/30 * * * * zdavatz`). Since the Swissmedic platform migration (~2026-06-23, now a Swisscom-operated gateway), `www.swissmedic.ch` intermittently resets this host's automated connections **after the TLS handshake** (TCP RST), which aborts `run_oddb2xml.sh` under `set -e` and leaves the feeds stale (the block is host/IP- and client-fingerprint-sensitive: a real browser works, `curl`/`wget`/Ruby get reset, while other admin.ch hosts answer fine — so it is a WAF/bot rule, not an outage). The watcher polls Swissmedic with **oddb2xml's own client** (a Ruby `open-uri` canary on `listen_neu.html`); while blocked it is a silent no-op, and the moment it gets HTTP 200 it launches **one** build and emails. It fires **at most once per day** (stamp in `$STATE_DIR`, default `<OUT_DIR>-watch`, kept **outside** the wiped `$BUILD_DIR`), and skips when a build is already running or today's `default/oddb_article.xml` is already fresh. It exports `RBENV_VERSION=3.4.5` + the rbenv-shims PATH to match the nightly cron (the repo `.ruby-version` pins an uninstalled Ruby).
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- oddb2xml (3.0.27)
4
+ oddb2xml (3.0.29)
5
5
  csv
6
6
  htmlentities
7
7
  httpi
data/History.txt CHANGED
@@ -1,3 +1,6 @@
1
+ === 3.0.28 / 30.06.2026
2
+ * New (--artikelstamm-v5): optionally emit the legacy Elexis Artikelstamm Version 5 file in addition to the v6 file. --artikelstamm now always produces the v6 output (artikelstamm_DDMMYYYY_v6.xml/.csv, with the per-ITEM <ARTSL> BAG indication codes since 3.0.26); passing --artikelstamm-v5 implies --artikelstamm and additionally writes artikelstamm_DDMMYYYY_v5.xml/.csv in the older v5 format (namespace http://elexis.ch/Elexis_Artikelstamm_v5, no <ARTSL> block), validated against the bundled Elexis_Artikelstamm_v5.xsd. The shared download/prepare phase runs once, so both files come from a single build. For consumers that have not yet migrated to v6.
3
+
1
4
  === 3.0.27 / 29.06.2026
2
5
  * New (-e/--extended, -b/--firstbase): carry the BAG Indikationscode (INDC) per article in oddb_article.xml as well. Each SL price-model <ART> now gets one <INDICATION_CODE code="XXXXX.NN" cud_id="DRUG.NN" limcd="DRUG.NN" vdat="..." vtdat="...">limitation text</INDICATION_CODE> child per indication -- the same data as the v6 Artikelstamm <ARTSL> block (3.0.26), but in the flat <INDICATION_CODE> shape already emitted on <PRD> in oddb_product.xml, enriched with the limitation code (limcd) and validity dates (vdat/vtdat). The bundled oddb2xml.xsd now defines <INDICATION_CODE> and references it from both <ART> and <PRD> (the <PRD> reference was missing since the 3.0.7 product-feed addition). Only emitted in --fhir mode (the default), where limitations carry an indication code. See issue #113.
3
6
 
data/README.md CHANGED
@@ -57,6 +57,9 @@ see `--help`.
57
57
  produced files are found under data
58
58
  -a, --append Additional target nonpharma
59
59
  -r, --artikelstamm Create Artikelstamm Version 6 for Elexis >= 3.1
60
+ --artikelstamm-v5 Additionally create the legacy Artikelstamm Version 5
61
+ (without the <ARTSL> BAG indication codes).
62
+ Implies --artikelstamm.
60
63
  -c, --compress-ext=<s> format F. {tar.gz|zip}
61
64
  -e, --extended pharma, non-pharma plus prices and non-pharma from zurrose.
62
65
  Products without EAN-Code will also be listed.
@@ -371,6 +374,14 @@ The output bumps from Artikelstamm v5 to **v6** (namespace
371
374
  `artikelstamm_DDMMYYYY_v6.xml`, validated against the bundled
372
375
  `Elexis_Artikelstamm_v6.xsd`); consumers must switch to the v6 file.
373
376
 
377
+ Consumers that have not yet migrated can keep receiving the **legacy v5
378
+ file** by adding `--artikelstamm-v5` (implies `--artikelstamm`). This writes
379
+ `artikelstamm_DDMMYYYY_v5.xml`/`.csv` *in addition to* the v6 files, in the
380
+ older v5 format (namespace `http://elexis.ch/Elexis_Artikelstamm_v5`, no
381
+ `<ARTSL>` block, validated against the bundled `Elexis_Artikelstamm_v5.xsd`).
382
+ The download and data-preparation phase runs only once, so both versions are
383
+ produced from a single build.
384
+
374
385
  Since 3.0.27 the same indication codes are also carried per article in
375
386
  the `-e`/`--extended` and `-b`/`--firstbase` feeds: each SL price-model
376
387
  `<ART>` in `oddb_article.xml` gets one `<INDICATION_CODE>` child per
@@ -1622,9 +1622,17 @@ module Oddb2xml
1622
1622
  end
1623
1623
  end
1624
1624
 
1625
- def build_artikelstamm
1625
+ # Legacy entry point: emit the Artikelstamm in the older Version 5 format
1626
+ # (Elexis_Artikelstamm_v5, no per-ITEM <ARTSL> indication-code block).
1627
+ # Reached via subject :artikelstamm_v5 when --artikelstamm-v5 is set.
1628
+ def build_artikelstamm_v5
1629
+ build_artikelstamm(version: 5)
1630
+ end
1631
+
1632
+ def build_artikelstamm(version: 6)
1626
1633
  @@emitted_v6_gtins = []
1627
- @csv_file = CSV.open(File.join(WORK_DIR, "artikelstamm_#{Date.today.strftime("%d%m%Y")}_v6.csv"), "w+")
1634
+ @artikelstamm_version = version
1635
+ @csv_file = CSV.open(File.join(WORK_DIR, "artikelstamm_#{Date.today.strftime("%d%m%Y")}_v#{version}.csv"), "w+")
1628
1636
  @csv_file << ["gtin", "name", "pkg_size", "galenic_form", "price_ex_factory", "price_public", "prodno", "atc_code", "active_substance", "original", "it-code", "sl-liste"]
1629
1637
  @csv_file.sync = true
1630
1638
  variant = "build_artikelstamm"
@@ -1832,7 +1840,8 @@ module Oddb2xml
1832
1840
  end
1833
1841
  xml.PRODNO prodno if prodno
1834
1842
  # v6 <ARTSL>: per-article BAG Indikationscodes (issue #113).
1835
- append_artsl(xml, package[:limitations])
1843
+ # The legacy v5 schema has no <ARTSL> element, so skip it there.
1844
+ append_artsl(xml, package[:limitations]) if @artikelstamm_version != 5
1836
1845
  @csv_file << [pkg_gtin, name, package[:unit], measure,
1837
1846
  pexf || "",
1838
1847
  ppub || "",
@@ -1958,7 +1967,7 @@ module Oddb2xml
1958
1967
  elexis_strftime_format = "%FT%T\.%L%:z"
1959
1968
  @@cumul_ver = (Date.today.year - 2013) * 12 + Date.today.month
1960
1969
  options_xml = {
1961
- "xmlns" => "http://elexis.ch/Elexis_Artikelstamm_v6",
1970
+ "xmlns" => "http://elexis.ch/Elexis_Artikelstamm_v#{@artikelstamm_version}",
1962
1971
  "CREATION_DATETIME" => Time.new.strftime(elexis_strftime_format),
1963
1972
  "BUILD_DATETIME" => Time.new.strftime(elexis_strftime_format),
1964
1973
  "DATA_SOURCE" => "oddb2xml"
data/lib/oddb2xml/cli.rb CHANGED
@@ -103,14 +103,21 @@ module Oddb2xml
103
103
  end
104
104
  build
105
105
  if @options[:artikelstamm] && system("which xmllint")
106
- elexis_v6_xsd = File.expand_path(File.join(__FILE__, "..", "..", "..", "Elexis_Artikelstamm_v6.xsd"))
107
- cmd = "xmllint --noout --schema #{elexis_v6_xsd} #{@the_files[:artikelstamm]}"
108
- if system(cmd)
109
- puts "Validatied #{@the_files[:artikelstamm]}"
110
- else
111
- puts "Validating failed using #{cmd}"
112
- require'pry'; binding.pry
113
- raise "Validating failed using #{cmd}"
106
+ to_validate = {"Elexis_Artikelstamm_v6.xsd" => @the_files[:artikelstamm]}
107
+ if @options[:artikelstamm_v5] && @the_files[:artikelstamm_v5]
108
+ to_validate["Elexis_Artikelstamm_v5.xsd"] = @the_files[:artikelstamm_v5]
109
+ end
110
+ to_validate.each_pair do |xsd_name, xml_file|
111
+ xsd = File.expand_path(File.join(__FILE__, "..", "..", "..", xsd_name))
112
+ cmd = "xmllint --noout --schema #{xsd} #{xml_file}"
113
+ if system(cmd)
114
+ puts "Validatied #{xml_file}"
115
+ else
116
+ puts "Validating failed using #{cmd}"
117
+ require "pry"
118
+ binding.pry
119
+ raise "Validating failed using #{cmd}"
120
+ end
114
121
  end
115
122
  end
116
123
  compress if @options[:compress_ext]
@@ -400,6 +407,9 @@ module Oddb2xml
400
407
  end
401
408
  if @options[:artikelstamm]
402
409
  @the_files[:artikelstamm] = "artikelstamm_#{Date.today.strftime("%d%m%Y")}_v6.xml"
410
+ if @options[:artikelstamm_v5]
411
+ @the_files[:artikelstamm_v5] = "artikelstamm_#{Date.today.strftime("%d%m%Y")}_v5.xml"
412
+ end
403
413
  elsif @options[:address]
404
414
  @the_files[:company] = "#{prefix}_betrieb.xml"
405
415
  @the_files[:person] = "#{prefix}_medizinalperson.xml"
@@ -396,14 +396,53 @@ module Oddb2xml
396
396
  @url = BASE_URL
397
397
  end
398
398
 
399
+ # A valid firstbase export is a non-empty CSV. When GS1 is unavailable it
400
+ # answers with an HTML error page (the GetFirstbaseHealthcare endpoint has
401
+ # been returning "403 - Forbidden: Access is denied") or open-uri raises.
402
+ # The old "w+" download truncated firstbase.csv to zero bytes on any such
403
+ # failure, silently dropping every NONPHARMA article. Reject non-CSV bodies
404
+ # so the caller can keep the previous good firstbase.csv instead.
405
+ def firstbase_csv?(text)
406
+ return false if text.nil?
407
+ head = text[0, 512].to_s.strip.downcase
408
+ return false if head.empty?
409
+ return false if head.start_with?("<!doctype", "<html", "<?xml")
410
+ return false if head.include?("403 - forbidden") || head.include?("access is denied")
411
+ true
412
+ end
413
+
399
414
  def download
400
415
  @file2save = File.join(DOWNLOADS, "firstbase.csv")
401
416
  report_download(@url, @file2save)
402
- begin
403
- download_as(@file2save, "w+")
417
+ # Price-increment / Artikelstamm runs (--skip-download) reuse the cached
418
+ # firstbase.csv. Do NOT skip merely because the file exists: the nightly
419
+ # deploy seeds a last-good copy so a GS1 outage does not blank the feed,
420
+ # and we still want a fresh download attempt on the first (downloading)
421
+ # build so a recovered GS1 refreshes the data.
422
+ if Oddb2xml.skip_download? && File.size?(@file2save)
423
+ Oddb2xml.log "FirstbaseDownloader: --skip-download, reusing cached #{@file2save} (#{File.size(@file2save)} bytes)"
404
424
  return File.expand_path(@file2save)
425
+ end
426
+ begin
427
+ data = Oddb2xml.uri_open(@url).read
428
+ if firstbase_csv?(data)
429
+ File.write(@file2save, data)
430
+ Oddb2xml.log "FirstbaseDownloader: fetched fresh firstbase.csv (#{data.bytesize} bytes)"
431
+ elsif File.size?(@file2save)
432
+ Oddb2xml.log "FirstbaseDownloader: GS1 returned no CSV (#{data.to_s.bytesize} bytes); keeping existing #{@file2save} (#{File.size(@file2save)} bytes)"
433
+ else
434
+ Oddb2xml.log "FirstbaseDownloader: GS1 returned no CSV and there is no cached firstbase.csv to fall back to"
435
+ end
405
436
  rescue Timeout::Error, Errno::ETIMEDOUT
406
437
  retrievable? ? retry : raise
438
+ rescue => error
439
+ # 403 / blocked / unreachable: keep any existing firstbase.csv (e.g. the
440
+ # last-good copy the deploy script seeds) rather than truncating it.
441
+ if File.size?(@file2save)
442
+ Oddb2xml.log "FirstbaseDownloader: download failed (#{error.class}: #{error}); keeping existing #{@file2save} (#{File.size(@file2save)} bytes)"
443
+ else
444
+ Oddb2xml.log "FirstbaseDownloader: download failed (#{error.class}: #{error}) and no cached firstbase.csv to fall back to"
445
+ end
407
446
  ensure
408
447
  Oddb2xml.download_finished(@file2save, false)
409
448
  end
@@ -18,6 +18,7 @@ module Oddb2xml
18
18
  EOS
19
19
  opt :append, "Additional target nonpharma", default: false
20
20
  opt :artikelstamm, "Create Artikelstamm Version 6 for Elexis >= 3.1"
21
+ opt :artikelstamm_v5, "Additionally create the legacy Artikelstamm Version 5 (without the <ARTSL> BAG indication codes). Implies --artikelstamm.", short: :none, default: false
21
22
  opt :compress_ext, "format F. {tar.gz|zip}", type: :string, default: nil, short: "c"
22
23
  opt :extended, "pharma, non-pharma plus prices and non-pharma from zurrose.
23
24
  Products without EAN-Code will also be listed.
@@ -68,6 +69,11 @@ module Oddb2xml
68
69
  @opts[:price] = :zurrose
69
70
  @opts[:calc] = true
70
71
  end
72
+ # --artikelstamm-v5 additionally emits the legacy v5 file; it requires the
73
+ # full Artikelstamm build, so it implies --artikelstamm (which yields v6).
74
+ if @opts[:artikelstamm_v5]
75
+ @opts[:artikelstamm] = true
76
+ end
71
77
  if @opts[:artikelstamm]
72
78
  @opts[:extended] = true
73
79
  @opts[:price] = :zurrose
@@ -1,3 +1,3 @@
1
1
  module Oddb2xml
2
- VERSION = "3.0.27"
2
+ VERSION = "3.0.29"
3
3
  end
@@ -35,7 +35,13 @@ pharma="—"
35
35
  [[ -f "$ARTICLE_XML" ]] && pharma=$(grep -c '<SMNO>' "$ARTICLE_XML" || true)
36
36
 
37
37
  nonpharma="—"
38
- [[ -f "$FIRSTBASE_CSV" ]] && nonpharma=$(( $(wc -l < "$FIRSTBASE_CSV") - 1 ))
38
+ # Only count when the CSV actually has data rows. An empty firstbase.csv (the
39
+ # GS1 firstbase dump upstream returns 403/empty from time to time) would make a
40
+ # naive `rows - 1` render "-1"; keep the "—" fallback instead.
41
+ if [[ -s "$FIRSTBASE_CSV" ]]; then
42
+ fb_rows=$(wc -l < "$FIRSTBASE_CSV")
43
+ (( fb_rows > 1 )) && nonpharma=$(( fb_rows - 1 ))
44
+ fi
39
45
 
40
46
  stand=$(date '+%d.%m.%Y %H:%M')
41
47
 
@@ -148,6 +154,12 @@ cat > "$tmp" <<HTML
148
154
  <a href="default/oddb_code.xml">oddb_code.xml</a> sowie
149
155
  <a href="default/oddb2xml.zip">oddb2xml.zip</a> (alle Dateien gepackt).</p>
150
156
 
157
+ <h2>Elexis Artikelstamm</h2>
158
+ <ul>
159
+ <li><a href="artikelstamm/">artikelstamm/</a> <span class="desc">— Elexis Artikelstamm v6 (mit BAG-Indikationscodes) und Legacy-v5, je als XML und CSV, täglich aktualisiert (erzeugt mit oddb2xml)</span></li>
160
+ <li><a href="artikelstamm/rust2xml/">artikelstamm/rust2xml/</a> <span class="desc">— derselbe Artikelstamm v6 + v5, erzeugt mit <a href="https://github.com/zdavatz/rust2xml">rust2xml</a> (Rust-Port), täglich um 03:00 aus denselben Live-Quellen</span></li>
161
+ </ul>
162
+
151
163
  <h2>aips2sqlite — Fachinformationen &amp; AmiKo-Datenbanken</h2>
152
164
  <ul>
153
165
  <li><a href="/aips2sqlite/fis/">fis/</a> <span class="desc">— Fachinformationen als XML/HTML (DE/FR/IT)</span></li>
@@ -11,10 +11,12 @@
11
11
  # silently re-downloaded everything each time.
12
12
  #
13
13
  # Output layout (under $OUT_DIR, default /home/zdavatz/oddb2xml):
14
- # <OUT_DIR>/45/ oddb_*.xml built with +45 %
15
- # <OUT_DIR>/50/ oddb_*.xml built with +50 %
16
- # <OUT_DIR>/55/ oddb_*.xml built with +55 %
17
- # <OUT_DIR>/default/ oddb_*.xml built with no increment
14
+ # <OUT_DIR>/45/ oddb_*.xml built with +45 %
15
+ # <OUT_DIR>/50/ oddb_*.xml built with +50 %
16
+ # <OUT_DIR>/55/ oddb_*.xml built with +55 %
17
+ # <OUT_DIR>/default/ oddb_*.xml built with no increment
18
+ # <OUT_DIR>/artikelstamm/ Elexis Artikelstamm v6 + legacy v5 (xml + csv),
19
+ # served at https://mediupdatexml.oddb.org/artikelstamm
18
20
  # Each destination dir also keeps the source archive as oddb2xml.zip.
19
21
  # The working dir ($BUILD_DIR, default <OUT_DIR>-build) holds the shared
20
22
  # downloads/ cache and the transient zip; it lives OUTSIDE $OUT_DIR so the
@@ -79,6 +81,41 @@ rm -rf "$BUILD_DIR"
79
81
  mkdir -p "$BUILD_DIR"
80
82
  cd "$BUILD_DIR"
81
83
 
84
+ # 3. Seed the ZurRose transfer.zip from the local get_transfer mirror.
85
+ # get_transfer.sh (crontab 00:30) downloads transfer.dat straight from
86
+ # zurrose.ch on THIS host and uploads the zip to pillbox.oddb.org — so the
87
+ # pillbox HTTP fetch is a needless detour back to our own file. Placing the
88
+ # zip in downloads/ makes oddb2xml's skip_download reuse it and the build no
89
+ # longer depends on pillbox being up (2026-07-02: pillbox refused connections
90
+ # during the 01:00 run and all three retries died, killing the whole nightly
91
+ # build). If the seed file is missing, oddb2xml falls back to the normal
92
+ # pillbox download as before.
93
+ GET_TRANSFER_ZIP="${GET_TRANSFER_ZIP:-/home/zdavatz/software/get_transfer/TRANSFER.ZIP}"
94
+ if [[ -s "$GET_TRANSFER_ZIP" ]]; then
95
+ mkdir -p "$BUILD_DIR/downloads"
96
+ cp -p "$GET_TRANSFER_ZIP" "$BUILD_DIR/downloads/transfer.zip"
97
+ log "Seeded ZurRose transfer.zip from $GET_TRANSFER_ZIP ($(date -r "$GET_TRANSFER_ZIP" '+%Y-%m-%d %H:%M'), no pillbox fetch needed)"
98
+ else
99
+ log "WARNING: $GET_TRANSFER_ZIP missing - falling back to pillbox.oddb.org download"
100
+ fi
101
+
102
+ # 3b. Firstbase (GS1 NONPHARMA) fallback. The GS1 GetFirstbaseHealthcare
103
+ # endpoint has been answering "403 - Forbidden", which blanked firstbase.csv and
104
+ # dropped every NONPHARMA article from the -b feed. Keep the last successful
105
+ # firstbase.csv in a persistent cache OUTSIDE $BUILD_DIR (it survives the nightly
106
+ # `rm -rf`) and seed it into downloads/ so the gem's FirstbaseDownloader falls
107
+ # back to yesterday's file when today's download fails. A recovered GS1 still
108
+ # refreshes the data: the first (downloading) build always retries the live
109
+ # fetch and only keeps the seed when that fetch yields no CSV.
110
+ FIRSTBASE_CACHE="${FIRSTBASE_CACHE:-${OUT_DIR%/}-state/firstbase.csv}"
111
+ if [[ -s "$FIRSTBASE_CACHE" ]]; then
112
+ mkdir -p "$BUILD_DIR/downloads"
113
+ cp -p "$FIRSTBASE_CACHE" "$BUILD_DIR/downloads/firstbase.csv"
114
+ log "Seeded firstbase.csv from last-good cache $FIRSTBASE_CACHE ($(($(wc -l < "$FIRSTBASE_CACHE") - 1)) rows, $(date -r "$FIRSTBASE_CACHE" '+%Y-%m-%d %H:%M'))"
115
+ else
116
+ log "No firstbase last-good cache at $FIRSTBASE_CACHE yet - relying on live GS1 download"
117
+ fi
118
+
82
119
  first=1
83
120
 
84
121
  # build_one <increment-percent|""> <destination-subdir>
@@ -113,10 +150,60 @@ build_one() {
113
150
  log "Staged $dest"
114
151
  }
115
152
 
153
+ # build_artikelstamm — build the Elexis Artikelstamm (v6 + legacy v5) and stage
154
+ # it at $OUT_DIR/artikelstamm, served at
155
+ # https://mediupdatexml.oddb.org/artikelstamm. Re-uses the shared downloads/
156
+ # cache (--skip-download), so it adds no extra upstream fetch beyond the few
157
+ # sources the firstbase builds don't pull (e.g. the ZurRose transfer.dat).
158
+ build_artikelstamm() {
159
+ local dest="$OUT_DIR/artikelstamm"
160
+ log "Building Artikelstamm (v6 + v5) -> $dest"
161
+ rm -f artikelstamm_*.xml artikelstamm_*.csv
162
+ run_with_retry "oddb2xml artikelstamm" -- \
163
+ "$ODDB2XML_BIN" --skip-download --artikelstamm --artikelstamm-v5
164
+
165
+ shopt -s nullglob
166
+ local out=(artikelstamm_*.xml artikelstamm_*.csv)
167
+ shopt -u nullglob
168
+ [[ ${#out[@]} -ge 1 ]] || { log "ERROR: no artikelstamm output produced"; exit 1; }
169
+
170
+ mkdir -p "$dest"
171
+ # Remove only oddb2xml's own top-level files; keep sub-directories such as
172
+ # rust2xml/ (published independently by rust2xml's own cron at 03:00) intact.
173
+ # A plain `rm -rf "$dest"` used to wipe that sibling output every night.
174
+ rm -f "$dest"/artikelstamm_*.xml "$dest"/artikelstamm_*.csv
175
+ # Publish under date-less, stable names so the download URLs never change:
176
+ # artikelstamm_01072026_v6.xml -> artikelstamm_v6.xml (same for _v5 / .csv).
177
+ local f base
178
+ for f in "${out[@]}"; do
179
+ base="$(basename "$f" | sed -E 's/_[0-9]{8}_/_/')"
180
+ cp -p "$f" "$dest/$base"
181
+ done
182
+ log "Staged ${#out[@]} file(s) to $dest"
183
+ }
184
+
185
+ # Build order: default first (it downloads the shared sources), then the
186
+ # Artikelstamm right after so it is published early, then the price increments.
187
+ build_one "" "default" # first run: downloads sources, no increment
188
+
189
+ # Refresh the last-good firstbase.csv cache after the downloading build. When
190
+ # GS1 answered, downloads/firstbase.csv now holds fresh data; when it 403'd, the
191
+ # gem kept the seeded copy - either way a non-empty file is worth caching so the
192
+ # next run can fall back to it. An empty file means both today's download AND the
193
+ # seed were missing, so leave the previous cache untouched.
194
+ FIRSTBASE_LIVE="$BUILD_DIR/downloads/firstbase.csv"
195
+ if [[ -s "$FIRSTBASE_LIVE" ]]; then
196
+ mkdir -p "$(dirname "$FIRSTBASE_CACHE")"
197
+ cp -p "$FIRSTBASE_LIVE" "$FIRSTBASE_CACHE"
198
+ log "Cached firstbase.csv as last-good ($(($(wc -l < "$FIRSTBASE_LIVE") - 1)) rows) -> $FIRSTBASE_CACHE"
199
+ else
200
+ log "WARNING: firstbase.csv is empty after the build (GS1 403 and no cache) - NONPHARMA missing this run"
201
+ fi
202
+
203
+ build_artikelstamm # Elexis Artikelstamm (v6 + legacy v5)
116
204
  for inc in $INCREMENTS; do
117
- build_one "$inc" "$inc"
205
+ build_one "$inc" "$inc" # price increments re-use the cached downloads/
118
206
  done
119
- build_one "" "default" # final run with no increment
120
207
 
121
208
  # 2b. Refresh the download landing page with the live PHARMA/NONPHARMA counts
122
209
  # (PHARMA from default/oddb_article.xml, NONPHARMA from the GS1 firstbase CSV).
@@ -512,6 +512,36 @@ Der behandelnde Arzt ist verpflichtet, die erforderlichen Daten laufend im vorge
512
512
  end
513
513
  end
514
514
  end
515
+ context "when artikelstamm-v5 option is given" do
516
+ before(:all) do
517
+ common_run_init
518
+ options = Oddb2xml::Options.parse(["--artikelstamm-v5", "--no-fhir"])
519
+ Oddb2xml::Cli.new(options).run
520
+ @v6_name = File.join(Oddb2xml::WORK_DIR, "artikelstamm_#{Date.today.strftime("%d%m%Y")}_v6.xml")
521
+ @v5_name = File.join(Oddb2xml::WORK_DIR, "artikelstamm_#{Date.today.strftime("%d%m%Y")}_v5.xml")
522
+ end
523
+
524
+ it "emits both the v6 and the legacy v5 file" do
525
+ expect(File.exist?(@v6_name)).to eq true
526
+ expect(File.exist?(@v5_name)).to eq true
527
+ end
528
+
529
+ it "uses the v5 namespace in the v5 file and the v6 namespace in the v6 file" do
530
+ expect(IO.read(@v5_name)).to include("http://elexis.ch/Elexis_Artikelstamm_v5")
531
+ expect(IO.read(@v6_name)).to include("http://elexis.ch/Elexis_Artikelstamm_v6")
532
+ end
533
+
534
+ it "validates the v5 file against Elexis_Artikelstamm_v5.xsd" do
535
+ xsd = File.expand_path(File.join(__FILE__, "..", "..", "Elexis_Artikelstamm_v5.xsd"))
536
+ schema = Nokogiri::XML::Schema(File.read(xsd))
537
+ doc = Nokogiri::XML(File.read(@v5_name))
538
+ expect(schema.validate(doc)).to eq []
539
+ end
540
+
541
+ it "never emits an <ARTSL> block in the v5 file" do
542
+ expect(IO.read(@v5_name)).not_to include("<ARTSL>")
543
+ end
544
+ end
515
545
  context "chapter 70 hack" do
516
546
  before(:all) do
517
547
  mock_downloads
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: oddb2xml
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.27
4
+ version: 3.0.29
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yasuhiro Asaka, Zeno R.R. Davatz, Niklaus Giger