edapitool 0.6.3__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.
APITool/export.py ADDED
@@ -0,0 +1,987 @@
1
+ """
2
+ Export functionality for Elite Dangerous data.
3
+
4
+ Supports exporting fleet carrier and other data to:
5
+ - CSV files
6
+ - JSON files
7
+ - (Future) Google Sheets
8
+ """
9
+
10
+ import csv
11
+ import json
12
+ import re
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import Optional, Union, TextIO
16
+
17
+ from .models import (
18
+ FleetCarrier,
19
+ CargoItem,
20
+ CommodityOrder,
21
+ MicroResourceOrder,
22
+ LockerItem,
23
+ ServiceCrew,
24
+ JumpRecord,
25
+ )
26
+
27
+
28
+ class CSVExporter:
29
+ """Export data to CSV format."""
30
+
31
+ def __init__(self, output_dir: Optional[Path] = None):
32
+ """
33
+ Initialize CSV exporter.
34
+
35
+ Args:
36
+ output_dir: Directory for output files (default: current directory)
37
+ """
38
+ self.output_dir = output_dir or Path.cwd()
39
+ self.output_dir.mkdir(parents=True, exist_ok=True)
40
+
41
+ def _generate_filename(self, prefix: str, callsign: str = "") -> Path:
42
+ """Generate a timestamped filename."""
43
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
44
+ if callsign:
45
+ return self.output_dir / f"{prefix}_{callsign}_{timestamp}.csv"
46
+ return self.output_dir / f"{prefix}_{timestamp}.csv"
47
+
48
+ def export_carrier_summary(
49
+ self,
50
+ carrier: FleetCarrier,
51
+ filepath: Optional[Path] = None,
52
+ ) -> Path:
53
+ """
54
+ Export carrier summary to CSV.
55
+
56
+ Args:
57
+ carrier: FleetCarrier data
58
+ filepath: Optional specific filepath
59
+
60
+ Returns:
61
+ Path to created file
62
+ """
63
+ filepath = filepath or self._generate_filename(
64
+ "carrier_summary", carrier.identity.callsign
65
+ )
66
+
67
+ with open(filepath, "w", newline="", encoding="utf-8") as f:
68
+ writer = csv.writer(f)
69
+
70
+ # Header info
71
+ writer.writerow(["Fleet Carrier Summary"])
72
+ writer.writerow(["Export Date", datetime.now().isoformat()])
73
+ writer.writerow([])
74
+
75
+ # Identity
76
+ writer.writerow(["Identity"])
77
+ writer.writerow(["Callsign", carrier.identity.callsign])
78
+ writer.writerow(["Name", carrier.identity.display_name])
79
+ writer.writerow([])
80
+
81
+ # Location
82
+ writer.writerow(["Location & Status"])
83
+ writer.writerow(["Current System", carrier.location.system])
84
+ writer.writerow(["State", carrier.location.state])
85
+ writer.writerow(["Docking Access", carrier.location.docking_access])
86
+ writer.writerow(["Tritium Fuel", carrier.fuel])
87
+ writer.writerow(["Theme", carrier.theme])
88
+ writer.writerow([])
89
+
90
+ # Finances
91
+ writer.writerow(["Finances"])
92
+ writer.writerow(["Bank Balance", carrier.finance.balance])
93
+ writer.writerow(["Reserved Balance", carrier.finance.reserved_balance])
94
+ writer.writerow(["Weekly Core Cost", carrier.finance.core_cost])
95
+ writer.writerow(["Weekly Services Cost", carrier.finance.services_cost])
96
+ writer.writerow(["Weekly Upkeep", carrier.finance.weekly_upkeep])
97
+ writer.writerow([])
98
+
99
+ # Capacity
100
+ writer.writerow(["Capacity"])
101
+ writer.writerow(["Ship Packs", carrier.capacity.ship_packs])
102
+ writer.writerow(["Module Packs", carrier.capacity.module_packs])
103
+ writer.writerow(["Cargo For Sale", carrier.capacity.cargo_for_sale])
104
+ writer.writerow(["Cargo Not For Sale", carrier.capacity.cargo_not_for_sale])
105
+ writer.writerow(["Cargo Reserved", carrier.capacity.cargo_reserved])
106
+ writer.writerow([])
107
+
108
+ # Services
109
+ writer.writerow(["Enabled Services"])
110
+ for service in carrier.enabled_services:
111
+ writer.writerow(["", service])
112
+
113
+ return filepath
114
+
115
+ def export_commodity_orders(
116
+ self,
117
+ carrier: FleetCarrier,
118
+ filepath: Optional[Path] = None,
119
+ ) -> Path:
120
+ """
121
+ Export commodity orders to CSV.
122
+
123
+ Args:
124
+ carrier: FleetCarrier data
125
+ filepath: Optional specific filepath
126
+
127
+ Returns:
128
+ Path to created file
129
+ """
130
+ filepath = filepath or self._generate_filename(
131
+ "commodities", carrier.identity.callsign
132
+ )
133
+
134
+ with open(filepath, "w", newline="", encoding="utf-8") as f:
135
+ writer = csv.writer(f)
136
+
137
+ writer.writerow([
138
+ "Commodity",
139
+ "Type",
140
+ "Stock/Demand",
141
+ "Outstanding",
142
+ "Price",
143
+ "Black Market",
144
+ ])
145
+
146
+ # Sales
147
+ for order in carrier.commodity_sales:
148
+ writer.writerow([
149
+ order.name,
150
+ "SELL",
151
+ order.stock,
152
+ "",
153
+ order.price,
154
+ "Yes" if order.blackmarket else "No",
155
+ ])
156
+
157
+ # Purchases
158
+ for order in carrier.commodity_purchases:
159
+ writer.writerow([
160
+ order.name,
161
+ "BUY",
162
+ order.demand,
163
+ order.outstanding,
164
+ order.price,
165
+ "",
166
+ ])
167
+
168
+ return filepath
169
+
170
+ def export_microresources(
171
+ self,
172
+ carrier: FleetCarrier,
173
+ filepath: Optional[Path] = None,
174
+ ) -> Path:
175
+ """
176
+ Export microresource orders to CSV.
177
+
178
+ Args:
179
+ carrier: FleetCarrier data
180
+ filepath: Optional specific filepath
181
+
182
+ Returns:
183
+ Path to created file
184
+ """
185
+ filepath = filepath or self._generate_filename(
186
+ "microresources", carrier.identity.callsign
187
+ )
188
+
189
+ with open(filepath, "w", newline="", encoding="utf-8") as f:
190
+ writer = csv.writer(f)
191
+
192
+ writer.writerow([
193
+ "Resource",
194
+ "Localized Name",
195
+ "Type",
196
+ "Stock/Demand",
197
+ "Outstanding",
198
+ "Price",
199
+ ])
200
+
201
+ # Sales
202
+ for order in carrier.micro_sales:
203
+ writer.writerow([
204
+ order.name,
205
+ order.localized_name,
206
+ "SELL",
207
+ order.stock,
208
+ "",
209
+ order.price,
210
+ ])
211
+
212
+ # Purchases
213
+ for order in carrier.micro_purchases:
214
+ writer.writerow([
215
+ order.name,
216
+ order.localized_name,
217
+ "BUY",
218
+ order.demand,
219
+ order.outstanding,
220
+ order.price,
221
+ ])
222
+
223
+ return filepath
224
+
225
+ def export_locker_inventory(
226
+ self,
227
+ carrier: FleetCarrier,
228
+ filepath: Optional[Path] = None,
229
+ ) -> Path:
230
+ """
231
+ Export carrier locker inventory to CSV.
232
+
233
+ Args:
234
+ carrier: FleetCarrier data
235
+ filepath: Optional specific filepath
236
+
237
+ Returns:
238
+ Path to created file
239
+ """
240
+ filepath = filepath or self._generate_filename(
241
+ "locker", carrier.identity.callsign
242
+ )
243
+
244
+ with open(filepath, "w", newline="", encoding="utf-8") as f:
245
+ writer = csv.writer(f)
246
+
247
+ writer.writerow([
248
+ "Item",
249
+ "Localized Name",
250
+ "Category",
251
+ "Quantity",
252
+ ])
253
+
254
+ for item in carrier.locker_items:
255
+ writer.writerow([
256
+ item.name,
257
+ item.localized_name,
258
+ item.category.title(),
259
+ item.quantity,
260
+ ])
261
+
262
+ return filepath
263
+
264
+ def export_cargo(
265
+ self,
266
+ carrier: FleetCarrier,
267
+ filepath: Optional[Path] = None,
268
+ ) -> Path:
269
+ """
270
+ Export fleet carrier cargo inventory to CSV.
271
+
272
+ This is the main commodity storage on the carrier - the items
273
+ like Meta-Alloys, Tea, Steel, etc. that the user needs to track.
274
+
275
+ Args:
276
+ carrier: FleetCarrier data
277
+ filepath: Optional specific filepath
278
+
279
+ Returns:
280
+ Path to created file
281
+ """
282
+ filepath = filepath or self._generate_filename(
283
+ "cargo", carrier.identity.callsign
284
+ )
285
+
286
+ with open(filepath, "w", newline="", encoding="utf-8") as f:
287
+ writer = csv.writer(f)
288
+
289
+ writer.writerow([
290
+ "Commodity",
291
+ "Display Name",
292
+ "Quantity",
293
+ "Value (CR)",
294
+ "Stolen",
295
+ "Mission",
296
+ "Origin System",
297
+ ])
298
+
299
+ # Sort by localized name for easier reading
300
+ sorted_cargo = sorted(carrier.cargo, key=lambda c: c.localized_name)
301
+
302
+ for item in sorted_cargo:
303
+ writer.writerow([
304
+ item.commodity,
305
+ item.localized_name,
306
+ item.quantity,
307
+ item.value,
308
+ "Yes" if item.stolen else "No",
309
+ "Yes" if item.mission else "No",
310
+ item.origin_system or "",
311
+ ])
312
+
313
+ # Add summary row
314
+ if carrier.cargo:
315
+ writer.writerow([])
316
+ total_qty = sum(c.quantity for c in carrier.cargo)
317
+ total_value = sum(c.value for c in carrier.cargo)
318
+ writer.writerow(["TOTAL", "", total_qty, total_value, "", "", ""])
319
+
320
+ return filepath
321
+
322
+ def export_cargo_aggregated(
323
+ self,
324
+ carrier: FleetCarrier,
325
+ filepath: Optional[Path] = None,
326
+ ) -> Path:
327
+ """
328
+ Export fleet carrier cargo with quantities aggregated by commodity.
329
+
330
+ Same commodity from different sources is combined into a single row.
331
+ This is the simplified view for inventory tracking.
332
+
333
+ Args:
334
+ carrier: FleetCarrier data
335
+ filepath: Optional specific filepath
336
+
337
+ Returns:
338
+ Path to created file
339
+ """
340
+ filepath = filepath or self._generate_filename(
341
+ "cargo_summary", carrier.identity.callsign
342
+ )
343
+
344
+ # Aggregate by commodity name
345
+ aggregated: dict[str, dict] = {}
346
+ for item in carrier.cargo:
347
+ key = item.commodity
348
+ if key not in aggregated:
349
+ aggregated[key] = {
350
+ "commodity": item.commodity,
351
+ "localized_name": item.localized_name,
352
+ "quantity": 0,
353
+ "value": 0,
354
+ }
355
+ aggregated[key]["quantity"] += item.quantity
356
+ aggregated[key]["value"] += item.value
357
+
358
+ with open(filepath, "w", newline="", encoding="utf-8") as f:
359
+ writer = csv.writer(f)
360
+
361
+ writer.writerow([
362
+ "Commodity",
363
+ "Display Name",
364
+ "Quantity",
365
+ "Value (CR)",
366
+ ])
367
+
368
+ # Sort by localized name
369
+ sorted_items = sorted(aggregated.values(), key=lambda x: x["localized_name"])
370
+
371
+ for item in sorted_items:
372
+ writer.writerow([
373
+ item["commodity"],
374
+ item["localized_name"],
375
+ item["quantity"],
376
+ item["value"],
377
+ ])
378
+
379
+ # Add summary row
380
+ if aggregated:
381
+ writer.writerow([])
382
+ total_qty = sum(item["quantity"] for item in aggregated.values())
383
+ total_value = sum(item["value"] for item in aggregated.values())
384
+ writer.writerow(["TOTAL", "", total_qty, total_value])
385
+
386
+ return filepath
387
+
388
+ def export_cargo_gsheet(
389
+ self,
390
+ carrier: FleetCarrier,
391
+ filepath: Optional[Path] = None,
392
+ include_stolen: bool = False,
393
+ include_mission: bool = False,
394
+ ) -> Path:
395
+ """
396
+ Export fleet carrier cargo in Google Sheets-ready format.
397
+
398
+ This format is optimized for VLOOKUP references:
399
+ - Row 1: Empty
400
+ - Row 2: Headers (Column A empty for margin)
401
+ - Row 3: Totals with formulas
402
+ - Row 4+: Data sorted alphabetically
403
+
404
+ Filters out stolen and mission cargo by default.
405
+ Unit price is derived from the highest-quantity stack.
406
+
407
+ Args:
408
+ carrier: FleetCarrier data
409
+ filepath: Optional specific filepath
410
+ include_stolen: Include stolen cargo items
411
+ include_mission: Include mission-reserved cargo items
412
+
413
+ Returns:
414
+ Path to created file
415
+ """
416
+ filepath = filepath or self._generate_filename(
417
+ "cargo_gsheet", carrier.identity.callsign
418
+ )
419
+
420
+ # Step 1: Filter cargo based on flags
421
+ filtered = [
422
+ c for c in carrier.cargo
423
+ if (include_stolen or not c.stolen)
424
+ and (include_mission or not c.mission)
425
+ and c.quantity > 0 # Skip zero-quantity items
426
+ ]
427
+
428
+ # Step 2: Group by commodity, track max-qty stack for each
429
+ groups: dict[str, dict] = {}
430
+ for item in filtered:
431
+ key = item.commodity
432
+ if key not in groups:
433
+ groups[key] = {
434
+ "items": [],
435
+ "max_qty_item": item,
436
+ "total_qty": 0,
437
+ "display_name": item.localized_name,
438
+ }
439
+ groups[key]["items"].append(item)
440
+ groups[key]["total_qty"] += item.quantity
441
+ # Track the item with highest quantity for pricing
442
+ if item.quantity > groups[key]["max_qty_item"].quantity:
443
+ groups[key]["max_qty_item"] = item
444
+
445
+ # Step 3: Calculate unit prices and prepare data
446
+ data = []
447
+ for key, group in groups.items():
448
+ max_item = group["max_qty_item"]
449
+ # Unit price from the largest stack
450
+ unit_price = max_item.value // max_item.quantity if max_item.quantity > 0 else 0
451
+ data.append({
452
+ "display_name": group["display_name"],
453
+ "quantity": group["total_qty"],
454
+ "unit_price": unit_price,
455
+ })
456
+
457
+ # Step 4: Sort alphabetically by display name
458
+ data.sort(key=lambda x: x["display_name"])
459
+
460
+ # Step 5: Write CSV with Google Sheets layout
461
+ with open(filepath, "w", newline="", encoding="utf-8") as f:
462
+ writer = csv.writer(f)
463
+
464
+ # Row 1: Empty
465
+ writer.writerow([])
466
+
467
+ # Row 2: Headers (Column A empty for margin)
468
+ writer.writerow(["", "Display Name", "Quantity", "Unit Price", "Total Value"])
469
+
470
+ # Row 3: Totals with formulas (Column A empty)
471
+ # Formulas reference Row 4 onwards
472
+ writer.writerow(["", "TOTAL", "=SUM(C4:C)", "", "=SUM(E4:E)"])
473
+
474
+ # Row 4+: Data with formulas for Total Value
475
+ for i, item in enumerate(data):
476
+ row_num = 4 + i
477
+ writer.writerow([
478
+ "", # Column A empty
479
+ item["display_name"],
480
+ item["quantity"],
481
+ item["unit_price"],
482
+ f"=C{row_num}*D{row_num}", # Total Value formula
483
+ ])
484
+
485
+ return filepath
486
+
487
+ def export_crew(
488
+ self,
489
+ carrier: FleetCarrier,
490
+ filepath: Optional[Path] = None,
491
+ ) -> Path:
492
+ """
493
+ Export crew information to CSV.
494
+
495
+ Args:
496
+ carrier: FleetCarrier data
497
+ filepath: Optional specific filepath
498
+
499
+ Returns:
500
+ Path to created file
501
+ """
502
+ filepath = filepath or self._generate_filename(
503
+ "crew", carrier.identity.callsign
504
+ )
505
+
506
+ with open(filepath, "w", newline="", encoding="utf-8") as f:
507
+ writer = csv.writer(f)
508
+
509
+ writer.writerow([
510
+ "Service",
511
+ "Crew Name",
512
+ "Gender",
513
+ "Faction",
514
+ "Weekly Salary",
515
+ "Enabled",
516
+ ])
517
+
518
+ for crew in carrier.crew:
519
+ writer.writerow([
520
+ crew.service,
521
+ crew.name,
522
+ crew.gender,
523
+ crew.faction,
524
+ crew.salary,
525
+ "Yes" if crew.enabled else "No",
526
+ ])
527
+
528
+ return filepath
529
+
530
+ def export_jump_history(
531
+ self,
532
+ carrier: FleetCarrier,
533
+ filepath: Optional[Path] = None,
534
+ ) -> Path:
535
+ """
536
+ Export jump history to CSV.
537
+
538
+ Args:
539
+ carrier: FleetCarrier data
540
+ filepath: Optional specific filepath
541
+
542
+ Returns:
543
+ Path to created file
544
+ """
545
+ filepath = filepath or self._generate_filename(
546
+ "jumps", carrier.identity.callsign
547
+ )
548
+
549
+ with open(filepath, "w", newline="", encoding="utf-8") as f:
550
+ writer = csv.writer(f)
551
+
552
+ writer.writerow([
553
+ "System",
554
+ "Arrival Time",
555
+ "Departure Time",
556
+ "Visit Duration (hrs)",
557
+ "Success",
558
+ ])
559
+
560
+ for jump in carrier.jump_history:
561
+ duration_hrs = jump.visit_duration / 3600
562
+ writer.writerow([
563
+ jump.system,
564
+ jump.arrival_time.isoformat() if jump.arrival_time else "",
565
+ jump.departure_time.isoformat() if jump.departure_time else "",
566
+ f"{duration_hrs:.1f}",
567
+ "Yes" if jump.success else "No",
568
+ ])
569
+
570
+ return filepath
571
+
572
+ def export_all(
573
+ self,
574
+ carrier: FleetCarrier,
575
+ prefix: str = "",
576
+ ) -> dict[str, Path]:
577
+ """
578
+ Export all carrier data to separate CSV files.
579
+
580
+ Args:
581
+ carrier: FleetCarrier data
582
+ prefix: Optional prefix for filenames
583
+
584
+ Returns:
585
+ Dictionary mapping export type to file path
586
+ """
587
+ callsign = carrier.identity.callsign
588
+
589
+ return {
590
+ "summary": self.export_carrier_summary(carrier),
591
+ "cargo": self.export_cargo(carrier),
592
+ "cargo_summary": self.export_cargo_aggregated(carrier),
593
+ "commodities": self.export_commodity_orders(carrier),
594
+ "microresources": self.export_microresources(carrier),
595
+ "locker": self.export_locker_inventory(carrier),
596
+ "crew": self.export_crew(carrier),
597
+ "jumps": self.export_jump_history(carrier),
598
+ }
599
+
600
+
601
+ class MarketExporter:
602
+ """
603
+ Export a station market as plain data.
604
+
605
+ Deliberately knows nothing about spreadsheets, requirements, or markers. A
606
+ market is a fact about a station -- what it sells, at what price, in what
607
+ quantity -- and it stays useful to anyone regardless of what they intend to
608
+ do with it. Whoever consumes this decides how to display it.
609
+
610
+ Mirrors CSVExporter/JSONExporter's shape so the market path and the carrier
611
+ path look the same to a caller.
612
+ """
613
+
614
+ def __init__(self, output_dir: Optional[Path] = None):
615
+ self.output_dir = output_dir or Path.cwd()
616
+ self.output_dir.mkdir(parents=True, exist_ok=True)
617
+
618
+ def _generate_filename(self, market, suffix: str) -> Path:
619
+ """Timestamped, and named after the station it describes."""
620
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
621
+ slug = re.sub(r"[^A-Za-z0-9]+", "-", market.station or "market").strip("-")
622
+ return self.output_dir / f"market_{slug}_{timestamp}.{suffix}"
623
+
624
+ def export_csv(self, market, filepath: Optional[Path] = None) -> Path:
625
+ """
626
+ One self-contained row per commodity.
627
+
628
+ Station, system and timestamp repeat on every row so files from
629
+ different stations concatenate into a usable dataset, and any single
630
+ row still says where and when it came from.
631
+ """
632
+ from .market import FLAT_FIELDS, flat_rows
633
+
634
+ filepath = filepath or self._generate_filename(market, "csv")
635
+ rows = flat_rows(market)
636
+ with open(filepath, "w", newline="", encoding="utf-8") as handle:
637
+ writer = csv.DictWriter(handle, fieldnames=FLAT_FIELDS)
638
+ writer.writeheader()
639
+ writer.writerows(rows)
640
+ return filepath
641
+
642
+ def export_json(self, market, filepath: Optional[Path] = None) -> Path:
643
+ """The same rows, with the market's identity hoisted out of them."""
644
+ from .market import flat_rows
645
+
646
+ filepath = filepath or self._generate_filename(market, "json")
647
+ payload = {
648
+ "station": market.station,
649
+ "system": market.system,
650
+ "market_id": market.market_id,
651
+ "timestamp": market.timestamp.isoformat() if market.timestamp else None,
652
+ "source": market.source,
653
+ "item_count": len(market.items),
654
+ "commodities": flat_rows(market),
655
+ }
656
+ with open(filepath, "w", encoding="utf-8") as handle:
657
+ json.dump(payload, handle, indent=2, ensure_ascii=False)
658
+ return filepath
659
+
660
+
661
+ class ShipCargoExporter:
662
+ """
663
+ Export the current ship's hold as plain data.
664
+
665
+ The ship's hold and the fleet carrier's hold are different inventories, so
666
+ this is a peer of the carrier exporters rather than a variation on them.
667
+ Like MarketExporter, it knows nothing about spreadsheets: what is aboard is
668
+ a fact about the ship, useful to anyone regardless of what they do next.
669
+ """
670
+
671
+ def __init__(self, output_dir: Optional[Path] = None):
672
+ self.output_dir = output_dir or Path.cwd()
673
+ self.output_dir.mkdir(parents=True, exist_ok=True)
674
+
675
+ def _generate_filename(self, cargo, suffix: str) -> Path:
676
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
677
+ slug = re.sub(r"[^A-Za-z0-9]+", "-", cargo.vessel or "ship").strip("-").lower()
678
+ return self.output_dir / f"shipcargo_{slug}_{timestamp}.{suffix}"
679
+
680
+ def export_csv(self, cargo, filepath: Optional[Path] = None) -> Path:
681
+ """
682
+ One self-contained row per commodity aboard.
683
+
684
+ Vessel and timestamp repeat on every row, so snapshots taken over a
685
+ trading run concatenate into something you can actually read.
686
+ """
687
+ from .ship import FLAT_FIELDS, flat_rows
688
+
689
+ filepath = filepath or self._generate_filename(cargo, "csv")
690
+ rows = flat_rows(cargo)
691
+ with open(filepath, "w", newline="", encoding="utf-8") as handle:
692
+ writer = csv.DictWriter(handle, fieldnames=FLAT_FIELDS)
693
+ writer.writeheader()
694
+ writer.writerows(rows)
695
+ return filepath
696
+
697
+ def export_json(self, cargo, filepath: Optional[Path] = None) -> Path:
698
+ """The same rows, with the hold's identity hoisted out of them."""
699
+ from .ship import flat_rows
700
+
701
+ filepath = filepath or self._generate_filename(cargo, "json")
702
+ with open(filepath, "w", encoding="utf-8") as handle:
703
+ json.dump(ship_payload(cargo), handle, indent=2, ensure_ascii=False)
704
+ return filepath
705
+
706
+
707
+ def ship_payload(cargo) -> dict:
708
+ """
709
+ The hold as one JSON-ready dict.
710
+
711
+ Shared by the file exporter and the CLI's ``--json``, so the structure a
712
+ script reads off stdout is byte-for-byte the structure it reads out of a
713
+ saved file. Two code paths producing "almost the same" JSON is a trap for
714
+ whoever writes the consumer.
715
+ """
716
+ from .ship import flat_rows
717
+
718
+ return {
719
+ "vessel": cargo.vessel,
720
+ "is_ship": cargo.is_ship,
721
+ "timestamp": cargo.timestamp.isoformat() if cargo.timestamp else None,
722
+ "reported_count": cargo.count,
723
+ "itemised_total": cargo.total,
724
+ "item_count": len(cargo.items),
725
+ "commodities": flat_rows(cargo),
726
+ }
727
+
728
+
729
+ class JSONExporter:
730
+ """Export data to JSON format."""
731
+
732
+ def __init__(self, output_dir: Optional[Path] = None):
733
+ """
734
+ Initialize JSON exporter.
735
+
736
+ Args:
737
+ output_dir: Directory for output files (default: current directory)
738
+ """
739
+ self.output_dir = output_dir or Path.cwd()
740
+ self.output_dir.mkdir(parents=True, exist_ok=True)
741
+
742
+ def export_carrier(
743
+ self,
744
+ carrier: FleetCarrier,
745
+ filepath: Optional[Path] = None,
746
+ include_raw: bool = False,
747
+ ) -> Path:
748
+ """
749
+ Export carrier data to JSON.
750
+
751
+ Args:
752
+ carrier: FleetCarrier data
753
+ filepath: Optional specific filepath
754
+ include_raw: Include raw CAPI response
755
+
756
+ Returns:
757
+ Path to created file
758
+ """
759
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
760
+ filepath = filepath or (
761
+ self.output_dir
762
+ / f"carrier_{carrier.identity.callsign}_{timestamp}.json"
763
+ )
764
+
765
+ data = {
766
+ "export_date": datetime.now().isoformat(),
767
+ "identity": {
768
+ "callsign": carrier.identity.callsign,
769
+ "name": carrier.identity.display_name,
770
+ },
771
+ "location": {
772
+ "system": carrier.location.system,
773
+ "state": carrier.location.state,
774
+ "docking_access": carrier.location.docking_access,
775
+ },
776
+ "finance": {
777
+ "balance": carrier.finance.balance,
778
+ "weekly_upkeep": carrier.finance.weekly_upkeep,
779
+ },
780
+ "fuel": carrier.fuel,
781
+ "commodity_orders": [
782
+ {
783
+ "name": o.name,
784
+ "type": o.order_type,
785
+ "quantity": o.stock if o.order_type == "sell" else o.demand,
786
+ "price": o.price,
787
+ }
788
+ for o in carrier.all_commodities
789
+ ],
790
+ "microresource_orders": [
791
+ {
792
+ "name": o.localized_name,
793
+ "type": o.order_type,
794
+ "quantity": o.stock if o.order_type == "sell" else o.demand,
795
+ "price": o.price,
796
+ }
797
+ for o in carrier.all_microresources
798
+ ],
799
+ "locker": [
800
+ {
801
+ "name": i.localized_name,
802
+ "category": i.category,
803
+ "quantity": i.quantity,
804
+ }
805
+ for i in carrier.locker_items
806
+ ],
807
+ }
808
+
809
+ if include_raw:
810
+ data["raw_capi_response"] = carrier.raw_data
811
+
812
+ with open(filepath, "w", encoding="utf-8") as f:
813
+ json.dump(data, f, indent=2, ensure_ascii=False)
814
+
815
+ return filepath
816
+
817
+
818
+ class ConstructionExporter:
819
+ """
820
+ Export a colony construction site's requirements as plain data.
821
+
822
+ A peer of MarketExporter and ShipCargoExporter, and like them it knows
823
+ nothing about spreadsheets: what a site still needs is a fact about the
824
+ site, useful to anyone regardless of what they do with it next.
825
+ """
826
+
827
+ def __init__(self, output_dir: Optional[Path] = None):
828
+ self.output_dir = output_dir or Path.cwd()
829
+ self.output_dir.mkdir(parents=True, exist_ok=True)
830
+
831
+ def _generate_filename(self, site, suffix: str) -> Path:
832
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
833
+ return self.output_dir / f"construction_{site.market_id or 'site'}_{timestamp}.{suffix}"
834
+
835
+ def export_csv(self, site, filepath: Optional[Path] = None) -> Path:
836
+ """
837
+ One self-contained row per commodity the site wants.
838
+
839
+ The site's identity and progress repeat on every row, so snapshots
840
+ taken across a build concatenate into something readable -- the same
841
+ choice ShipCargoExporter makes, for the same reason.
842
+ """
843
+ filepath = filepath or self._generate_filename(site, "csv")
844
+ fields = [
845
+ "market_id", "timestamp", "progress", "complete",
846
+ "commodity", "symbol", "required", "provided", "remaining", "payment",
847
+ ]
848
+ stamp = site.timestamp.isoformat() if site.timestamp else ""
849
+ with open(filepath, "w", newline="", encoding="utf-8") as handle:
850
+ writer = csv.DictWriter(handle, fieldnames=fields)
851
+ writer.writeheader()
852
+ for resource in site.resources:
853
+ writer.writerow({
854
+ "market_id": site.market_id,
855
+ "timestamp": stamp,
856
+ "progress": round(site.progress, 6),
857
+ "complete": site.complete,
858
+ "commodity": resource.name,
859
+ "symbol": resource.symbol,
860
+ "required": resource.required,
861
+ "provided": resource.provided,
862
+ "remaining": resource.remaining,
863
+ "payment": resource.payment,
864
+ })
865
+ return filepath
866
+
867
+ def export_json(self, site, filepath: Optional[Path] = None) -> Path:
868
+ """The same data, with the site's identity hoisted out of the rows."""
869
+ filepath = filepath or self._generate_filename(site, "json")
870
+ with open(filepath, "w", encoding="utf-8") as handle:
871
+ json.dump(construction_payload(site), handle, indent=2, ensure_ascii=False)
872
+ return filepath
873
+
874
+
875
+ def construction_payload(site) -> dict:
876
+ """
877
+ One construction site as a JSON-ready dict.
878
+
879
+ Shared by the file exporter and the CLI's ``--json`` for the same reason
880
+ ship_payload is: a script reading stdout and a script reading a saved file
881
+ must see the identical structure. Two near-identical builders is precisely
882
+ the defect that let a live regression through earlier in this project --
883
+ the CLI and the daemon each had their own copy of one grid function.
884
+ """
885
+ return {
886
+ "market_id": site.market_id,
887
+ "timestamp": site.timestamp.isoformat() if site.timestamp else None,
888
+ "progress": site.progress,
889
+ "complete": site.complete,
890
+ "failed": site.failed,
891
+ "total_required": site.total_required,
892
+ "total_provided": site.total_provided,
893
+ "total_remaining": site.total_remaining,
894
+ "resources": [
895
+ {
896
+ "symbol": r.symbol,
897
+ "name": r.name,
898
+ "required": r.required,
899
+ "provided": r.provided,
900
+ "remaining": r.remaining,
901
+ "payment": r.payment,
902
+ }
903
+ for r in site.resources
904
+ ],
905
+ }
906
+
907
+
908
+ # The two header rows the block carries, and where the commodity table
909
+ # begins. Named rather than counted at the call site so a sheet formula's
910
+ # range and the writer's own idea of where the table starts cannot drift.
911
+ CONSTRUCTION_REGION_META = [
912
+ "Site", "System", "MarketID", "Updated (UTC)", "State",
913
+ "Progress", "Required", "Provided", "Remaining",
914
+ ]
915
+ CONSTRUCTION_REGION_HEADERS = [
916
+ "Symbol", "Commodity", "Required", "Provided", "Remaining", "Payment",
917
+ ]
918
+ # Rows 1-2 are the metadata header and its values, row 3 is blank, row 4 is
919
+ # the table's own header. The table therefore starts at row 5 -- the same row
920
+ # the settlement tabs in this workbook start their commodities on, so a
921
+ # commodity in column B lands on the same line as its counterpart in the
922
+ # block and the two can be checked against each other by eye.
923
+ CONSTRUCTION_REGION_TABLE_ROW = 5
924
+
925
+
926
+ def construction_region_rows(site, location=None) -> list[list]:
927
+ """
928
+ One construction site as a grid, for publishing into a region of a tab.
929
+
930
+ A metadata header and its values, a blank line, then the commodity table
931
+ with its own headers. A spreadsheet reads the table with the same VLOOKUP
932
+ idiom it already uses for MarketData and ShipCargo. Nothing here decides
933
+ what the numbers mean: the tool states what the game said, and the sheet's
934
+ own formulas turn that into "buy this many".
935
+
936
+ Why the metadata is labelled
937
+ ---------------------------
938
+ The first version emitted those nine values with no header at all, and the
939
+ first question anyone asked of it was "what are the numbers after
940
+ 'active'?". A hidden region is still read by a person -- when they set it
941
+ up, and every time something looks wrong -- and ``8517`` alone in a cell
942
+ tells them nothing.
943
+
944
+ Why the table starts at row 5
945
+ -----------------------------
946
+ Because the settlement tabs this is published beside start their own
947
+ commodities on row 5. A commodity in column B therefore lands on the same
948
+ line as its counterpart in the block, and the two can be checked against
949
+ each other by eye without counting rows. A vertical label/value list was
950
+ considered and rejected for exactly this: it reads well on its own and
951
+ pushes the table down past any such alignment.
952
+
953
+ Pure, and separate from any writer, for the same reason the other grid
954
+ builders are: a grid you cannot construct without a network connection is
955
+ a grid nobody tests.
956
+
957
+ ``location`` is optional because a site is identified by its MarketID and
958
+ is perfectly reportable without ever having learned its name -- which is
959
+ the normal case for a site seen only in passing.
960
+ """
961
+ name = system = ""
962
+ if location is not None:
963
+ name = location.short_station or ""
964
+ system = location.system or ""
965
+
966
+ stamp = site.timestamp.isoformat() if site.timestamp else ""
967
+ state = "complete" if site.complete else ("failed" if site.failed else "active")
968
+
969
+ # The block is as wide as its widest row. Every row is padded to match, so
970
+ # the grid is rectangular -- a ragged one is a separate write per row.
971
+ width = len(CONSTRUCTION_REGION_META)
972
+
973
+ def pad(cells: list) -> list:
974
+ return list(cells) + [""] * (width - len(cells))
975
+
976
+ rows: list[list] = [
977
+ pad(CONSTRUCTION_REGION_META),
978
+ pad([name, system, site.market_id, stamp, state, site.progress,
979
+ site.total_required, site.total_provided, site.total_remaining]),
980
+ pad([]),
981
+ pad(CONSTRUCTION_REGION_HEADERS),
982
+ ]
983
+ for r in site.resources:
984
+ rows.append(pad([
985
+ r.symbol, r.name, r.required, r.provided, r.remaining, r.payment,
986
+ ]))
987
+ return rows