tradedangerous 12.7.6__py3-none-any.whl

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.
Files changed (87) hide show
  1. py.typed +1 -0
  2. trade.py +49 -0
  3. tradedangerous/__init__.py +43 -0
  4. tradedangerous/cache.py +1381 -0
  5. tradedangerous/cli.py +136 -0
  6. tradedangerous/commands/TEMPLATE.py +74 -0
  7. tradedangerous/commands/__init__.py +244 -0
  8. tradedangerous/commands/buildcache_cmd.py +102 -0
  9. tradedangerous/commands/buy_cmd.py +427 -0
  10. tradedangerous/commands/commandenv.py +372 -0
  11. tradedangerous/commands/exceptions.py +94 -0
  12. tradedangerous/commands/export_cmd.py +150 -0
  13. tradedangerous/commands/import_cmd.py +222 -0
  14. tradedangerous/commands/local_cmd.py +243 -0
  15. tradedangerous/commands/market_cmd.py +207 -0
  16. tradedangerous/commands/nav_cmd.py +252 -0
  17. tradedangerous/commands/olddata_cmd.py +270 -0
  18. tradedangerous/commands/parsing.py +221 -0
  19. tradedangerous/commands/rares_cmd.py +298 -0
  20. tradedangerous/commands/run_cmd.py +1521 -0
  21. tradedangerous/commands/sell_cmd.py +262 -0
  22. tradedangerous/commands/shipvendor_cmd.py +60 -0
  23. tradedangerous/commands/station_cmd.py +68 -0
  24. tradedangerous/commands/trade_cmd.py +181 -0
  25. tradedangerous/commands/update_cmd.py +67 -0
  26. tradedangerous/corrections.py +55 -0
  27. tradedangerous/csvexport.py +234 -0
  28. tradedangerous/db/__init__.py +27 -0
  29. tradedangerous/db/adapter.py +192 -0
  30. tradedangerous/db/config.py +107 -0
  31. tradedangerous/db/engine.py +259 -0
  32. tradedangerous/db/lifecycle.py +332 -0
  33. tradedangerous/db/locks.py +208 -0
  34. tradedangerous/db/orm_models.py +500 -0
  35. tradedangerous/db/paths.py +113 -0
  36. tradedangerous/db/utils.py +661 -0
  37. tradedangerous/edscupdate.py +565 -0
  38. tradedangerous/edsmupdate.py +474 -0
  39. tradedangerous/formatting.py +210 -0
  40. tradedangerous/fs.py +156 -0
  41. tradedangerous/gui.py +1146 -0
  42. tradedangerous/mapping.py +133 -0
  43. tradedangerous/mfd/__init__.py +103 -0
  44. tradedangerous/mfd/saitek/__init__.py +3 -0
  45. tradedangerous/mfd/saitek/directoutput.py +678 -0
  46. tradedangerous/mfd/saitek/x52pro.py +195 -0
  47. tradedangerous/misc/checkpricebounds.py +287 -0
  48. tradedangerous/misc/clipboard.py +49 -0
  49. tradedangerous/misc/coord64.py +83 -0
  50. tradedangerous/misc/csvdialect.py +57 -0
  51. tradedangerous/misc/derp-sentinel.py +35 -0
  52. tradedangerous/misc/diff-system-csvs.py +159 -0
  53. tradedangerous/misc/eddb.py +81 -0
  54. tradedangerous/misc/eddn.py +349 -0
  55. tradedangerous/misc/edsc.py +437 -0
  56. tradedangerous/misc/edsm.py +121 -0
  57. tradedangerous/misc/importeddbstats.py +54 -0
  58. tradedangerous/misc/prices-json-exp.py +179 -0
  59. tradedangerous/misc/progress.py +194 -0
  60. tradedangerous/plugins/__init__.py +249 -0
  61. tradedangerous/plugins/edcd_plug.py +371 -0
  62. tradedangerous/plugins/eddblink_plug.py +861 -0
  63. tradedangerous/plugins/edmc_batch_plug.py +133 -0
  64. tradedangerous/plugins/spansh_plug.py +2647 -0
  65. tradedangerous/prices.py +211 -0
  66. tradedangerous/submit-distances.py +422 -0
  67. tradedangerous/templates/Added.csv +37 -0
  68. tradedangerous/templates/Category.csv +17 -0
  69. tradedangerous/templates/RareItem.csv +143 -0
  70. tradedangerous/templates/TradeDangerous.sql +338 -0
  71. tradedangerous/tools.py +40 -0
  72. tradedangerous/tradecalc.py +1302 -0
  73. tradedangerous/tradedb.py +2320 -0
  74. tradedangerous/tradeenv.py +313 -0
  75. tradedangerous/tradeenv.pyi +109 -0
  76. tradedangerous/tradeexcept.py +131 -0
  77. tradedangerous/tradeorm.py +183 -0
  78. tradedangerous/transfers.py +192 -0
  79. tradedangerous/utils.py +243 -0
  80. tradedangerous/version.py +16 -0
  81. tradedangerous-12.7.6.dist-info/METADATA +106 -0
  82. tradedangerous-12.7.6.dist-info/RECORD +87 -0
  83. tradedangerous-12.7.6.dist-info/WHEEL +5 -0
  84. tradedangerous-12.7.6.dist-info/entry_points.txt +3 -0
  85. tradedangerous-12.7.6.dist-info/licenses/LICENSE +373 -0
  86. tradedangerous-12.7.6.dist-info/top_level.txt +2 -0
  87. tradegui.py +24 -0
@@ -0,0 +1,2647 @@
1
+ # tradedangerous/plugins/spansh_plug.py
2
+ # -----------------------------------------------------------------------------
3
+ # Spansh Import Plugin (new, defragmented)
4
+ #
5
+ # Behavioural contract:
6
+ # - Optimised for modify/update (churn-safe via service timestamps)
7
+ # - Streaming reader for huge top-level JSON array
8
+ # - Options: -O url=… | -O file=… (mutually exclusive), -O maxage=<float days>
9
+ # - JSON/intermediate in tmp/, CSV & .prices in data/
10
+ # - Warnings gated by verbosity; low-verbosity uses single-line progress
11
+ # - After import: export CSVs (incl. RareItem) and regenerate TradeDangerous.prices
12
+ # - Returns True from finish() to stop default flow
13
+ #
14
+ # DB/dialect specifics live in tradedangerous.db.utils (parse_ts, batch sizing, etc.)
15
+ # -----------------------------------------------------------------------------
16
+ from __future__ import annotations
17
+
18
+ from concurrent.futures import ThreadPoolExecutor, as_completed
19
+ from datetime import datetime, timedelta, timezone
20
+ from email.utils import parsedate_to_datetime
21
+ from importlib.resources import files as implib_files, as_file as implib_as_file
22
+ from pathlib import Path
23
+ import csv
24
+ import json # used for debug
25
+ import io
26
+ import os
27
+ import shutil
28
+ import sys
29
+ import time
30
+ import traceback
31
+ import typing
32
+
33
+ # SQLAlchemy
34
+ from sqlalchemy import MetaData, Table, select, insert, update, func, and_, or_, text, UniqueConstraint
35
+ from sqlalchemy.engine import Engine
36
+ from sqlalchemy.orm import Session
37
+
38
+ import ijson # Used for main stream
39
+ import urllib.request
40
+
41
+ # Framework modules
42
+ from tradedangerous import plugins, cache, csvexport # provided by project
43
+ from tradedangerous.cache import processImportFile
44
+
45
+ # DB helpers (dialect specifics live here)
46
+ from tradedangerous.db import utils as db_utils
47
+ from tradedangerous.db.lifecycle import ensure_fresh_db, reset_db
48
+ from tradedangerous.db.locks import station_advisory_lock
49
+
50
+
51
+ if typing.TYPE_CHECKING:
52
+ from collections.abc import Generator, Iterable, Mapping
53
+ from typing import Any, Optional
54
+ from tradedangerous import TradeDB, TradeEnv
55
+
56
+
57
+ DEFAULT_URL = "https://downloads.spansh.co.uk/galaxy_stations.json"
58
+
59
+
60
+ class ImportPlugin(plugins.ImportPluginBase):
61
+ """
62
+ Spansh galaxy dump importer:
63
+ - Consumes galaxy_stations.json (local file or remote URL)
64
+ - Updates System, Station, Ship/ShipVendor, Upgrade/UpgradeVendor, Item/StationItem
65
+ - Respects per-service freshness & optional maxage (days)
66
+ - Imports RareItem.csv via cache.processImportFile() AFTER systems/stations exist
67
+ - Exports CSVs (+RareItem) and rebuilds TradeDangerous.prices
68
+ """
69
+
70
+ pluginInfo = {
71
+ "name": "spansh",
72
+ "author": "TD Team",
73
+ "version": "2.1",
74
+ "minimum-tb-version": "1.76",
75
+ "description": "Imports Spansh galaxy dump and refreshes cache artefacts.",
76
+ }
77
+
78
+ # Correct option contract: dict name -> help text
79
+ pluginOptions = {
80
+ "url": "Remote URL to galaxy_stations.json (default if neither url nor file is given)",
81
+ "file": "Local path to galaxy_stations.json; use '-' to read from stdin",
82
+ "maxage": "Skip service sections older than <days> (float), evaluated per service",
83
+ "pricesonly": "Skip import/exports; regenerate TradeDangerous.prices only (for testing).",
84
+ "force_baseline": "If set, overwrite service blocks to Spansh baseline (from_live=0) and delete any extras.",
85
+ "skip_stationitems": "Skip exporting StationItem.csv (large). Env: TD_SKIP_STATIONITEM_EXPORT=1",
86
+ "progress_compact": "Use shorter one-line import status (or set env TD_PROGRESS_COMPACT=1).",
87
+ # --- EDCD sourcing (hardcoded URLs; can be disabled or overridden) ---
88
+ "no_edcd": "Disable EDCD preloads (categories, FDev tables) and EDCD rares import.",
89
+ "edcd_commodity": "Override URL or local path for EDCD commodity.csv.",
90
+ "edcd_outfitting": "Override URL or local path for EDCD outfitting.csv.",
91
+ "edcd_shipyard": "Override URL or local path for EDCD shipyard.csv.",
92
+ "edcd_rares": "Override URL or local path for EDCD rare_commodity.csv.",
93
+ # --- Extra Debug Options
94
+ "only_system": "Process only the system with this name or id64; still stream the real file.",
95
+ "debug_trace": "Emit compact JSONL decision logs to tmp/spansh_trace.jsonl (1 line per decision).",
96
+ }
97
+
98
+ # Hardcoded EDCD sources (raw GitHub)
99
+ EDCD_URLS = {
100
+ "commodity": "https://raw.githubusercontent.com/EDCD/FDevIDs/master/commodity.csv",
101
+ "outfitting": "https://raw.githubusercontent.com/EDCD/FDevIDs/master/outfitting.csv",
102
+ "shipyard": "https://raw.githubusercontent.com/EDCD/FDevIDs/master/shipyard.csv",
103
+ "rares": "https://raw.githubusercontent.com/EDCD/FDevIDs/master/rare_commodity.csv",
104
+ }
105
+
106
+ tdb: TradeDB
107
+ tdenv: TradeEnv
108
+ session: Session | None # this means you have to check it's been set, though
109
+ batch_size: int | None
110
+
111
+ # ------------------------------
112
+ # Construction & plumbing (REPLACEMENT)
113
+ #
114
+ def __init__(self, tdb, cmdenv):
115
+ super().__init__(tdb, cmdenv)
116
+ self.tdb = tdb
117
+ self.tdenv = cmdenv
118
+ self.session = None
119
+
120
+ # Paths (data/tmp) from env/config; fall back defensively
121
+ self.data_dir = Path(getattr(self.tdenv, "dataDir", getattr(self.tdb, "dataDir", "data"))).resolve()
122
+ self.tmp_dir = Path(getattr(self.tdenv, "tmpDir", getattr(self.tdb, "tmpDir", "tmp"))).resolve()
123
+ for p in (self.data_dir, self.tmp_dir):
124
+ try:
125
+ p.mkdir(parents=True, exist_ok=True)
126
+ except Exception as e:
127
+ raise CleanExit(f"Failed to create directory {p}: {e!r}") from None
128
+
129
+ # Batch size decided AFTER session is opened (see finish())
130
+ self.batch_size = None
131
+
132
+ # Verbosity gates
133
+ self._is_tty = sys.stderr.isatty() or sys.stdout.isatty()
134
+ self._debug_level = int(getattr(self.tdenv, "debug", 0) or 0) # -v levels
135
+ self._warn_enabled = bool(getattr(self.tdenv, "warn", None)) or (self._debug_level >= 3)
136
+
137
+ # Progress state
138
+ self._last_progress_time = 0.0
139
+
140
+ # Station type mapping (existing helper in this module)
141
+ self._station_type_map = self._build_station_type_map()
142
+
143
+ # Debug trace option
144
+ self.debug_trace = str(self.getOption("debug_trace") or "0").strip().lower() not in ("0", "", "false", "no")
145
+ self._trace_fp = None
146
+
147
+ # --------------------------------------
148
+ # Small tracing helper
149
+ #
150
+ def _trace(self, **evt) -> None:
151
+ """
152
+ Lightweight debug tracer. Writes one compact JSON line per call
153
+ into tmp/spansh_trace.jsonl when -O debug_trace=1 is set.
154
+ Has no side-effects on existing logic if disabled.
155
+ """
156
+ if not getattr(self, "debug_trace", False):
157
+ return
158
+ try:
159
+ # lazily open file handle if not yet opened
160
+ if not hasattr(self, "_trace_fp") or self._trace_fp is None:
161
+ tmp = getattr(self, "tmp_dir", Path("tmp"))
162
+ tmp.mkdir(parents=True, exist_ok=True)
163
+ self._trace_fp = (tmp / "spansh_trace.jsonl").open("a", encoding="utf-8")
164
+
165
+ # sanitize datetimes
166
+ for k, v in list(evt.items()):
167
+ if hasattr(v, "isoformat"):
168
+ evt[k] = v.isoformat()
169
+
170
+ self._trace_fp.write(json.dumps(evt, ensure_ascii=False) + "\n")
171
+ self._trace_fp.flush()
172
+ except Exception:
173
+ pass # never break main flow
174
+
175
+ # --- TD shim: seed 'Added' from templates (idempotent) ---
176
+ def _seed_added_from_templates(self, session) -> None:
177
+ """
178
+ Seed the legacy 'Added' table from the packaged CSV:
179
+ tradedangerous/templates/Added.csv
180
+
181
+ DB-agnostic; uses cache.processImportFile. No reliance on any templatesDir.
182
+ """
183
+ # Obtain a Traversable for the packaged resource and materialize to a real path
184
+ res = implib_files("tradedangerous").joinpath("templates", "Added.csv")
185
+ with implib_as_file(res) as csv_path:
186
+ if not csv_path.exists():
187
+ # Graceful failure so schedulers can retry
188
+ raise CleanExit(f"Packaged Added.csv not found: {csv_path}")
189
+ try:
190
+ processImportFile(
191
+ tdenv=self.tdenv,
192
+ session=session,
193
+ importPath=csv_path,
194
+ tableName="Added",
195
+ )
196
+ except Exception as e:
197
+ # Keep diagnostics, but avoid hard process exit
198
+ self._warn("Seeding 'Added' from templates failed; continuing without it.")
199
+ self._warn(f"{type(e).__name__}: {e}")
200
+ traceback.print_exc()
201
+ raise CleanExit("Failed to seed 'Added' table from templates.") from e # ^ contradiction?
202
+
203
+ # --------------------------------------
204
+ # EDCD Import Functions
205
+ #
206
+ def _acquire_edcd_files(self) -> dict[str, Path | None]:
207
+ """
208
+ Download (or resolve) EDCD CSVs to tmp/ with conditional caching.
209
+ Honors -O no_edcd=1 and per-file overrides:
210
+ - edcd_commodity, edcd_outfitting, edcd_shipyard, edcd_rares
211
+ Each override may be a local path or an http(s) URL.
212
+ Returns dict: {commodity,outfitting,shipyard,rares} -> Path or None.
213
+ """
214
+
215
+ def _resolve_one(opt_key: str, default_url: str, basename: str) -> Optional[Path]:
216
+ override = self.getOption(opt_key)
217
+ target = self.tmp_dir / f"edcd_{basename}.csv"
218
+ label = f"EDCD {basename}.csv"
219
+
220
+ # Explicit disable via empty override
221
+ if override is not None and str(override).strip() == "":
222
+ return None
223
+
224
+ # Local path override
225
+ if override and ("://" not in override):
226
+ p = Path(override)
227
+ if not p.exists():
228
+ cwd = getattr(self.tdenv, "cwDir", None)
229
+ if cwd:
230
+ p = Path(cwd, override)
231
+ if p.exists() and p.is_file():
232
+ return p.resolve()
233
+ override = None # fall back to URL
234
+
235
+ # URL (override or default)
236
+ url = override or default_url
237
+ try:
238
+ return self._download_with_cache(url, target, label=label)
239
+ except CleanExit:
240
+ return target if target.exists() else None
241
+ except Exception:
242
+ return target if target.exists() else None
243
+
244
+ if self.getOption("no_edcd"):
245
+ return {"commodity": None, "outfitting": None, "shipyard": None, "rares": None}
246
+
247
+ return {
248
+ "commodity": _resolve_one("edcd_commodity", self.EDCD_URLS["commodity"], "commodity"),
249
+ "outfitting": _resolve_one("edcd_outfitting", self.EDCD_URLS["outfitting"], "outfitting"),
250
+ "shipyard": _resolve_one("edcd_shipyard", self.EDCD_URLS["shipyard"], "shipyard"),
251
+ "rares": _resolve_one("edcd_rares", self.EDCD_URLS["rares"], "rare_commodity"),
252
+ }
253
+
254
+ # ---------- EDCD: Categories (add-only) ----------
255
+ #
256
+ def _edcd_import_categories_add_only(
257
+ self,
258
+ session: Session,
259
+ tables: dict[str, Table],
260
+ commodity_csv: Path,
261
+ ) -> int:
262
+ """
263
+ Read EDCD commodity.csv, extract distinct category names, and add any
264
+ missing Category rows. No updates, no deletes.
265
+
266
+ Deterministic + append-only behaviour:
267
+ - If Category is empty: seed the TD canonical categories with fixed IDs (1..16).
268
+ - If Category is non-empty: validate the canonical ID→name mapping; abort if drifted.
269
+ - Any new categories found in EDCD are appended with IDs > current max ID,
270
+ in deterministic (case-insensitive) name order.
271
+
272
+ Yes, we shoulda done it alphabetical in the first place, but we didn't, so
273
+ here we are.
274
+
275
+ Returns: number of rows inserted (seed + appended).
276
+ """
277
+ t_cat = tables["Category"]
278
+
279
+ # TD canonical mapping — frozen IDs
280
+ canonical_by_id: dict[int, str] = {
281
+ 1: "Metals",
282
+ 2: "Minerals",
283
+ 3: "Chemicals",
284
+ 4: "Foods",
285
+ 5: "Textiles",
286
+ 6: "Industrial Materials",
287
+ 7: "Medicines",
288
+ 8: "Legal Drugs",
289
+ 9: "Machinery",
290
+ 10: "Technology",
291
+ 11: "Weapons",
292
+ 12: "Consumer Items",
293
+ 13: "Slavery",
294
+ 14: "Waste",
295
+ 15: "NonMarketable",
296
+ 16: "Salvage",
297
+ }
298
+
299
+ inserted = 0
300
+
301
+ # Load existing categories
302
+ rows = session.execute(select(t_cat.c.category_id, t_cat.c.name)).all()
303
+ existing_by_id: dict[int, str] = {
304
+ int(cid): (str(name) if name is not None else "")
305
+ for (cid, name) in rows
306
+ }
307
+
308
+ # Seed canonical set if empty
309
+ if not existing_by_id:
310
+ seed_rows = [
311
+ {"category_id": cid, "name": name}
312
+ for cid, name in sorted(canonical_by_id.items(), key=lambda kv: kv[0])
313
+ ]
314
+ session.execute(insert(t_cat), seed_rows)
315
+ inserted += len(seed_rows)
316
+ existing_by_id = {cid: name for cid, name in canonical_by_id.items()}
317
+
318
+ # Sanity guardrail: detect drift
319
+ else:
320
+ for cid, expected_name in canonical_by_id.items():
321
+ if cid not in existing_by_id:
322
+ raise CleanExit(
323
+ "Category ID drift detected: "
324
+ f"missing canonical category_id={cid} (expected '{expected_name}'). "
325
+ "Refusing to proceed."
326
+ )
327
+ actual = (existing_by_id.get(cid) or "").strip()
328
+ if actual.lower() != expected_name.lower():
329
+ raise CleanExit(
330
+ "Category ID drift detected: "
331
+ f"category_id={cid} expected '{expected_name}' but found '{actual}'. "
332
+ "Refusing to proceed."
333
+ )
334
+
335
+ existing_lc = {
336
+ (str(n) or "").strip().lower()
337
+ for n in existing_by_id.values()
338
+ if n is not None
339
+ }
340
+
341
+ # Parse EDCD commodity.csv and collect category spellings (case-insensitive)
342
+ with open(commodity_csv, "r", encoding="utf-8", newline="") as fh:
343
+ reader = csv.DictReader(fh)
344
+
345
+ cat_col = None
346
+ for h in (reader.fieldnames or []):
347
+ if h and str(h).strip().lower() == "category":
348
+ cat_col = h
349
+ break
350
+ if cat_col is None:
351
+ raise CleanExit(f"EDCD commodity.csv missing 'category' column: {commodity_csv}")
352
+
353
+ # lk -> set(spellings)
354
+ seen: dict[str, set[str]] = {}
355
+
356
+ for row in reader:
357
+ raw = row.get(cat_col)
358
+ if not raw:
359
+ continue
360
+ name = str(raw).strip()
361
+ if not name:
362
+ continue
363
+
364
+ lk = name.lower()
365
+ if lk in existing_lc:
366
+ continue
367
+
368
+ seen.setdefault(lk, set()).add(name)
369
+
370
+ if not seen:
371
+ return inserted
372
+
373
+ # Deterministic selection of display name per lk
374
+ def _choose_name(spellings: set[str]) -> str:
375
+ # stable across rebuilds even if EDCD row order changes
376
+ return min(spellings, key=lambda s: (s.casefold(), s))
377
+
378
+ new_names: list[str] = [_choose_name(seen[lk]) for lk in sorted(seen.keys())]
379
+
380
+ max_id = max(existing_by_id.keys(), default=0)
381
+ to_add = []
382
+ next_id = max_id + 1
383
+ for nm in new_names:
384
+ to_add.append({"category_id": next_id, "name": nm})
385
+ next_id += 1
386
+
387
+ session.execute(insert(t_cat), to_add)
388
+ inserted += len(to_add)
389
+ return inserted
390
+
391
+ # ---------- EDCD: FDev tables (direct load) ----------
392
+ #
393
+
394
+ def _edcd_import_table_direct(self, session: Session, table: Table, csv_path: Path) -> int:
395
+ """
396
+ Upsert CSV rows into a table whose columns match CSV headers.
397
+ Prefers the table's primary key; if absent, falls back to a single-column
398
+ UNIQUE key (e.g. 'id' in FDev tables). Returns approx rows written.
399
+ """
400
+ # --- choose key columns for upsert ---
401
+ pk_cols = tuple(c.name for c in table.primary_key.columns)
402
+ key_cols: tuple[str, ...] = pk_cols
403
+
404
+ if not key_cols:
405
+ # Common case for EDCD FDev tables: UNIQUE(id) but no PK
406
+ if "id" in table.c:
407
+ key_cols = ("id",)
408
+ else:
409
+ # Try to discover a single-column UNIQUE constraint via reflection
410
+ try:
411
+ uniq_single = []
412
+ for cons in getattr(table, "constraints", set()):
413
+ if isinstance(cons, UniqueConstraint):
414
+ cols = tuple(col.name for col in cons.columns)
415
+ if len(cols) == 1:
416
+ uniq_single.append(cols[0])
417
+ if uniq_single:
418
+ key_cols = (uniq_single[0],)
419
+ except Exception:
420
+ pass
421
+
422
+ if not key_cols:
423
+ raise CleanExit(f"Table {table.name} has neither a primary key nor a single-column UNIQUE key; cannot upsert from EDCD")
424
+
425
+ # --- read CSV ---
426
+ with open(csv_path, "r", encoding="utf-8", newline="") as fh:
427
+ reader = csv.DictReader(fh)
428
+ cols = [c for c in (reader.fieldnames or []) if c in table.c]
429
+ if not cols:
430
+ return 0
431
+ rows = [{k: row.get(k) for k in cols} for row in reader]
432
+
433
+ if not rows:
434
+ return 0
435
+
436
+ # --- table-specific sanitation (fixes ck_fdo_mount / ck_fdo_guidance) ---
437
+ if table.name == "FDevOutfitting":
438
+ allowed_mount = {"Fixed", "Gimballed", "Turreted"}
439
+ allowed_guid = {"Dumbfire", "Seeker", "Swarm"}
440
+
441
+ def _norm(val, allowed):
442
+ if val is None:
443
+ return None
444
+ s = str(val).strip()
445
+ if not s or s not in allowed:
446
+ return None
447
+ return s
448
+
449
+ for r in rows:
450
+ if "mount" in r:
451
+ r["mount"] = _norm(r["mount"], allowed_mount)
452
+ if "guidance" in r:
453
+ r["guidance"] = _norm(r["guidance"], allowed_guid)
454
+
455
+ # --- perform upsert using chosen key columns ---
456
+ upd_cols = tuple(c for c in cols if c not in key_cols)
457
+
458
+ if db_utils.is_sqlite(session):
459
+ db_utils.sqlite_upsert_simple(session, table, rows=rows, key_cols=key_cols, update_cols=upd_cols)
460
+ return len(rows)
461
+
462
+ if db_utils.is_mysql(session):
463
+ db_utils.mysql_upsert_simple(session, table, rows=rows, key_cols=key_cols, update_cols=upd_cols)
464
+ return len(rows)
465
+
466
+ # Generic backend (read-then-insert/update)
467
+ for r in rows:
468
+ cond = and_(*[getattr(table.c, k) == r[k] for k in key_cols])
469
+ ext = session.execute(select(*[getattr(table.c, k) for k in key_cols]).where(cond)).first()
470
+ if ext is None:
471
+ session.execute(insert(table).values(**r))
472
+ elif upd_cols:
473
+ session.execute(update(table).where(cond).values(**{k: r[k] for k in upd_cols}))
474
+ return len(rows)
475
+
476
+ def _edcd_import_fdev_catalogs(self, session: Session, tables: dict[str, Table], *, outfitting_csv: Path, shipyard_csv: Path) -> tuple[int, int]:
477
+ u = self._edcd_import_table_direct(session, tables["FDevOutfitting"], outfitting_csv)
478
+ s = self._edcd_import_table_direct(session, tables["FDevShipyard"], shipyard_csv)
479
+ return (u, s)
480
+
481
+ # --------------------------------------
482
+ # Comparison Helpers
483
+ #
484
+ def _apply_vendor_block_per_rules(
485
+ self,
486
+ t_vendor: Table,
487
+ station_id: int,
488
+ ids: Iterable[int],
489
+ ts_sp: datetime,
490
+ *,
491
+ id_col: str,
492
+ ) -> tuple[int, int, int]:
493
+ """
494
+ Per-row rule for ShipVendor / UpgradeVendor:
495
+ - If db.modified > ts_sp: leave row.
496
+ - If db.modified == ts_sp: no-op.
497
+ - If db.modified < ts_sp: set modified = ts_sp.
498
+ Deletions:
499
+ - Remove rows missing in JSON if (db.modified <= ts_sp).
500
+ Returns (insert_count, update_count, delete_count).
501
+ """
502
+ keep_ids = {int(x) for x in ids if x is not None}
503
+ inserts = updates = deletes = 0
504
+
505
+ # --- INSERT missing (batch) ---
506
+ if keep_ids:
507
+ # Find which of keep_ids are missing
508
+ existing_ids = {
509
+ int(r[0]) for r in self.session.execute(
510
+ select(getattr(t_vendor.c, id_col)).where(
511
+ and_(t_vendor.c.station_id == station_id,
512
+ getattr(t_vendor.c, id_col).in_(keep_ids))
513
+ )
514
+ ).all()
515
+ }
516
+ to_insert = keep_ids - existing_ids
517
+ if to_insert:
518
+ self.session.execute(
519
+ insert(t_vendor),
520
+ [{id_col: vid, "station_id": station_id, "modified": ts_sp} for vid in to_insert]
521
+ )
522
+ inserts = len(to_insert)
523
+
524
+ # --- UPDATE only those with modified < ts_sp (batch) ---
525
+ if keep_ids:
526
+ res = self.session.execute(
527
+ update(t_vendor)
528
+ .where(
529
+ and_(
530
+ t_vendor.c.station_id == station_id,
531
+ getattr(t_vendor.c, id_col).in_(keep_ids),
532
+ or_(t_vendor.c.modified.is_(None), t_vendor.c.modified < ts_sp),
533
+ )
534
+ )
535
+ .values(modified=ts_sp)
536
+ )
537
+ # rowcount includes both existing rows (not inserts) whose modified was < ts_sp
538
+ updates = int(res.rowcount or 0)
539
+
540
+ # --- DELETE rows NOT in keep_ids, but only if <= ts_sp (single statement) ---
541
+ res = self.session.execute(
542
+ t_vendor.delete().where(
543
+ and_(
544
+ t_vendor.c.station_id == station_id,
545
+ ~getattr(t_vendor.c, id_col).in_(keep_ids) if keep_ids else True,
546
+ or_(t_vendor.c.modified.is_(None), t_vendor.c.modified <= ts_sp),
547
+ )
548
+ )
549
+ )
550
+ deletes = int(res.rowcount or 0)
551
+
552
+ return inserts, updates, deletes
553
+
554
+ def _sync_vendor_block_fast(
555
+ self,
556
+ tables: dict[str, Table],
557
+ *,
558
+ station_id: int,
559
+ entries: list[dict[str, Any]],
560
+ ts_sp: Optional[datetime],
561
+ kind: str, # "ship" or "module"
562
+ ) -> tuple[int, int]:
563
+ """
564
+ Fast, set-based vendor sync for a single station and one service (shipyard/outfitting).
565
+
566
+ Returns: (number_of_inserts_or_updates_on_vendor_links, deletions_count).
567
+ """
568
+ # Ensure we never write NULL into NOT NULL 'modified' columns.
569
+ ts_eff = (ts_sp or datetime.utcnow().replace(microsecond=0))
570
+
571
+ if kind == "ship":
572
+ t_master = tables["Ship"]
573
+ t_vendor = tables["ShipVendor"]
574
+ id_key = "shipId"
575
+ id_col = "ship_id"
576
+ master_rows = []
577
+ keep_ids: set[int] = set()
578
+ for e in entries:
579
+ if not isinstance(e, dict):
580
+ continue
581
+ ship_id = e.get(id_key)
582
+ name = e.get("name")
583
+ if ship_id is None or name is None:
584
+ continue
585
+ keep_ids.add(int(ship_id))
586
+ master_rows.append({"ship_id": ship_id, "name": name})
587
+
588
+ elif kind == "module":
589
+ t_master = tables["Upgrade"]
590
+ t_vendor = tables["UpgradeVendor"]
591
+ id_key = "moduleId"
592
+ id_col = "upgrade_id"
593
+ master_rows = []
594
+ keep_ids = set()
595
+ for e in entries:
596
+ if not isinstance(e, dict):
597
+ continue
598
+ up_id = e.get(id_key)
599
+ name = e.get("name")
600
+ if up_id is None or name is None:
601
+ continue
602
+ keep_ids.add(int(up_id))
603
+ master_rows.append({
604
+ "upgrade_id": up_id,
605
+ "name": name,
606
+ "class": e.get("class"),
607
+ "rating": e.get("rating"),
608
+ "ship": e.get("ship"),
609
+ })
610
+ else:
611
+ raise CleanExit(f"_sync_vendor_block_fast: unknown kind={kind!r}")
612
+
613
+ # 1) Ensure master rows exist (simple upsert, no timestamp guards).
614
+ if master_rows:
615
+ key_name = list(master_rows[0].keys())[0]
616
+ update_cols = tuple(k for k in master_rows[0].keys() if k != key_name)
617
+ if db_utils.is_sqlite(self.session):
618
+ db_utils.sqlite_upsert_simple(
619
+ self.session, t_master, rows=master_rows,
620
+ key_cols=(key_name,),
621
+ update_cols=update_cols,
622
+ )
623
+ elif db_utils.is_mysql(self.session):
624
+ db_utils.mysql_upsert_simple(
625
+ self.session, t_master, rows=master_rows,
626
+ key_cols=(key_name,),
627
+ update_cols=update_cols,
628
+ )
629
+ else:
630
+ for r in master_rows:
631
+ cond = bool(getattr(t_master.c, key_name) == r[key_name])
632
+ exists = self.session.execute(select(getattr(t_master.c, key_name)).where(cond)).first()
633
+ if exists is None:
634
+ self.session.execute(insert(t_master).values(**r))
635
+ else:
636
+ upd = {k: v for k, v in r.items() if k != key_name}
637
+ if upd:
638
+ self.session.execute(update(t_master).where(cond).values(**upd))
639
+
640
+ # 2) Link rows with timestamp guard for vendor tables.
641
+ wrote = 0
642
+ delc = 0
643
+ if keep_ids:
644
+ existing = {
645
+ int(r[0]): (r[1] or None)
646
+ for r in self.session.execute(
647
+ select(getattr(t_vendor.c, id_col), t_vendor.c.modified).where(
648
+ and_(t_vendor.c.station_id == station_id, getattr(t_vendor.c, id_col).in_(keep_ids))
649
+ )
650
+ ).all()
651
+ }
652
+ to_insert = keep_ids - set(existing.keys())
653
+ to_update = {
654
+ vid for vid, mod in existing.items()
655
+ if (mod is None) or (ts_eff > mod)
656
+ }
657
+ wrote = len(to_insert) + len(to_update)
658
+
659
+ vendor_rows = [{id_col: vid, "station_id": station_id, "modified": ts_eff} for vid in keep_ids]
660
+ if db_utils.is_sqlite(self.session):
661
+ db_utils.sqlite_upsert_modified(
662
+ self.session, t_vendor, rows=vendor_rows,
663
+ key_cols=(id_col, "station_id"),
664
+ modified_col="modified",
665
+ update_cols=(),
666
+ )
667
+ elif db_utils.is_mysql(self.session):
668
+ db_utils.mysql_upsert_modified(
669
+ self.session, t_vendor, rows=vendor_rows,
670
+ key_cols=(id_col, "station_id"),
671
+ modified_col="modified",
672
+ update_cols=(),
673
+ )
674
+ else:
675
+ for r in vendor_rows:
676
+ cond = and_(getattr(t_vendor.c, id_col) == r[id_col], t_vendor.c.station_id == station_id)
677
+ cur = self.session.execute(select(t_vendor.c.modified).where(cond)).first()
678
+ if cur is None:
679
+ self.session.execute(insert(t_vendor).values(**r))
680
+ else:
681
+ mod = cur[0]
682
+ if (mod is None) or (ts_eff > mod):
683
+ self.session.execute(update(t_vendor).where(cond).values(modified=ts_eff))
684
+
685
+ return wrote, delc
686
+
687
+ def _cleanup_absent_stations(self, tables: dict[str, Table], present_station_ids: set[int], json_ts: datetime) -> tuple[int, int, int]:
688
+ """
689
+ After streaming, delete baseline rows for stations absent from the JSON
690
+ if the JSON timestamp is >= row.modified. Never delete newer-than-JSON rows.
691
+ Returns (market_del, outfit_del, ship_del) counts.
692
+ """
693
+ t_si, t_uv, t_sv, t_st = tables["StationItem"], tables["UpgradeVendor"], tables["ShipVendor"], tables["Station"]
694
+
695
+ # All station ids in DB
696
+ all_sids = [int(r[0]) for r in self.session.execute(select(t_st.c.station_id)).all()]
697
+ absent = [sid for sid in all_sids if sid not in present_station_ids]
698
+ if not absent:
699
+ return (0, 0, 0)
700
+
701
+ # Markets: delete baseline rows (from_live=0) with modified <= json_ts
702
+ del_m = self.session.execute(
703
+ t_si.delete().where(
704
+ and_(
705
+ t_si.c.station_id.in_(absent),
706
+ t_si.c.from_live == 0,
707
+ or_(t_si.c.modified.is_(None), t_si.c.modified <= json_ts),
708
+ )
709
+ )
710
+ ).rowcount or 0
711
+
712
+ # Vendors: delete rows with modified <= json_ts
713
+ del_u = self.session.execute(
714
+ tables["UpgradeVendor"].delete().where(
715
+ and_(t_uv.c.station_id.in_(absent), or_(t_uv.c.modified.is_(None), t_uv.c.modified <= json_ts))
716
+ )
717
+ ).rowcount or 0
718
+ del_s = self.session.execute(
719
+ tables["ShipVendor"].delete().where(
720
+ and_(t_sv.c.station_id.in_(absent), or_(t_sv.c.modified.is_(None), t_sv.c.modified <= json_ts))
721
+ )
722
+ ).rowcount or 0
723
+
724
+ return (int(del_m), int(del_u), int(del_s))
725
+
726
+ def _sync_market_block_fast(
727
+ self,
728
+ tables: dict[str, Table],
729
+ categories: dict[str, int],
730
+ *,
731
+ station_id: int,
732
+ commodities: list[dict[str, Any]],
733
+ ts_sp: datetime,
734
+ ) -> tuple[int, int]:
735
+ """
736
+ Fast, set-based market sync for one station.
737
+
738
+ Returns: (number_of_inserts_or_updates_on_StationItem, deletions_count).
739
+ """
740
+ t_item, t_si = tables["Item"], tables["StationItem"]
741
+
742
+ item_rows: list[dict[str, Any]] = []
743
+ link_rows: list[dict[str, Any]] = []
744
+ keep_ids: set[int] = set()
745
+
746
+ for co in commodities:
747
+ if not isinstance(co, dict):
748
+ continue
749
+ fdev_id = co.get("commodityId")
750
+ name = co.get("name")
751
+ cat_name = co.get("category")
752
+ if fdev_id is None or name is None or cat_name is None:
753
+ continue
754
+
755
+ cat_id = categories.get(str(cat_name).lower())
756
+ if cat_id is None:
757
+ raise CleanExit(f'Unknown commodity category "{cat_name}"')
758
+
759
+ keep_ids.add(int(fdev_id))
760
+ item_rows.append({
761
+ "item_id": fdev_id,
762
+ "name": name,
763
+ "category_id": cat_id,
764
+ "fdev_id": fdev_id,
765
+ "ui_order": 0,
766
+ })
767
+
768
+ demand = co.get("demand")
769
+ supply = co.get("supply")
770
+ buy = co.get("buyPrice")
771
+ sell = co.get("sellPrice")
772
+
773
+ link_rows.append({
774
+ "station_id": station_id,
775
+ "item_id": fdev_id,
776
+ "demand_price": sell,
777
+ "demand_units": demand,
778
+ "demand_level": -1,
779
+ "supply_price": buy,
780
+ "supply_units": supply,
781
+ "supply_level": -1,
782
+ "from_live": 0,
783
+ "modified": ts_sp,
784
+ })
785
+
786
+ # 1) Upsert Items (simple)
787
+ if item_rows:
788
+ if db_utils.is_sqlite(self.session):
789
+ db_utils.sqlite_upsert_simple(
790
+ self.session, t_item, rows=item_rows,
791
+ key_cols=("item_id",),
792
+ update_cols=("name", "category_id", "fdev_id", "ui_order"),
793
+ )
794
+ elif db_utils.is_mysql(self.session):
795
+ db_utils.mysql_upsert_simple(
796
+ self.session, t_item, rows=item_rows,
797
+ key_cols=("item_id",),
798
+ update_cols=("name", "category_id", "fdev_id", "ui_order"),
799
+ )
800
+ else:
801
+ for r in item_rows:
802
+ exists = self.session.execute(
803
+ select(t_item.c.item_id).where(t_item.c.item_id == r["item_id"])
804
+ ).first()
805
+ if exists is None:
806
+ self.session.execute(insert(t_item).values(**r))
807
+ else:
808
+ self.session.execute(
809
+ update(t_item).where(t_item.c.item_id == r["item_id"]).values(
810
+ name=r["name"], category_id=r["category_id"], fdev_id=r["fdev_id"], ui_order=r["ui_order"]
811
+ )
812
+ )
813
+
814
+ # 2) Compute effective inserts/updates for StationItem (pre-check modified), then upsert
815
+ wrote = 0
816
+ if link_rows:
817
+ existing = {
818
+ (int(r[0]), int(r[1])): (r[2] or None)
819
+ for r in self.session.execute(
820
+ select(t_si.c.station_id, t_si.c.item_id, t_si.c.modified).where(
821
+ and_(t_si.c.station_id == station_id, t_si.c.item_id.in_(keep_ids))
822
+ )
823
+ ).all()
824
+ }
825
+ to_insert = {
826
+ (station_id, rid) for rid in keep_ids
827
+ if (station_id, rid) not in existing
828
+ }
829
+ to_update = {
830
+ (station_id, rid) for rid, mod in ((rid, existing.get((station_id, rid))) for rid in keep_ids)
831
+ if (mod is None) or (ts_sp is not None and ts_sp > mod)
832
+ }
833
+ wrote = len(to_insert) + len(to_update)
834
+
835
+ if db_utils.is_sqlite(self.session):
836
+ db_utils.sqlite_upsert_modified(
837
+ self.session, t_si, rows=link_rows,
838
+ key_cols=("station_id", "item_id"),
839
+ modified_col="modified",
840
+ update_cols=("demand_price", "demand_units", "demand_level",
841
+ "supply_price", "supply_units", "supply_level", "from_live"),
842
+ )
843
+ elif db_utils.is_mysql(self.session):
844
+ db_utils.mysql_upsert_modified(
845
+ self.session, t_si, rows=link_rows,
846
+ key_cols=("station_id", "item_id"),
847
+ modified_col="modified",
848
+ update_cols=("demand_price", "demand_units", "demand_level",
849
+ "supply_price", "supply_units", "supply_level", "from_live"),
850
+ )
851
+ else:
852
+ for r in link_rows:
853
+ row = self.session.execute(
854
+ select(t_si.c.modified).where(and_(
855
+ t_si.c.station_id == r["station_id"],
856
+ t_si.c.item_id == r["item_id"],
857
+ ))
858
+ ).first()
859
+ if row is None:
860
+ self.session.execute(insert(t_si).values(**r))
861
+ else:
862
+ dbm = row[0]
863
+ if dbm is None or r["modified"] > dbm:
864
+ self.session.execute(
865
+ update(t_si)
866
+ .where(and_(t_si.c.station_id == r["station_id"], t_si.c.item_id == r["item_id"]))
867
+ .values(**r)
868
+ )
869
+
870
+ # 3) Delete baseline rows missing from JSON, not newer than ts_sp
871
+ delc = 0
872
+ base_where = and_(
873
+ t_si.c.station_id == station_id,
874
+ t_si.c.from_live == 0,
875
+ or_(t_si.c.modified.is_(None), t_si.c.modified <= ts_sp),
876
+ )
877
+ if keep_ids:
878
+ delete_stmt = t_si.delete().where(and_(base_where, ~t_si.c.item_id.in_(keep_ids)))
879
+ else:
880
+ delete_stmt = t_si.delete().where(base_where)
881
+
882
+ res = self.session.execute(delete_stmt)
883
+ try:
884
+ delc = int(res.rowcount or 0)
885
+ except Exception:
886
+ delc = 0
887
+
888
+ return wrote, delc
889
+
890
+ # ------------------------------
891
+ # Lifecycle hooks
892
+ #
893
+ def run(self) -> bool:
894
+ """
895
+ Full orchestrator: acquisition → bootstrap → EDCD preload → import → rares → export.
896
+ Returns False to keep default flow suppressed.
897
+ """
898
+ started = time.time()
899
+
900
+ if self.getOption("pricesonly"):
901
+ try:
902
+ self._print("Regenerating TradeDangerous.prices …")
903
+ cache.regeneratePricesFile(self.tdb, self.tdenv)
904
+ self._print("Prices file generated.")
905
+ except Exception as e:
906
+ self._error(f"Prices regeneration failed: {e!r}")
907
+ return False
908
+ return False
909
+
910
+ # Acquire Spansh JSON
911
+ try:
912
+ source_path = self._acquire_source()
913
+ except CleanExit as ce:
914
+ self._warn(str(ce))
915
+ return False
916
+ except Exception as e:
917
+ self._error(f"Acquisition failed: {e!r}")
918
+ return False
919
+
920
+ # -------- Bootstrap DB (no cache rebuild here) --------
921
+ try:
922
+ backend = self.tdb.engine.dialect.name.lower()
923
+ data_dir = Path(getattr(self.tdenv, "dataDir", getattr(self.tdb, "dataDir", "data")))
924
+ metadata = getattr(self.tdb, "metadata", None)
925
+
926
+ summary = ensure_fresh_db(
927
+ backend=backend,
928
+ engine=self.tdb.engine,
929
+ data_dir=data_dir,
930
+ metadata=metadata,
931
+ mode="auto",
932
+ tdb=self.tdb,
933
+ tdenv=self.tdenv,
934
+ rebuild=False, # do not run buildCache here
935
+ )
936
+ self._print(
937
+ f"DB bootstrap: action={summary.get('action','kept')} "
938
+ f"reason={summary.get('reason','ok')} backend={summary.get('backend')}"
939
+ )
940
+
941
+ # No valid DB? Create full schema now (SQLite from canonical SQL; MariaDB via ORM)
942
+ if summary.get("action") == "needs_rebuild":
943
+ db_path = Path(self.tdb.engine.url.database or (data_dir / "TradeDangerous.db")) # SQLite only
944
+ self._print("No valid DB detected — creating full schema…")
945
+ reset_db(self.tdb.engine, db_path=db_path)
946
+
947
+ # Seed 'Added' once on a fresh schema
948
+ self.session = self._open_session()
949
+ self._seed_added_from_templates(self.session)
950
+ self.session.commit()
951
+ self._safe_close_session()
952
+
953
+ except Exception as e:
954
+ self._error(f"Database bootstrap failed: {e!r}")
955
+ return False
956
+
957
+ # -------- Session + batch + reflection --------
958
+ try:
959
+ self.session = self._open_session()
960
+ self.batch_size = self._resolve_batch_size()
961
+ tables = self._reflect_tables(self.session.get_bind())
962
+ except Exception as e:
963
+ self._error(f"Failed to open/reflect DB session: {e!r}")
964
+ return False
965
+
966
+ # -------- EDCD preloads (hardcoded URLs; can be disabled) --------
967
+ edcd = self._acquire_edcd_files()
968
+
969
+ # Categories (add-only) — COMMIT immediately so they persist even if later phases fail.
970
+ try:
971
+ if edcd.get("commodity"):
972
+ added = self._edcd_import_categories_add_only(self.session, tables, edcd["commodity"])
973
+ if added:
974
+ self._print(f"EDCD categories: added {added} new categories")
975
+ self.session.commit()
976
+ except CleanExit as ce:
977
+ self._warn(str(ce))
978
+ return False
979
+ except Exception as e:
980
+ self._warn(f"EDCD categories skipped due to error: {e!r}")
981
+
982
+ # FDev catalogs (outfitting, shipyard) — COMMIT immediately as well.
983
+ try:
984
+ if edcd.get("outfitting") and edcd.get("shipyard"):
985
+ u, s = self._edcd_import_fdev_catalogs(
986
+ self.session, tables,
987
+ outfitting_csv=edcd["outfitting"],
988
+ shipyard_csv=edcd["shipyard"],
989
+ )
990
+ if (u + s) > 0:
991
+ self._print(f"EDCD FDev: Outfitting upserts={u:,} Shipyard upserts={s:,}")
992
+ self.session.commit()
993
+ except Exception as e:
994
+ self._warn(f"EDCD FDev catalogs skipped due to error: {e!r}")
995
+
996
+ # Load categories (may have grown) before Spansh import
997
+ try:
998
+ categories = self._load_categories(self.session, tables)
999
+ except Exception as e:
1000
+ self._error(f"Failed to load categories: {e!r}")
1001
+ return False
1002
+
1003
+ # -------- Import Spansh JSON --------
1004
+ try:
1005
+ if self._debug_level < 1:
1006
+ self._print("This will take at least several minutes.")
1007
+ self._print("You can increase verbosity (-v) to get a sense of progress")
1008
+ self._print("Importing spansh data")
1009
+ stats = self._import_stream(source_path, categories, tables)
1010
+ self._end_live_status()
1011
+
1012
+ mk_e = stats.get("market_writes", 0) + stats.get("market_stations", 0)
1013
+ of_e = stats.get("outfit_writes", 0) + stats.get("outfit_stations", 0)
1014
+ sh_e = stats.get("ship_writes", 0) + stats.get("ship_stations", 0)
1015
+ self._print(
1016
+ f"Import complete — systems: {stats.get('systems',0):,} "
1017
+ f"stations: {stats.get('stations',0):,} "
1018
+ f"evaluated: markets≈{mk_e:,} outfitters≈{of_e:,} shipyards≈{sh_e:,} "
1019
+ f"kept: markets≈{stats.get('market_stations',0):,} outfitters≈{stats.get('outfit_stations',0):,} shipyards≈{stats.get('ship_stations',0):,}"
1020
+ )
1021
+ except CleanExit as ce:
1022
+ self._warn(str(ce))
1023
+ self._safe_close_session()
1024
+ return False
1025
+ except Exception as e:
1026
+ self._error(f"Import failed: {e!r}")
1027
+ self._safe_close_session()
1028
+ return False
1029
+
1030
+ # Enforce Item.ui_order
1031
+ try:
1032
+ t0 = time.time()
1033
+ self._enforce_ui_order(self.session, tables)
1034
+ self._print(f"ui_order enforced in {time.time()-t0:.2f}s")
1035
+ except Exception as e:
1036
+ self._error(f"ui_order enforcement failed: {e!r}")
1037
+ self._safe_close_session()
1038
+ return False
1039
+
1040
+ # Final commit for import phase
1041
+ try:
1042
+ self.session.commit()
1043
+ except Exception as e:
1044
+ self._warn(f"Commit failed at end of import; rolling back. Cause: {e!r}")
1045
+ self.session.rollback()
1046
+ self._safe_close_session()
1047
+ return False
1048
+
1049
+ self._safe_close_session()
1050
+
1051
+ # -------- Rares (prefer EDCD; fallback to template) --------
1052
+ try:
1053
+ t0 = time.time()
1054
+ if edcd.get("rares"):
1055
+ self._import_rareitems_edcd(edcd["rares"])
1056
+ else:
1057
+ self._import_rareitems()
1058
+ self._print(f"Rares imported in {time.time()-t0:.2f}s")
1059
+ except CleanExit as ce:
1060
+ self._warn(str(ce))
1061
+ return False
1062
+ except Exception as e:
1063
+ self._error(f"RareItem import failed: {e!r}")
1064
+ return False
1065
+
1066
+ # -------- Export (uses your parallel exporter already present) --------
1067
+ try:
1068
+ self._export_and_mirror() # timing + final print handled inside
1069
+ except Exception as e:
1070
+ self._error(f"Export failed: {e!r}")
1071
+ return False
1072
+
1073
+ elapsed = self._format_hms(time.time() - started)
1074
+ self._print(f"{elapsed} Done")
1075
+ return False
1076
+
1077
+
1078
+
1079
+ def finish(self) -> bool:
1080
+ """No-op: handled in run(); finish() won’t be called."""
1081
+ return True
1082
+
1083
+ # ------------------------------
1084
+ # Acquisition (url/file/stdin)
1085
+ # ------------------------------
1086
+
1087
+ def _acquire_source(self) -> Path:
1088
+ """Return a readable filesystem path to the JSON source (tmp/)."""
1089
+ url = self.getOption("url")
1090
+ file_ = self.getOption("file")
1091
+ cache_path = self.tmp_dir / "galaxy_stations.json"
1092
+
1093
+ if file_:
1094
+ if file_ == "-":
1095
+ self._print("Reading Spansh dump from stdin …")
1096
+ self._write_stream_to_file(sys.stdin.buffer, cache_path)
1097
+ return cache_path
1098
+ src = Path(file_)
1099
+ if not src.exists() or not src.is_file():
1100
+ raise CleanExit(f"Local file not found: {src}")
1101
+ return src.resolve()
1102
+
1103
+ if not url:
1104
+ url = DEFAULT_URL
1105
+
1106
+ # Pass a friendly label so progress says “Spansh dump”
1107
+ return self._download_with_cache(url, cache_path, label="Spansh dump")
1108
+
1109
+ def _download_with_cache(self, url: str, cache_path: Path, *, label: str = "download") -> Path:
1110
+ """Conditional download with HEAD Last-Modified and atomic .part."""
1111
+ remote_lm: Optional[datetime] = None
1112
+ try:
1113
+ req = urllib.request.Request(url, method="HEAD")
1114
+ with urllib.request.urlopen(req, timeout=30) as resp:
1115
+ lm_header = resp.headers.get("Last-Modified")
1116
+ if lm_header:
1117
+ try:
1118
+ remote_lm = parsedate_to_datetime(lm_header).astimezone(timezone.utc).replace(tzinfo=None)
1119
+ except Exception:
1120
+ remote_lm = None
1121
+ except Exception:
1122
+ pass
1123
+
1124
+ if cache_path.exists() and remote_lm:
1125
+ local_mtime = datetime.fromtimestamp(cache_path.stat().st_mtime, tz=timezone.utc).replace(tzinfo=None)
1126
+ if local_mtime >= remote_lm:
1127
+ self._print(f"Remote not newer; using cached {label}")
1128
+ return cache_path
1129
+
1130
+ self._print(f"Downloading {label} from {url} …")
1131
+ part = cache_path.with_suffix(cache_path.suffix + ".part")
1132
+ if part.exists():
1133
+ try:
1134
+ part.unlink()
1135
+ except Exception:
1136
+ pass
1137
+
1138
+ req = urllib.request.Request(url, method="GET")
1139
+ connect_timeout = 30
1140
+ chunk = 8 * 1024 * 1024 # 8 MiB
1141
+
1142
+ try:
1143
+ with urllib.request.urlopen(req, timeout=connect_timeout) as resp, open(part, "wb") as fh:
1144
+ total_hdr = resp.headers.get("Content-Length")
1145
+ total = int(total_hdr) if total_hdr and total_hdr.isdigit() else None
1146
+ downloaded = 0
1147
+ start = time.time()
1148
+
1149
+ while True:
1150
+ data = resp.read(chunk)
1151
+ if not data:
1152
+ break
1153
+ fh.write(data)
1154
+ downloaded += len(data)
1155
+ self._download_progress(downloaded, total, start, label=label)
1156
+
1157
+ part.replace(cache_path)
1158
+
1159
+ # Set mtime to Last-Modified if present on GET
1160
+ lm_header = None
1161
+ try:
1162
+ with urllib.request.urlopen(urllib.request.Request(url, method="HEAD"), timeout=10) as head2:
1163
+ lm_header = head2.headers.get("Last-Modified")
1164
+ except Exception:
1165
+ pass
1166
+ if lm_header:
1167
+ try:
1168
+ got_lm = parsedate_to_datetime(lm_header).astimezone(timezone.utc).replace(tzinfo=None)
1169
+ ts = got_lm.replace(tzinfo=timezone.utc).timestamp()
1170
+ os.utime(cache_path, (ts, ts))
1171
+ except Exception:
1172
+ pass
1173
+
1174
+ except Exception as e:
1175
+ try:
1176
+ if part.exists():
1177
+ part.unlink()
1178
+ except Exception:
1179
+ pass
1180
+ raise CleanExit(f"Download failed or timed out for {label}; skipping run ({e!r})") from None
1181
+
1182
+ self._print(f'Download complete: {label} → "{cache_path}"')
1183
+ return cache_path
1184
+
1185
+ def _download_progress(self, downloaded: int, total: Optional[int], start_ts: float, *, label: str = "download") -> None:
1186
+ now = time.time()
1187
+ if now - self._last_progress_time < 0.5 and self._debug_level < 1:
1188
+ return
1189
+ self._last_progress_time = now
1190
+
1191
+ rate = downloaded / max(now - start_ts, 1e-9)
1192
+ if total:
1193
+ pct = (downloaded / total) * 100.0
1194
+ msg = f"{label}: {self._fmt_bytes(downloaded)} / {self._fmt_bytes(total)} ({pct:5.1f}%) {self._fmt_bytes(rate)}/s"
1195
+ else:
1196
+ msg = f"{label}: {self._fmt_bytes(downloaded)} read {self._fmt_bytes(rate)}/s"
1197
+ self._live_status(msg)
1198
+
1199
+ def _write_stream_to_file(self, stream: io.BufferedReader, dest: Path) -> None:
1200
+ part = dest.with_suffix(dest.suffix + ".part")
1201
+ if part.exists():
1202
+ try:
1203
+ part.unlink()
1204
+ except Exception:
1205
+ pass
1206
+ written = 0
1207
+ start = time.time()
1208
+ try:
1209
+ with open(part, "wb") as fh:
1210
+ while True:
1211
+ buf = stream.read(8 * 1024 * 1024)
1212
+ if not buf:
1213
+ break
1214
+ fh.write(buf)
1215
+ written += len(buf)
1216
+ self._download_progress(written, None, start)
1217
+ part.replace(dest)
1218
+ except Exception as e:
1219
+ try:
1220
+ if part.exists():
1221
+ part.unlink()
1222
+ except Exception:
1223
+ pass
1224
+ raise CleanExit(f"Failed to read stdin into tmp file: {e!r})") from None
1225
+
1226
+ # ------------------------------
1227
+ # DB session / reflection
1228
+ # ------------------------------
1229
+ def _open_session(self) -> Session:
1230
+ """
1231
+ Create a DB session and apply per-connection bulk settings.
1232
+ """
1233
+ if hasattr(self.tdb, "Session") and callable(self.tdb.Session):
1234
+ sess = self.tdb.Session()
1235
+ elif hasattr(db_utils, "get_session"):
1236
+ sess = db_utils.get_session(self.tdb.engine)
1237
+ else:
1238
+ raise RuntimeError("No Session factory available")
1239
+
1240
+ # SQLite pragmas (non-fatal)
1241
+ try:
1242
+ if db_utils.is_sqlite(sess):
1243
+ db_utils.sqlite_set_bulk_pragmas(sess)
1244
+ except Exception:
1245
+ pass
1246
+
1247
+ # MySQL/MariaDB session tuning (non-fatal)
1248
+ try:
1249
+ if db_utils.is_mysql(sess):
1250
+ db_utils.mysql_set_bulk_session(sess)
1251
+ except Exception:
1252
+ pass
1253
+
1254
+ return sess
1255
+
1256
+ def _reflect_tables(self, engine: Engine) -> dict[str, Table]:
1257
+ meta = MetaData()
1258
+ names = [
1259
+ "System", "Station", "Item", "Category", "StationItem",
1260
+ "Ship", "ShipVendor", "Upgrade", "UpgradeVendor",
1261
+ "FDevOutfitting", "FDevShipyard", "RareItem",
1262
+ ]
1263
+ return {n: Table(n, meta, autoload_with=engine) for n in names}
1264
+
1265
+ # ------------------------------
1266
+ # Import (streaming JSON → upserts)
1267
+ # ------------------------------
1268
+ def _import_stream(self, source_path: Path, categories: dict[str, int], tables: dict[str, Table]) -> dict[str, int]:
1269
+ """
1270
+ Streaming importer with service-level maxage gating (FK-safe), using per-row rules.
1271
+
1272
+ FIXES:
1273
+ - Batch commits now honor utils.get_import_batch_size() across *all* parent/child ops.
1274
+ - System/Station increments are counted in stats and batch_ops.
1275
+ - Commit checks occur before each station is processed (outside advisory lock scope),
1276
+ reducing long transactions and making Ctrl-C loss less likely.
1277
+ """
1278
+ batch_ops = 0
1279
+ stats = {
1280
+ "systems": 0, "stations": 0,
1281
+ "market_stations": 0, "outfit_stations": 0, "ship_stations": 0,
1282
+ "market_writes": 0, "outfit_writes": 0, "ship_writes": 0,
1283
+ "commodities": 0,
1284
+ }
1285
+
1286
+ # NEW: initialize parse metrics for _progress_line(); iterator keeps these updated
1287
+ self._parse_bytes = 0
1288
+ self._parse_rate = 0.0
1289
+
1290
+ maxage_days = float(self.getOption("maxage")) if self.getOption("maxage") else None
1291
+ maxage_td = timedelta(days=maxage_days) if maxage_days is not None else None
1292
+ now_utc = datetime.utcnow()
1293
+
1294
+ try:
1295
+ json_ts = datetime.fromtimestamp(os.path.getmtime(source_path), tz=timezone.utc).replace(tzinfo=None)
1296
+ except Exception:
1297
+ json_ts = datetime.utcfromtimestamp(0)
1298
+
1299
+ seen_station_ids: set[int] = set()
1300
+ force_baseline = bool(self.getOption("force_baseline"))
1301
+
1302
+ def recent(ts: Optional[datetime]) -> bool:
1303
+ if ts is None:
1304
+ return not maxage_td # None -> True, otherwise False.
1305
+ if maxage_td is None:
1306
+ return True
1307
+ return (now_utc - ts) <= maxage_td
1308
+
1309
+ def svc_ts(st: dict[str, Any], key: str) -> Optional[datetime]:
1310
+ obj = st.get(key) or {}
1311
+ if not isinstance(obj, dict):
1312
+ return None
1313
+ return self._parse_ts(obj.get("updateTime"))
1314
+
1315
+ with open(source_path, "rb") as fh:
1316
+ for sys_idx, system_obj in enumerate(self._iter_top_level_json_array(fh), 1):
1317
+ sys_id64 = system_obj.get("id64")
1318
+ sys_name = system_obj.get("name")
1319
+ coords = system_obj.get("coords") or {}
1320
+ if sys_id64 is None or sys_name is None or not isinstance(coords, dict):
1321
+ if self._debug_level >= 3:
1322
+ self._warn(f"Skipping malformed system object at index {sys_idx}")
1323
+ continue
1324
+
1325
+ self._trace(phase="system", decision="consider", name=sys_name, id64=sys_id64)
1326
+
1327
+ # Collect stations (top-level + body-embedded)
1328
+ stations: list[dict[str, Any]] = []
1329
+ if isinstance(system_obj.get("stations"), list):
1330
+ stations.extend(system_obj["stations"])
1331
+ bodies = system_obj.get("bodies") or []
1332
+ if isinstance(bodies, list):
1333
+ for b in bodies:
1334
+ if isinstance(b, dict):
1335
+ stl = b.get("stations")
1336
+ if isinstance(stl, list):
1337
+ stations.extend(stl)
1338
+
1339
+ # --- System upsert ---
1340
+ t_system = tables["System"]
1341
+ x, y, z = coords.get("x"), coords.get("y"), coords.get("z")
1342
+ sys_modified = self._parse_ts(system_obj.get("updateTime"))
1343
+ self._upsert_system(t_system, int(sys_id64), str(sys_name), x, y, z, sys_modified)
1344
+
1345
+ # Count system progress and participate in batching
1346
+ stats["systems"] += 1
1347
+ batch_ops += 1
1348
+
1349
+ imported_station_modifieds: list[datetime] = []
1350
+
1351
+ for st in stations:
1352
+ # Periodic commit BEFORE processing the next station (outside any advisory locks)
1353
+ if (self.batch_size is not None) and (batch_ops >= self.batch_size):
1354
+ try:
1355
+ self.session.commit()
1356
+ batch_ops = 0
1357
+ except Exception as e:
1358
+ self._warn(f"Batch commit failed; rolling back. Cause: {e!r}")
1359
+ self.session.rollback()
1360
+
1361
+ name = st.get("name")
1362
+ sid = st.get("id")
1363
+ if not isinstance(name, str) or sid is None:
1364
+ continue
1365
+ station_id = int(sid)
1366
+ seen_station_ids.add(station_id)
1367
+ stats["stations"] += 1
1368
+ # Count at least one op per station so batching still progresses even if no vendor writes occur
1369
+ batch_ops += 1
1370
+
1371
+ # NEW: drive live progress from here (throttled inside _progress_line)
1372
+ self._progress_line(stats)
1373
+
1374
+ # Flags/timestamps
1375
+ has_market = bool(st.get("hasMarket") or ("market" in st))
1376
+ has_outfit = bool(st.get("hasOutfitting") or ("outfitting" in st))
1377
+ has_ship = bool(st.get("hasShipyard") or ("shipyard" in st))
1378
+ mkt_ts = svc_ts(st, "market")
1379
+ outf_ts = svc_ts(st, "outfitting")
1380
+ ship_ts = svc_ts(st, "shipyard")
1381
+ mkt_fresh = recent(mkt_ts)
1382
+ outf_fresh = recent(outf_ts)
1383
+ ship_fresh = recent(ship_ts)
1384
+
1385
+ # Station upsert (idempotent)
1386
+ t_station = tables["Station"]
1387
+ type_id, planetary = self._map_station_type(st.get("type"))
1388
+ pads = st.get("landingPads") or {}
1389
+ max_pad = self._derive_pad_size(pads)
1390
+ sflags = {
1391
+ "market": "Y" if has_market else "N",
1392
+ "blackmarket": "?" if st.get("hasBlackmarket") is None else ("Y" if st.get("hasBlackmarket") else "N"),
1393
+ "shipyard": "Y" if has_ship else "N",
1394
+ "outfitting": "Y" if has_outfit else "N",
1395
+ "rearm": "?" if st.get("hasRearm") is None else ("Y" if st.get("hasRearm") else "N"),
1396
+ "refuel": "?" if st.get("hasRefuel") is None else ("Y" if st.get("hasRefuel") else "N"),
1397
+ "repair": "?" if st.get("hasRepair") is None else ("Y" if st.get("hasRepair") else "N"),
1398
+ }
1399
+ st_modified = self._parse_ts(st.get("updateTime"))
1400
+ if st_modified:
1401
+ imported_station_modifieds.append(st_modified)
1402
+
1403
+ ls_from_star_val = st.get("distanceToArrival", 0)
1404
+ try:
1405
+ if ls_from_star_val is None:
1406
+ ls_from_star_val = 0
1407
+ else:
1408
+ ls_from_star_val = max(int(float(ls_from_star_val)), 0)
1409
+ except Exception:
1410
+ ls_from_star_val = 0
1411
+
1412
+ self._upsert_station(
1413
+ t_station, station_id=int(station_id), system_id=int(sys_id64), name=name,
1414
+ ls_from_star=ls_from_star_val, max_pad=max_pad,
1415
+ type_id=int(type_id), planetary=planetary, sflags=sflags, modified=st_modified
1416
+ )
1417
+
1418
+ # ----------------------------
1419
+ # Ship vendor
1420
+ # ----------------------------
1421
+ if has_ship and ship_fresh:
1422
+ ships = (st.get("shipyard") or {}).get("ships") or []
1423
+ if isinstance(ships, list) and ships:
1424
+ if force_baseline:
1425
+ wrote, _, delc = self._apply_vendor_block_per_rules(
1426
+ tables["ShipVendor"], station_id, (s.get("shipId") for s in ships if isinstance(s, dict)),
1427
+ ship_ts, id_col="ship_id",
1428
+ )
1429
+ if wrote or delc:
1430
+ stats["ship_writes"] += 1
1431
+ batch_ops += (wrote + delc)
1432
+ stats["ship_stations"] += 1
1433
+ else:
1434
+ wrote, delc = self._sync_vendor_block_fast(
1435
+ tables, station_id=station_id, entries=ships, ts_sp=ship_ts, kind="ship"
1436
+ )
1437
+ if wrote or delc:
1438
+ stats["ship_writes"] += 1
1439
+ batch_ops += (wrote + delc)
1440
+ stats["ship_stations"] += 1
1441
+ else:
1442
+ stats["ship_stations"] += 1
1443
+
1444
+ # ----------------------------
1445
+ # Outfitting vendor
1446
+ # ----------------------------
1447
+ if has_outfit and outf_fresh:
1448
+ modules = (st.get("outfitting") or {}).get("modules") or []
1449
+ if isinstance(modules, list) and modules:
1450
+ if force_baseline:
1451
+ wrote = self._upsert_outfitting(tables, station_id, modules, outf_ts)
1452
+ _, _, delc = self._apply_vendor_block_per_rules(
1453
+ tables["UpgradeVendor"], station_id,
1454
+ (m.get("moduleId") for m in modules if isinstance(m, dict)),
1455
+ outf_ts, id_col="upgrade_id",
1456
+ )
1457
+ if wrote or delc:
1458
+ stats["outfit_writes"] += 1
1459
+ batch_ops += (wrote + delc)
1460
+ stats["outfit_stations"] += 1
1461
+ else:
1462
+ wrote, delc = self._sync_vendor_block_fast(
1463
+ tables, station_id=station_id, entries=modules, ts_sp=outf_ts, kind="module"
1464
+ )
1465
+ if wrote or delc:
1466
+ stats["outfit_writes"] += 1
1467
+ batch_ops += (wrote + delc)
1468
+ stats["outfit_stations"] += 1
1469
+ else:
1470
+ stats["outfit_stations"] += 1
1471
+
1472
+ # ----------------------------
1473
+ # Market (commit check already happened before this station)
1474
+ # ----------------------------
1475
+ if has_market and mkt_fresh:
1476
+ commodities = (st.get("market") or {}).get("commodities") or []
1477
+ if isinstance(commodities, list) and commodities:
1478
+ # The advisory lock context pins lock + DML to the same connection/txn.
1479
+ with station_advisory_lock(self.session, station_id, timeout_seconds=0.2, max_retries=4) as got:
1480
+ if not got:
1481
+ # Could not acquire; try this station on a later pass
1482
+ continue
1483
+
1484
+ self._trace(phase="market", decision="process",
1485
+ station_id=station_id, commodities=len(commodities))
1486
+
1487
+ if force_baseline:
1488
+ wrote_i, wrote_si = self._upsert_market(
1489
+ tables, categories, station_id, commodities, mkt_ts
1490
+ )
1491
+ # Remove any extras unconditionally (baseline reset)
1492
+ t_si = tables["StationItem"]
1493
+ keep_ids = {
1494
+ int(co.get("commodityId"))
1495
+ for co in commodities
1496
+ if isinstance(co, dict) and co.get("commodityId") is not None
1497
+ }
1498
+ if keep_ids:
1499
+ self.session.execute(
1500
+ t_si.delete().where(
1501
+ and_(t_si.c.station_id == station_id, ~t_si.c.item_id.in_(keep_ids))
1502
+ )
1503
+ )
1504
+ stats["commodities"] += wrote_si
1505
+ if wrote_si or wrote_i:
1506
+ stats["market_writes"] += 1
1507
+ batch_ops += (wrote_i + wrote_si)
1508
+ stats["market_stations"] += 1
1509
+ else:
1510
+ wrote_links, delc = self._sync_market_block_fast(
1511
+ tables, categories,
1512
+ station_id=station_id,
1513
+ commodities=commodities,
1514
+ ts_sp=mkt_ts,
1515
+ )
1516
+ if wrote_links or delc:
1517
+ stats["market_writes"] += 1
1518
+ batch_ops += (wrote_links + delc)
1519
+ stats["market_stations"] += 1
1520
+ else:
1521
+ stats["market_stations"] += 1
1522
+
1523
+ # Baseline absent-station cleanup (global, after full stream)
1524
+ # We only remove baseline content (from_live=0 for markets; vendor links)
1525
+ # and only where modified <= json_ts, so anything newer (e.g. live/ZMQ) is preserved.
1526
+ try:
1527
+ if force_baseline and seen_station_ids:
1528
+ m_del, u_del, s_del = self._cleanup_absent_stations(
1529
+ tables,
1530
+ present_station_ids=seen_station_ids,
1531
+ json_ts=json_ts,
1532
+ )
1533
+ if (m_del + u_del + s_del) > 0 and self._debug_level >= 1:
1534
+ self._print(
1535
+ f"Baseline cleanup: markets={m_del:,} upgrades={u_del:,} ships={s_del:,}"
1536
+ )
1537
+ except Exception as e:
1538
+ self._warn(f"Absent-station cleanup skipped due to error: {e!r}")
1539
+
1540
+ return stats
1541
+
1542
+
1543
+ # ------------------------------
1544
+ # Upsert helpers
1545
+ # ------------------------------
1546
+
1547
+
1548
+ def _upsert_system(
1549
+ self, t_system: Table, system_id: int, name: str,
1550
+ x: Optional[float], y: Optional[float], z: Optional[float],
1551
+ modified: Optional[datetime],
1552
+ ) -> None:
1553
+ """
1554
+ Upsert System with timestamp guard.
1555
+ 'added' policy (when column exists):
1556
+ - INSERT: set added=20 (EDSM).
1557
+ - UPDATE: do not overwrite, unless existing added IS NULL → set to 20.
1558
+ """
1559
+ if modified is None:
1560
+ modified = datetime.utcfromtimestamp(0)
1561
+
1562
+ has_added_col = hasattr(t_system.c, "added")
1563
+
1564
+ row = {
1565
+ "system_id": system_id,
1566
+ "name": name,
1567
+ "pos_x": x, "pos_y": y, "pos_z": z,
1568
+ "modified": modified,
1569
+ }
1570
+ if has_added_col:
1571
+ row["added"] = 20 # EDSM on INSERT
1572
+
1573
+ if db_utils.is_sqlite(self.session):
1574
+ db_utils.sqlite_upsert_modified(
1575
+ self.session, t_system,
1576
+ rows=[row],
1577
+ key_cols=("system_id",),
1578
+ modified_col="modified",
1579
+ update_cols=("name", "pos_x", "pos_y", "pos_z"),
1580
+ )
1581
+ if has_added_col:
1582
+ self.session.execute(
1583
+ update(t_system)
1584
+ .where((t_system.c.system_id == system_id) & (t_system.c.added.is_(None)))
1585
+ .values(added=20)
1586
+ )
1587
+ return
1588
+
1589
+ if db_utils.is_mysql(self.session):
1590
+ db_utils.mysql_upsert_modified(
1591
+ self.session, t_system,
1592
+ rows=[row],
1593
+ key_cols=("system_id",),
1594
+ modified_col="modified",
1595
+ update_cols=("name", "pos_x", "pos_y", "pos_z"),
1596
+ )
1597
+ if has_added_col:
1598
+ self.session.execute(
1599
+ update(t_system)
1600
+ .where((t_system.c.system_id == system_id) & (t_system.c.added.is_(None)))
1601
+ .values(added=20)
1602
+ )
1603
+ return
1604
+
1605
+ # Generic fallback
1606
+ sel_cols = [t_system.c.modified]
1607
+ if has_added_col:
1608
+ sel_cols.append(t_system.c.added)
1609
+ existing = self.session.execute(
1610
+ select(*sel_cols).where(t_system.c.system_id == system_id)
1611
+ ).first()
1612
+
1613
+ if existing is None:
1614
+ self.session.execute(insert(t_system).values(**row))
1615
+ else:
1616
+ db_modified = existing[0]
1617
+ values = {"name": name, "pos_x": x, "pos_y": y, "pos_z": z}
1618
+ if db_modified is None or modified > db_modified:
1619
+ values["modified"] = modified
1620
+ self.session.execute(
1621
+ update(t_system)
1622
+ .where(t_system.c.system_id == system_id)
1623
+ .values(**values)
1624
+ )
1625
+ if has_added_col:
1626
+ db_added = existing[1] if len(existing) > 1 else None
1627
+ if db_added is None:
1628
+ self.session.execute(
1629
+ update(t_system)
1630
+ .where((t_system.c.system_id == system_id) & (t_system.c.added.is_(None)))
1631
+ .values(added=20)
1632
+ )
1633
+
1634
+ def _upsert_station(
1635
+ self, t_station: Table, station_id: int, system_id: int, name: str,
1636
+ ls_from_star: Optional[float], max_pad: str, type_id: int, planetary: str,
1637
+ sflags: dict[str, str], modified: Optional[datetime],
1638
+ ) -> None:
1639
+ """
1640
+ Upsert Station with timestamp guard.
1641
+ """
1642
+ if modified is None:
1643
+ modified = datetime.utcfromtimestamp(0)
1644
+
1645
+ if db_utils.is_sqlite(self.session):
1646
+ db_utils.sqlite_upsert_modified(
1647
+ self.session, t_station,
1648
+ rows=[{
1649
+ "station_id": station_id,
1650
+ "system_id": system_id,
1651
+ "name": name,
1652
+ "ls_from_star": ls_from_star,
1653
+ "max_pad_size": max_pad,
1654
+ "type_id": type_id,
1655
+ "planetary": planetary,
1656
+ "market": sflags["market"],
1657
+ "blackmarket": sflags["blackmarket"],
1658
+ "shipyard": sflags["shipyard"],
1659
+ "outfitting": sflags["outfitting"],
1660
+ "rearm": sflags["rearm"],
1661
+ "refuel": sflags["refuel"],
1662
+ "repair": sflags["repair"],
1663
+ "modified": modified,
1664
+ }],
1665
+ key_cols=("station_id",),
1666
+ modified_col="modified",
1667
+ update_cols=(
1668
+ "system_id", "name", "ls_from_star", "max_pad_size", "type_id", "planetary",
1669
+ "market", "blackmarket", "shipyard", "outfitting", "rearm", "refuel", "repair",
1670
+ ),
1671
+ )
1672
+ return
1673
+
1674
+ if db_utils.is_mysql(self.session):
1675
+ db_utils.mysql_upsert_modified(
1676
+ self.session, t_station,
1677
+ rows=[{
1678
+ "station_id": station_id,
1679
+ "system_id": system_id,
1680
+ "name": name,
1681
+ "ls_from_star": ls_from_star,
1682
+ "max_pad_size": max_pad,
1683
+ "type_id": type_id,
1684
+ "planetary": planetary,
1685
+ "market": sflags["market"],
1686
+ "blackmarket": sflags["blackmarket"],
1687
+ "shipyard": sflags["shipyard"],
1688
+ "outfitting": sflags["outfitting"],
1689
+ "rearm": sflags["rearm"],
1690
+ "refuel": sflags["refuel"],
1691
+ "repair": sflags["repair"],
1692
+ "modified": modified,
1693
+ }],
1694
+ key_cols=("station_id",),
1695
+ modified_col="modified",
1696
+ update_cols=(
1697
+ "system_id", "name", "ls_from_star", "max_pad_size", "type_id", "planetary",
1698
+ "market", "blackmarket", "shipyard", "outfitting", "rearm", "refuel", "repair",
1699
+ ),
1700
+ )
1701
+ return
1702
+
1703
+ # Generic fallback
1704
+ row = self.session.execute(
1705
+ select(t_station.c.system_id, t_station.c.modified)
1706
+ .where(t_station.c.station_id == station_id)
1707
+ ).first()
1708
+
1709
+ if row is None:
1710
+ self.session.execute(
1711
+ insert(t_station).values(
1712
+ station_id=station_id,
1713
+ system_id=system_id,
1714
+ name=name,
1715
+ ls_from_star=ls_from_star,
1716
+ max_pad_size=max_pad,
1717
+ type_id=type_id,
1718
+ planetary=planetary,
1719
+ market=sflags["market"],
1720
+ blackmarket=sflags["blackmarket"],
1721
+ shipyard=sflags["shipyard"],
1722
+ outfitting=sflags["outfitting"],
1723
+ rearm=sflags["rearm"],
1724
+ refuel=sflags["refuel"],
1725
+ repair=sflags["repair"],
1726
+ modified=modified,
1727
+ )
1728
+ )
1729
+ else:
1730
+ db_system_id, db_modified = row
1731
+ values = {
1732
+ "name": name,
1733
+ "ls_from_star": ls_from_star,
1734
+ "max_pad_size": max_pad,
1735
+ "type_id": type_id,
1736
+ "planetary": planetary,
1737
+ "market": sflags["market"],
1738
+ "blackmarket": sflags["blackmarket"],
1739
+ "shipyard": sflags["shipyard"],
1740
+ "outfitting": sflags["outfitting"],
1741
+ "rearm": sflags["rearm"],
1742
+ "refuel": sflags["refuel"],
1743
+ "repair": sflags["repair"],
1744
+ }
1745
+ if db_system_id != system_id:
1746
+ values["system_id"] = system_id
1747
+ if db_modified is None or modified > db_modified:
1748
+ values["modified"] = modified
1749
+
1750
+ self.session.execute(
1751
+ update(t_station)
1752
+ .where(t_station.c.station_id == station_id)
1753
+ .values(**values)
1754
+ )
1755
+
1756
+ def _upsert_shipyard(self, tables: dict[str, Table], station_id: int, ships: list[dict[str, Any]], ts: datetime) -> int:
1757
+ t_ship, t_vendor = tables["Ship"], tables["ShipVendor"]
1758
+ ship_rows, vendor_rows = [], []
1759
+
1760
+ for sh in ships:
1761
+ ship_id = sh.get("shipId")
1762
+ name = sh.get("name")
1763
+ if ship_id is None or name is None:
1764
+ continue
1765
+ ship_rows.append({"ship_id": ship_id, "name": name})
1766
+ vendor_rows.append({"ship_id": ship_id, "station_id": station_id, "modified": ts})
1767
+
1768
+ if ship_rows:
1769
+ if db_utils.is_sqlite(self.session):
1770
+ db_utils.sqlite_upsert_simple(self.session, t_ship, rows=ship_rows, key_cols=("ship_id",), update_cols=("name",))
1771
+ elif db_utils.is_mysql(self.session):
1772
+ db_utils.mysql_upsert_simple(self.session, t_ship, rows=ship_rows, key_cols=("ship_id",), update_cols=("name",))
1773
+ else:
1774
+ for r in ship_rows:
1775
+ exists = self.session.execute(select(t_ship.c.name).where(t_ship.c.ship_id == r["ship_id"])).first()
1776
+ if exists is None:
1777
+ self.session.execute(insert(t_ship).values(**r))
1778
+ elif exists[0] != r["name"]:
1779
+ self.session.execute(update(t_ship).where(t_ship.c.ship_id == r["ship_id"]).values(name=r["name"]))
1780
+
1781
+ wrote = 0
1782
+ if vendor_rows:
1783
+ if db_utils.is_sqlite(self.session):
1784
+ db_utils.sqlite_upsert_modified(self.session, t_vendor, rows=vendor_rows,
1785
+ key_cols=("ship_id", "station_id"), modified_col="modified", update_cols=())
1786
+ wrote = len(vendor_rows)
1787
+ elif db_utils.is_mysql(self.session):
1788
+ db_utils.mysql_upsert_modified(self.session, t_vendor, rows=vendor_rows,
1789
+ key_cols=("ship_id", "station_id"), modified_col="modified", update_cols=())
1790
+ wrote = len(vendor_rows)
1791
+ else:
1792
+ for r in vendor_rows:
1793
+ ven = self.session.execute(
1794
+ select(t_vendor.c.modified).where(and_(t_vendor.c.ship_id == r["ship_id"], t_vendor.c.station_id == r["station_id"]))
1795
+ ).first()
1796
+ if ven is None:
1797
+ self.session.execute(insert(t_vendor).values(**r))
1798
+ wrote += 1
1799
+ else:
1800
+ dbm = ven[0]
1801
+ if dbm is None or r["modified"] > dbm:
1802
+ self.session.execute(
1803
+ update(t_vendor)
1804
+ .where(and_(t_vendor.c.ship_id == r["ship_id"], t_vendor.c.station_id == r["station_id"]))
1805
+ .values(modified=r["modified"])
1806
+ )
1807
+ wrote += 1
1808
+ return wrote
1809
+
1810
+ def _upsert_outfitting(self, tables: dict[str, Table], station_id: int, modules: list[dict[str, Any]], ts: datetime) -> int:
1811
+ t_up, t_vendor = tables["Upgrade"], tables["UpgradeVendor"]
1812
+ up_rows, vendor_rows = [], []
1813
+
1814
+ for mo in modules:
1815
+ up_id = mo.get("moduleId")
1816
+ name = mo.get("name")
1817
+ cls = mo.get("class")
1818
+ rating = mo.get("rating")
1819
+ ship = mo.get("ship")
1820
+ if up_id is None or name is None:
1821
+ continue
1822
+
1823
+ up_rows.append({"upgrade_id": up_id, "name": name, "class": cls, "rating": rating, "ship": ship})
1824
+ vendor_rows.append({"upgrade_id": up_id, "station_id": station_id, "modified": ts})
1825
+
1826
+ if up_rows:
1827
+ if db_utils.is_sqlite(self.session):
1828
+ db_utils.sqlite_upsert_simple(self.session, t_up, rows=up_rows, key_cols=("upgrade_id",),
1829
+ update_cols=("name", "class", "rating", "ship"))
1830
+ elif db_utils.is_mysql(self.session):
1831
+ db_utils.mysql_upsert_simple(self.session, t_up, rows=up_rows, key_cols=("upgrade_id",),
1832
+ update_cols=("name", "class", "rating", "ship"))
1833
+ else:
1834
+ for r in up_rows:
1835
+ exists = self.session.execute(select(t_up.c.upgrade_id).where(t_up.c.upgrade_id == r["upgrade_id"])).first()
1836
+ if exists is None:
1837
+ self.session.execute(insert(t_up).values(**r))
1838
+ else:
1839
+ self.session.execute(
1840
+ update(t_up).where(t_up.c.upgrade_id == r["upgrade_id"]).values(
1841
+ name=r["name"], **{"class": r["class"]}, rating=r["rating"], ship=r["ship"]
1842
+ )
1843
+ )
1844
+
1845
+ wrote = 0
1846
+ if vendor_rows:
1847
+ if db_utils.is_sqlite(self.session):
1848
+ db_utils.sqlite_upsert_modified(self.session, t_vendor, rows=vendor_rows,
1849
+ key_cols=("upgrade_id", "station_id"), modified_col="modified", update_cols=())
1850
+ wrote = len(vendor_rows)
1851
+ elif db_utils.is_mysql(self.session):
1852
+ db_utils.mysql_upsert_modified(self.session, t_vendor, rows=vendor_rows,
1853
+ key_cols=("upgrade_id", "station_id"), modified_col="modified", update_cols=())
1854
+ wrote = len(vendor_rows)
1855
+ else:
1856
+ for r in vendor_rows:
1857
+ ven = self.session.execute(
1858
+ select(t_vendor.c.modified).where(and_(t_vendor.c.upgrade_id == r["upgrade_id"], t_vendor.c.station_id == r["station_id"]))
1859
+ ).first()
1860
+ if ven is None:
1861
+ self.session.execute(insert(t_vendor).values(**r))
1862
+ wrote += 1
1863
+ else:
1864
+ dbm = ven[0]
1865
+ if dbm is None or r["modified"] > dbm:
1866
+ self.session.execute(
1867
+ update(t_vendor)
1868
+ .where(and_(t_vendor.c.upgrade_id == r["upgrade_id"], t_vendor.c.station_id == r["station_id"]))
1869
+ .values(modified=r["modified"])
1870
+ )
1871
+ wrote += 1
1872
+ return wrote
1873
+
1874
+ def _upsert_market(
1875
+ self,
1876
+ tables: dict[str, Table],
1877
+ categories: dict[str, int],
1878
+ station_id: int,
1879
+ commodities: list[dict[str, Any]],
1880
+ ts: datetime,
1881
+ ) -> tuple[int, int]:
1882
+ t_item, t_si = tables["Item"], tables["StationItem"]
1883
+ item_rows, link_rows = [], []
1884
+ wrote_items = 0
1885
+
1886
+ for co in commodities:
1887
+ fdev_id = co.get("commodityId")
1888
+ name = co.get("name")
1889
+ cat_name = co.get("category")
1890
+ if fdev_id is None or name is None or cat_name is None:
1891
+ continue
1892
+
1893
+ cat_id = categories.get(str(cat_name).lower())
1894
+ if cat_id is None:
1895
+ raise CleanExit(f'Unknown commodity category "{cat_name}"')
1896
+
1897
+ item_rows.append({"item_id": fdev_id, "name": name, "category_id": cat_id, "fdev_id": fdev_id, "ui_order": 0})
1898
+
1899
+ demand = co.get("demand")
1900
+ supply = co.get("supply")
1901
+ buy = co.get("buyPrice")
1902
+ sell = co.get("sellPrice")
1903
+
1904
+ link_rows.append({
1905
+ "station_id": station_id,
1906
+ "item_id": fdev_id,
1907
+ "demand_price": sell,
1908
+ "demand_units": demand,
1909
+ "demand_level": -1,
1910
+ "supply_price": buy,
1911
+ "supply_units": supply,
1912
+ "supply_level": -1,
1913
+ "from_live": 0,
1914
+ "modified": ts,
1915
+ })
1916
+
1917
+ if item_rows:
1918
+ if db_utils.is_sqlite(self.session):
1919
+ db_utils.sqlite_upsert_simple(self.session, t_item, rows=item_rows, key_cols=("item_id",),
1920
+ update_cols=("name", "category_id", "fdev_id", "ui_order"))
1921
+ elif db_utils.is_mysql(self.session):
1922
+ db_utils.mysql_upsert_simple(self.session, t_item, rows=item_rows, key_cols=("item_id",),
1923
+ update_cols=("name", "category_id", "fdev_id", "ui_order"))
1924
+ else:
1925
+ for r in item_rows:
1926
+ exists = self.session.execute(
1927
+ select(t_item.c.item_id, t_item.c.name, t_item.c.category_id).where(t_item.c.item_id == r["item_id"])
1928
+ ).first()
1929
+ if exists is None:
1930
+ self.session.execute(insert(t_item).values(**r))
1931
+ wrote_items += 1
1932
+ else:
1933
+ _, db_name, db_cat = exists
1934
+ if (db_name != r["name"]) or (db_cat != r["category_id"]):
1935
+ self.session.execute(
1936
+ update(t_item).where(t_item.c.item_id == r["item_id"]).values(
1937
+ name=r["name"], category_id=r["category_id"]
1938
+ )
1939
+ )
1940
+
1941
+ wrote_links = 0
1942
+ if link_rows:
1943
+ if db_utils.is_sqlite(self.session):
1944
+ db_utils.sqlite_upsert_modified(self.session, t_si, rows=link_rows,
1945
+ key_cols=("station_id", "item_id"), modified_col="modified",
1946
+ update_cols=("demand_price", "demand_units", "demand_level",
1947
+ "supply_price", "supply_units", "supply_level", "from_live"))
1948
+ wrote_links = len(link_rows)
1949
+ elif db_utils.is_mysql(self.session):
1950
+ db_utils.mysql_upsert_modified(self.session, t_si, rows=link_rows,
1951
+ key_cols=("station_id", "item_id"), modified_col="modified",
1952
+ update_cols=("demand_price", "demand_units", "demand_level",
1953
+ "supply_price", "supply_units", "supply_level", "from_live"))
1954
+ wrote_links = len(link_rows)
1955
+ else:
1956
+ for r in link_rows:
1957
+ si = self.session.execute(
1958
+ select(t_si.c.modified).where(and_(t_si.c.station_id == r["station_id"], t_si.c.item_id == r["item_id"]))
1959
+ ).first()
1960
+ if si is None:
1961
+ self.session.execute(insert(t_si).values(**r))
1962
+ wrote_links += 1
1963
+ else:
1964
+ dbm = si[0]
1965
+ if dbm is None or r["modified"] > dbm:
1966
+ self.session.execute(
1967
+ update(t_si)
1968
+ .where(and_(t_si.c.station_id == r["station_id"], t_si.c.item_id == r["item_id"]))
1969
+ .values(**r)
1970
+ )
1971
+ wrote_links += 1
1972
+
1973
+ return (wrote_items, wrote_links)
1974
+
1975
+ # ------------------------------
1976
+ # UI ordering
1977
+ # ------------------------------
1978
+ def _enforce_ui_order(self, session: Session, tables: dict[str, Table]) -> None:
1979
+ t_item, t_cat = tables["Item"], tables["Category"]
1980
+ cats = session.execute(select(t_cat.c.category_id)).all()
1981
+ for (cat_id,) in cats:
1982
+ rows = session.execute(
1983
+ select(t_item.c.item_id, t_item.c.name, t_item.c.ui_order)
1984
+ .where(t_item.c.category_id == cat_id)
1985
+ .order_by(func.lower(t_item.c.name).asc(), t_item.c.name.asc(), t_item.c.item_id.asc())
1986
+ ).all()
1987
+ expected = 1
1988
+ for item_id, _name, ui_order in rows:
1989
+ if ui_order != expected:
1990
+ session.execute(update(t_item).where(t_item.c.item_id == item_id).values(ui_order=expected))
1991
+ expected += 1
1992
+
1993
+ # ------------------------------
1994
+ # Rares import (via cache.processImportFile)
1995
+ # ------------------------------
1996
+ def _import_rareitems_edcd(self, rares_csv: Path, commodity_csv: Optional[Path] = None) -> None:
1997
+ """
1998
+ EDCD rares → TD.RareItem
1999
+
2000
+ Supports CSV shapes:
2001
+ A) name, system, station
2002
+ B) id, symbol, market_id, category, name (FDevIDs canonical)
2003
+
2004
+ Shape B maps: station_id = int(market_id), category by name.
2005
+ Clears RareItem then upserts by UNIQUE(name). Writes a CSV of skipped rows to tmp/.
2006
+ """
2007
+
2008
+ def _norm(s: Optional[str]) -> str:
2009
+ if s is None:
2010
+ return ""
2011
+ s = s.strip().strip("'").strip('"')
2012
+ s = s.replace("’", "'").replace("‘", "'")
2013
+ s = s.replace("–", "-").replace("—", "-")
2014
+ s = " ".join(s.split())
2015
+ return s.casefold()
2016
+
2017
+ def _kwant(fieldnames, *aliases) -> Optional[str]:
2018
+ if not fieldnames:
2019
+ return None
2020
+ canon = {}
2021
+ for h in fieldnames or []:
2022
+ if not h:
2023
+ continue
2024
+ k = h.strip().lower().replace("_", "").replace(" ", "")
2025
+ canon[k] = h
2026
+ for a in aliases:
2027
+ k = a.strip().lower().replace("_", "").replace(" ", "")
2028
+ if k in canon:
2029
+ return canon[k]
2030
+ return None
2031
+
2032
+ sess = None
2033
+ try:
2034
+ sess = self._open_session()
2035
+ tables = self._reflect_tables(sess.get_bind())
2036
+ t_sys, t_stn, t_cat, t_rare = tables["System"], tables["Station"], tables["Category"], tables["RareItem"]
2037
+
2038
+ # Build lookups for Shape A
2039
+ stn_by_names: dict[tuple[str, str], int] = {}
2040
+ for sid, sys_name, stn_name in sess.execute(
2041
+ select(t_stn.c.station_id, t_sys.c.name, t_stn.c.name).where(t_stn.c.system_id == t_sys.c.system_id)
2042
+ ).all():
2043
+ if sys_name and stn_name:
2044
+ stn_by_names[(_norm(sys_name), _norm(stn_name))] = int(sid)
2045
+
2046
+ # Category name -> id (from DB)
2047
+ cat_id_by_name = {
2048
+ _norm(n): int(cid)
2049
+ for cid, n in sess.execute(select(t_cat.c.category_id, t_cat.c.name)).all()
2050
+ if n is not None
2051
+ }
2052
+
2053
+ kept = skipped = 0
2054
+ skipped_no_station = 0
2055
+ skipped_no_category = 0
2056
+ out_rows: list[dict] = []
2057
+ skipped_rows: list[dict] = [] # <-- record details
2058
+
2059
+ with open(rares_csv, "r", encoding="utf-8", newline="") as fh:
2060
+ reader = csv.DictReader(fh)
2061
+ hdr = [h for h in (reader.fieldnames or []) if h]
2062
+ hdr_canon = [h.lower().replace("_", "").replace(" ", "") for h in hdr]
2063
+
2064
+ has_market_shape = all(x in hdr_canon for x in ["id", "symbol", "marketid", "category", "name"])
2065
+ has_name_shape = all(x in hdr_canon for x in ["name", "system", "station"])
2066
+
2067
+ if not (has_market_shape or has_name_shape):
2068
+ raise CleanExit(
2069
+ "rare_commodity.csv headers not recognized. "
2070
+ f"Seen headers: {', '.join(reader.fieldnames or [])}. File: {rares_csv}"
2071
+ )
2072
+
2073
+ if has_market_shape:
2074
+ # FDevIDs: station_id = int(market_id)
2075
+ k_name = _kwant(reader.fieldnames, "name")
2076
+ k_market = _kwant(reader.fieldnames, "market_id", "marketid")
2077
+ k_cat = _kwant(reader.fieldnames, "category", "categoryname")
2078
+
2079
+ for row in reader:
2080
+ rn_raw = row.get(k_name)
2081
+ mk_raw = row.get(k_market)
2082
+ cat_raw= row.get(k_cat)
2083
+
2084
+ try:
2085
+ station_id = int(mk_raw) if mk_raw is not None else None
2086
+ except (TypeError, ValueError):
2087
+ station_id = None
2088
+
2089
+ # validate station exists
2090
+ if station_id is None or sess.execute(
2091
+ select(t_stn.c.station_id).where(t_stn.c.station_id == station_id)
2092
+ ).first() is None:
2093
+ skipped += 1
2094
+ skipped_no_station += 1
2095
+ skipped_rows.append({"reason":"no_station","name":rn_raw,"market_id":mk_raw,"category":cat_raw})
2096
+ continue
2097
+
2098
+ cid = cat_id_by_name.get(_norm(cat_raw))
2099
+ if cid is None:
2100
+ skipped += 1
2101
+ skipped_no_category += 1
2102
+ skipped_rows.append({"reason":"no_category","name":rn_raw,"market_id":mk_raw,"category":cat_raw})
2103
+ continue
2104
+
2105
+ out_rows.append({
2106
+ "name": rn_raw,
2107
+ "station_id": station_id,
2108
+ "category_id": cid,
2109
+ "cost": None,
2110
+ "max_allocation": None,
2111
+ })
2112
+ kept += 1
2113
+
2114
+ else:
2115
+ # Legacy/community: need commodity.csv to map product -> category
2116
+ name_to_catid: dict[str, int] = {}
2117
+ if commodity_csv is None:
2118
+ files = self._acquire_edcd_files()
2119
+ commodity_csv = files.get("commodity")
2120
+ if commodity_csv and Path(commodity_csv).exists():
2121
+ with open(commodity_csv, "r", encoding="utf-8", newline="") as fh2:
2122
+ rd2 = csv.DictReader(fh2)
2123
+ k2_name = _kwant(rd2.fieldnames, "name","commodity","commodityname","product")
2124
+ k2_cat = _kwant(rd2.fieldnames, "category","categoryname")
2125
+ if k2_name and k2_cat:
2126
+ for r2 in rd2:
2127
+ n = _norm(r2.get(k2_name))
2128
+ c = _norm(r2.get(k2_cat))
2129
+ if n and c:
2130
+ cid = cat_id_by_name.get(c)
2131
+ if cid is not None:
2132
+ name_to_catid[n] = cid
2133
+
2134
+ k_name = _kwant(reader.fieldnames, "name","commodity","commodityname","product")
2135
+ k_system = _kwant(reader.fieldnames, "system","systemname")
2136
+ k_station = _kwant(reader.fieldnames, "station","stationname")
2137
+
2138
+ for row in reader:
2139
+ rn_raw = row.get(k_name)
2140
+ sys_raw = row.get(k_system)
2141
+ stn_raw = row.get(k_station)
2142
+ rn, sysn, stnn = _norm(rn_raw), _norm(sys_raw), _norm(stn_raw)
2143
+
2144
+ if not rn or not sysn or not stnn:
2145
+ skipped += 1
2146
+ skipped_rows.append({"reason":"missing_fields","name":rn_raw,"system":sys_raw,"station":stn_raw})
2147
+ continue
2148
+
2149
+ station_id = stn_by_names.get((sysn, stnn))
2150
+ if station_id is None:
2151
+ skipped += 1
2152
+ skipped_no_station += 1
2153
+ skipped_rows.append({"reason":"no_station","name":rn_raw,"system":sys_raw,"station":stn_raw})
2154
+ continue
2155
+
2156
+ cid = name_to_catid.get(rn)
2157
+ if cid is None:
2158
+ skipped += 1
2159
+ skipped_no_category += 1
2160
+ skipped_rows.append({"reason":"no_category","name":rn_raw,"system":sys_raw,"station":stn_raw})
2161
+ continue
2162
+
2163
+ out_rows.append({
2164
+ "name": rn_raw,
2165
+ "station_id": station_id,
2166
+ "category_id": cid,
2167
+ "cost": None,
2168
+ "max_allocation": None,
2169
+ })
2170
+ kept += 1
2171
+
2172
+ # Clear → upsert
2173
+ try:
2174
+ sess.execute(text('DELETE FROM "RareItem"'))
2175
+ except Exception:
2176
+ sess.execute(text("DELETE FROM RareItem"))
2177
+
2178
+ if out_rows:
2179
+ if db_utils.is_sqlite(sess):
2180
+ db_utils.sqlite_upsert_simple(
2181
+ sess, t_rare, rows=out_rows, key_cols=("name",),
2182
+ update_cols=tuple(k for k in out_rows[0].keys() if k != "name")
2183
+ )
2184
+ elif db_utils.is_mysql(sess):
2185
+ db_utils.mysql_upsert_simple(
2186
+ sess, t_rare, rows=out_rows, key_cols=("name",),
2187
+ update_cols=tuple(k for k in out_rows[0].keys() if k != "name")
2188
+ )
2189
+ else:
2190
+ for r in out_rows:
2191
+ ex = sess.execute(select(t_rare.c.name).where(t_rare.c.name == r["name"])).first()
2192
+ if ex is None:
2193
+ sess.execute(insert(t_rare).values(**r))
2194
+ else:
2195
+ sess.execute(
2196
+ update(t_rare).where(t_rare.c.name == r["name"])
2197
+ .values({k: r[k] for k in r.keys() if k != "name"})
2198
+ )
2199
+ sess.commit()
2200
+
2201
+ # Write a CSV with skipped details
2202
+ if skipped_rows:
2203
+ outp = self.tmp_dir / "edcd_rares_skipped.csv"
2204
+ keys = sorted({k for r in skipped_rows for k in r.keys()})
2205
+ with open(outp, "w", encoding="utf-8", newline="") as fh:
2206
+ w = csv.DictWriter(fh, fieldnames=keys)
2207
+ w.writeheader()
2208
+ w.writerows(skipped_rows)
2209
+ self._print(f"EDCD Rares: imported={kept:,} skipped={skipped:,} "
2210
+ f"(no_station={skipped_no_station:,}, no_category={skipped_no_category:,}) "
2211
+ f"→ details: {outp}")
2212
+ else:
2213
+ self._print(f"EDCD Rares: imported={kept:,} skipped={skipped:,} "
2214
+ f"(no_station={skipped_no_station:,}, no_category={skipped_no_category:,})")
2215
+
2216
+ except Exception as e:
2217
+ if sess is not None:
2218
+ try:
2219
+ sess.rollback()
2220
+ except Exception:
2221
+ pass
2222
+ raise CleanExit(f"RareItem import failed: {e!r}") from e
2223
+ finally:
2224
+ if sess is not None:
2225
+ try:
2226
+ sess.close()
2227
+ except Exception:
2228
+ pass
2229
+
2230
+ # ------------------------------
2231
+ # Export / cache refresh
2232
+ #
2233
+ def _export_cache(self) -> None:
2234
+ """
2235
+ Export CSVs and regenerate TradeDangerous.prices — concurrently, with optional StationItem gating.
2236
+
2237
+ IMPORTANT:
2238
+ - CSV exports are written to tdenv.dataDir (private) so they remain authoritative.
2239
+ - A separate mirror step publishes selected/all CSVs to TD_CSV (public).
2240
+ """
2241
+
2242
+ def _opt_true(val: Optional[str]) -> bool:
2243
+ if val is None:
2244
+ return False
2245
+ if isinstance(val, str):
2246
+ return val.strip().lower() in ("1", "true", "yes", "on", "y")
2247
+ return bool(val)
2248
+
2249
+ skip_stationitems = (
2250
+ _opt_true(self.getOption("skip_stationitems"))
2251
+ or _opt_true(os.environ.get("TD_SKIP_STATIONITEM_EXPORT"))
2252
+ )
2253
+
2254
+ # Export destination: always private dataDir
2255
+ export_dir = Path(self.tdenv.dataDir).resolve()
2256
+ try:
2257
+ export_dir.mkdir(parents=True, exist_ok=True)
2258
+ except Exception as e:
2259
+ raise CleanExit(f"Export failed: unable to create export directory {export_dir}: {e!r}") from None
2260
+
2261
+ # Heaviest tables first to maximize overlap
2262
+ tables = [
2263
+ "StationItem",
2264
+ "ShipVendor",
2265
+ "UpgradeVendor",
2266
+ "Station",
2267
+ "System",
2268
+ "Category", # <-- REQUIRED for correct downstream category mapping
2269
+ "Item",
2270
+ "Ship",
2271
+ "Upgrade",
2272
+ "RareItem",
2273
+ "FDevOutfitting",
2274
+ "FDevShipyard",
2275
+ ]
2276
+ if skip_stationitems:
2277
+ tables = [t for t in tables if t != "StationItem"]
2278
+
2279
+ # Worker count (env override allowed); +1 slot reserved for prices task
2280
+ try:
2281
+ workers = int(os.environ.get("TD_EXPORT_WORKERS", "4"))
2282
+ except ValueError:
2283
+ workers = 4
2284
+ workers = max(1, workers) + 1 # extra slot for the prices job
2285
+
2286
+ def _export_one(table_name: str) -> str:
2287
+ sess = None
2288
+ try:
2289
+ sess = self._open_session() # fresh session per worker
2290
+ csvexport.exportTableToFile(sess, self.tdenv, table_name, csvPath=export_dir)
2291
+ return f"{table_name}.csv"
2292
+ finally:
2293
+ if sess is not None:
2294
+ try:
2295
+ sess.close()
2296
+ except Exception:
2297
+ pass
2298
+
2299
+ def _regen_prices() -> str:
2300
+ cache.regeneratePricesFile(self.tdb, self.tdenv)
2301
+ return "TradeDangerous.prices"
2302
+
2303
+ self._print(f"Exporting cache CSVs to: {export_dir}")
2304
+ for t in tables:
2305
+ self._print(f" - {t}.csv")
2306
+ if skip_stationitems:
2307
+ self._warn("Skipping StationItem.csv export (requested).")
2308
+ self._print("Regenerating TradeDangerous.prices …")
2309
+
2310
+ # Parallel export + prices regen, with conservative fallback
2311
+ try:
2312
+ with ThreadPoolExecutor(max_workers=workers) as ex:
2313
+ futures = {ex.submit(_export_one, t): f"{t}.csv" for t in tables}
2314
+ futures[ex.submit(_regen_prices)] = "TradeDangerous.prices"
2315
+ for fut in as_completed(futures):
2316
+ _ = fut.result() # raise on any worker failure
2317
+ except Exception as e:
2318
+ self._warn(f"Parallel export encountered an error ({e!r}); falling back to serial.")
2319
+ for t in tables:
2320
+ _export_one(t)
2321
+ _regen_prices()
2322
+
2323
+ self._print("Cache export completed.")
2324
+
2325
+ def _mirror_csv_exports(self) -> None:
2326
+ """
2327
+ If TD_CSV is set, mirror all CSVs emitted into tdenv.dataDir to TD_CSV.
2328
+
2329
+ This is a publish step:
2330
+ - source: private exports in tdenv.dataDir (TD_DATA)
2331
+ - dest: public directory TD_CSV
2332
+ """
2333
+ src_dir = Path(self.tdenv.dataDir).resolve()
2334
+ dst_env = os.environ.get("TD_CSV")
2335
+ if not dst_env:
2336
+ return
2337
+ dst_dir = Path(dst_env).expanduser().resolve()
2338
+
2339
+ if src_dir == dst_dir:
2340
+ # Nothing to do; already exporting directly into the public path
2341
+ return
2342
+
2343
+ try:
2344
+ dst_dir.mkdir(parents=True, exist_ok=True)
2345
+ except Exception as e:
2346
+ self._warn(f"TD_CSV mirror: unable to create destination {dst_dir}: {e!r}")
2347
+ return
2348
+
2349
+ copied = 0
2350
+ for src in src_dir.glob("*.csv"):
2351
+ try:
2352
+ shutil.copy2(src, dst_dir / src.name)
2353
+ copied += 1
2354
+ except Exception as e:
2355
+ self._warn(f"TD_CSV mirror: failed to copy {src.name}: {e!r}")
2356
+
2357
+ self._print(f"TD_CSV mirror: copied {copied} csv file(s) → {dst_dir}")
2358
+
2359
+
2360
+ def _export_and_mirror(self) -> None:
2361
+ """
2362
+ Run the normal cache/CSV export, then mirror CSVs to TD_CSV if set.
2363
+ Use this in place of a direct _export_cache() call.
2364
+ """
2365
+ t0 = time.time()
2366
+ self._export_cache() # existing exporter (unchanged)
2367
+ self._print(f"Cache export completed in {time.time()-t0:.2f}s")
2368
+ self._mirror_csv_exports()
2369
+
2370
+ # ------------------------------
2371
+ # Categories cache
2372
+ #
2373
+ def _load_categories(self, session: Session, tables: dict[str, Table]) -> dict[str, int]:
2374
+ t_cat = tables["Category"]
2375
+ rows = session.execute(select(t_cat.c.category_id, t_cat.c.name)).all()
2376
+ return {str(name).lower(): int(cid) for (cid, name) in rows}
2377
+
2378
+ # ------------------------------
2379
+ # Streaming JSON reader
2380
+ #
2381
+ def _ijson_items(self, fh: io.BufferedReader, prefix: str):
2382
+ """
2383
+ Use the fastest available ijson backend with clean fallback.
2384
+ Order: yajl2_cffi → yajl2_c → yajl2 → python.
2385
+ """
2386
+ try:
2387
+ from ijson.backends import yajl2_cffi as ijson_fast
2388
+ return ijson_fast.items(fh, prefix)
2389
+ except Exception:
2390
+ pass
2391
+ try:
2392
+ from ijson.backends import yajl2_c as ijson_fast # ctypes wrapper
2393
+ return ijson_fast.items(fh, prefix)
2394
+ except Exception:
2395
+ pass
2396
+ try:
2397
+ from ijson.backends import yajl2 as ijson_fast
2398
+ return ijson_fast.items(fh, prefix)
2399
+ except Exception:
2400
+ pass
2401
+ # Fallback to whatever was imported at module top
2402
+ return ijson.items(fh, prefix)
2403
+
2404
+ def _iter_top_level_json_array(self, fh: io.BufferedReader) -> Generator[dict[str, Any]]:
2405
+ """
2406
+ High-performance streaming reader for a huge top-level JSON array of systems.
2407
+ NOTE: As of 2025-10, we removed _parse_progress(). This iterator now
2408
+ maintains byte/rate metrics only; rendering is handled by _progress_line().
2409
+ """
2410
+ start_ts = time.time()
2411
+ last_tick_systems = 0
2412
+ TICK_EVERY = 256
2413
+
2414
+ it = self._ijson_items(fh, 'item')
2415
+ for idx, obj in enumerate(it, 1):
2416
+ if (idx - last_tick_systems) >= TICK_EVERY:
2417
+ last_tick_systems = idx
2418
+ # Update parse metrics (no printing here)
2419
+ try:
2420
+ pos = fh.tell()
2421
+ elapsed = max(time.time() - start_ts, 1e-9)
2422
+ self._parse_bytes = pos
2423
+ self._parse_rate = pos / elapsed
2424
+ except Exception:
2425
+ pass
2426
+ yield obj
2427
+
2428
+ # Final metric update at EOF
2429
+ try:
2430
+ pos = fh.tell()
2431
+ elapsed = max(time.time() - start_ts, 1e-9)
2432
+ self._parse_bytes = pos
2433
+ self._parse_rate = pos / elapsed
2434
+ except Exception:
2435
+ pass
2436
+
2437
+ if self._is_tty:
2438
+ self._live_status("")
2439
+
2440
+ # ------------------------------
2441
+ # Mapping / derivations / misc
2442
+ #
2443
+ @staticmethod
2444
+ def _build_station_type_map() -> dict[Optional[str], tuple[int, bool]]:
2445
+ return {
2446
+ None: (0, False),
2447
+ "None": (0, False),
2448
+ "Outpost": (1, False),
2449
+ "Coriolis Starport": (2, False),
2450
+ "Ocellus Starport": (3, False),
2451
+ "Orbis Starport": (4, False),
2452
+ "Planetary Outpost": (11, True),
2453
+ "Planetary Port": (12, True),
2454
+ "Mega ship": (13, False),
2455
+ "Asteroid base": (14, False),
2456
+ "Drake-Class Carrier": (24, False),
2457
+ "Settlement": (25, True),
2458
+ }
2459
+
2460
+ def _map_station_type(self, type_name: Optional[str]) -> tuple[int, str]:
2461
+ if isinstance(type_name, str):
2462
+ res = self._station_type_map.get(type_name)
2463
+ if res:
2464
+ type_id, is_planetary = res
2465
+ return type_id, "Y" if is_planetary else "N"
2466
+ return (0, "?")
2467
+
2468
+ @staticmethod
2469
+ def _derive_pad_size(landing: Mapping[str, Any]) -> str:
2470
+ try:
2471
+ if landing.get("large"):
2472
+ return "L"
2473
+ if landing.get("medium"):
2474
+ return "M"
2475
+ if landing.get("small"):
2476
+ return "S"
2477
+ except Exception:
2478
+ pass
2479
+ return "?"
2480
+
2481
+ def _resolve_batch_size(self) -> Optional[int]:
2482
+ """
2483
+ Decide commit batch size for *spansh* profile.
2484
+ """
2485
+ if self.session is not None and hasattr(db_utils, "get_import_batch_size"):
2486
+ try:
2487
+ val = db_utils.get_import_batch_size(self.session, profile="spansh")
2488
+ if val is not None:
2489
+ return val
2490
+ except Exception:
2491
+ pass
2492
+
2493
+ raw = os.environ.get("TD_LISTINGS_BATCH")
2494
+ if raw is not None:
2495
+ try:
2496
+ envv = int(raw)
2497
+ return envv if envv > 0 else None
2498
+ except ValueError:
2499
+ pass
2500
+
2501
+ try:
2502
+ if db_utils.is_sqlite(self.session):
2503
+ return None
2504
+ if db_utils.is_mysql(self.session):
2505
+ return 50_000
2506
+ except Exception:
2507
+ pass
2508
+
2509
+ return 5_000
2510
+
2511
+ # ---- ts/format/logging helpers ----
2512
+ def _parse_ts(self, value: Any) -> Optional[datetime]:
2513
+ try:
2514
+ return db_utils.parse_ts(value) # UTC-naive, μs=0
2515
+ except Exception:
2516
+ return None
2517
+
2518
+ @staticmethod
2519
+ def _format_hms(seconds: float) -> str:
2520
+ m, s = divmod(int(seconds), 60)
2521
+ h, m = divmod(m, 60)
2522
+ return f"{h}:{m:02d}:{s:02d}"
2523
+
2524
+ def _fmt_bytes(self, n: float) -> str:
2525
+ units = ["B", "KiB", "MiB", "GiB", "TiB"]
2526
+ i = 0
2527
+ while n >= 1024 and i < len(units) - 1:
2528
+ n /= 1024.0
2529
+ i += 1
2530
+ return f"{int(n)} {units[i]}" if i == 0 else f"{n:.1f} {units[i]}"
2531
+
2532
+ def _progress_line(self, stats: dict[str, int]) -> None:
2533
+ """
2534
+ Single-line live status while importing.
2535
+
2536
+ Modes:
2537
+ - default (verbose-ish): rich long line
2538
+ - compact: shorter, log-friendly line (enable with -O progress_compact=1 or TD_PROGRESS_COMPACT=1)
2539
+ """
2540
+ now = time.time()
2541
+ if now - self._last_progress_time < (0.5 if self._debug_level < 1 else 0.2):
2542
+ return
2543
+ self._last_progress_time = now
2544
+ self._started_importing = True
2545
+
2546
+ # Determine compact mode (CLI overrides env; default is rich/False)
2547
+ # Truthy whitelist: 1, true, yes, on, y (case-insensitive)
2548
+ _opt = self.getOption("progress_compact")
2549
+ if _opt is not None:
2550
+ _val = str(_opt).strip().lower()
2551
+ else:
2552
+ _env = os.getenv("TD_PROGRESS_COMPACT")
2553
+ _val = "" if _env is None else str(_env).strip().lower()
2554
+ compact = _val in {"1", "true", "yes", "on", "y"}
2555
+
2556
+ parse_bytes = getattr(self, "_parse_bytes", 0)
2557
+ parse_rate = getattr(self, "_parse_rate", 0.0)
2558
+ systems = stats.get("systems", 0)
2559
+ stations = stats.get("stations", 0)
2560
+
2561
+ wm = stats.get("market_writes", 0)
2562
+ wo = stats.get("outfit_writes", 0)
2563
+ ws = stats.get("ship_writes", 0)
2564
+
2565
+ km = stats.get("market_stations", 0)
2566
+ ko = stats.get("outfit_stations", 0)
2567
+ ks = stats.get("ship_stations", 0)
2568
+
2569
+ if compact:
2570
+ # Compact, log-friendly (newline prints)
2571
+ msg = (
2572
+ f"Importing… {parse_bytes/1048576:.1f} MiB read {parse_rate/1048576:.1f} MiB/s "
2573
+ f"systems:{systems:,} stations:{stations:,} "
2574
+ f"checked m/o/s:{km:,}/{ko:,}/{ks:,} written m/o/s:{wm:,}/{wo:,}/{ws:,}"
2575
+ )
2576
+ self._print(msg)
2577
+ return
2578
+
2579
+ # Rich/long line (TTY-optimized; truncated only on TTY)
2580
+ msg = (
2581
+ f"Importing… {parse_bytes/1048576:.1f} MiB read {parse_rate/1048576:.1f} MiB/s "
2582
+ f"[Parsed - Systems: {systems:,} Stations: {stations:,}] "
2583
+ f"Checked(stations): mkt={km:,} outf={ko:,} shp={ks:,} "
2584
+ f"Written(stations): mkt={wm:,} outf={wo:,} shp={ws:,}"
2585
+
2586
+ )
2587
+ self._live_status(msg)
2588
+
2589
+ def _live_line(self, msg: str) -> None:
2590
+ self._live_status(msg)
2591
+
2592
+ def _live_status(self, msg: str) -> None:
2593
+ """
2594
+ Live status line for TTY; plain prints for non-TTY.
2595
+ IMPORTANT: only truncate when TTY so logs are not cut off.
2596
+ """
2597
+ try:
2598
+ if self._is_tty:
2599
+ width = shutil.get_terminal_size(fallback=(120, 20)).columns
2600
+ if width and width > 4:
2601
+ msg = msg[: width - 2]
2602
+ s = f"\x1b[2K\r{msg}"
2603
+ sys.stderr.write(s)
2604
+ sys.stderr.flush()
2605
+ else:
2606
+ # Non-TTY: emit full line, no truncation, no control codes.
2607
+ self._print(msg)
2608
+ except Exception:
2609
+ self._print(msg)
2610
+
2611
+ def _end_live_status(self) -> None:
2612
+ try:
2613
+ if self._is_tty:
2614
+ sys.stderr.write("\x1b[2K\r\n")
2615
+ sys.stderr.flush()
2616
+ except Exception:
2617
+ pass
2618
+
2619
+ # ---- printing/warnings ----
2620
+ def _print(self, *args, **kwargs):
2621
+ printer = getattr(self.tdenv, "print", None)
2622
+ if callable(printer):
2623
+ printer(*args, **kwargs)
2624
+ else:
2625
+ print(*args, **kwargs)
2626
+
2627
+ def _warn(self, msg: str):
2628
+ if self._warn_enabled:
2629
+ self._print(f"WARNING: {msg}")
2630
+
2631
+ def _error(self, msg: str):
2632
+ self._print(f"ERROR: {msg}")
2633
+
2634
+ def _safe_close_session(self):
2635
+ try:
2636
+ if self.session is not None:
2637
+ self.session.close()
2638
+ except Exception:
2639
+ pass
2640
+ self.session = None
2641
+
2642
+ # -----------------------------------------------------------------------------
2643
+ # Exceptions
2644
+ # -----------------------------------------------------------------------------
2645
+ class CleanExit(Exception):
2646
+ """Controlled early exit: log and stop this run so schedulers can retry later."""
2647
+ pass