setiastrosuitepro 1.6.1__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.

Potentially problematic release.


This version of setiastrosuitepro might be problematic. Click here for more details.

Files changed (342) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/data/SASP_data.fits +0 -0
  3. setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
  4. setiastro/data/catalogs/astrobin_filters.csv +890 -0
  5. setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
  6. setiastro/data/catalogs/cali2.csv +63 -0
  7. setiastro/data/catalogs/cali2color.csv +65 -0
  8. setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
  9. setiastro/data/catalogs/celestial_catalog.csv +24031 -0
  10. setiastro/data/catalogs/detected_stars.csv +24784 -0
  11. setiastro/data/catalogs/fits_header_data.csv +46 -0
  12. setiastro/data/catalogs/test.csv +8 -0
  13. setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
  14. setiastro/images/Astro_Spikes.png +0 -0
  15. setiastro/images/HRDiagram.png +0 -0
  16. setiastro/images/LExtract.png +0 -0
  17. setiastro/images/LInsert.png +0 -0
  18. setiastro/images/Oxygenation-atm-2.svg.png +0 -0
  19. setiastro/images/RGB080604.png +0 -0
  20. setiastro/images/abeicon.png +0 -0
  21. setiastro/images/aberration.png +0 -0
  22. setiastro/images/andromedatry.png +0 -0
  23. setiastro/images/andromedatry_satellited.png +0 -0
  24. setiastro/images/annotated.png +0 -0
  25. setiastro/images/aperture.png +0 -0
  26. setiastro/images/astrosuite.ico +0 -0
  27. setiastro/images/astrosuite.png +0 -0
  28. setiastro/images/astrosuitepro.icns +0 -0
  29. setiastro/images/astrosuitepro.ico +0 -0
  30. setiastro/images/astrosuitepro.png +0 -0
  31. setiastro/images/background.png +0 -0
  32. setiastro/images/background2.png +0 -0
  33. setiastro/images/benchmark.png +0 -0
  34. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  35. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  36. setiastro/images/blaster.png +0 -0
  37. setiastro/images/blink.png +0 -0
  38. setiastro/images/clahe.png +0 -0
  39. setiastro/images/collage.png +0 -0
  40. setiastro/images/colorwheel.png +0 -0
  41. setiastro/images/contsub.png +0 -0
  42. setiastro/images/convo.png +0 -0
  43. setiastro/images/copyslot.png +0 -0
  44. setiastro/images/cosmic.png +0 -0
  45. setiastro/images/cosmicsat.png +0 -0
  46. setiastro/images/crop1.png +0 -0
  47. setiastro/images/cropicon.png +0 -0
  48. setiastro/images/curves.png +0 -0
  49. setiastro/images/cvs.png +0 -0
  50. setiastro/images/debayer.png +0 -0
  51. setiastro/images/denoise_cnn_custom.png +0 -0
  52. setiastro/images/denoise_cnn_graph.png +0 -0
  53. setiastro/images/disk.png +0 -0
  54. setiastro/images/dse.png +0 -0
  55. setiastro/images/exoicon.png +0 -0
  56. setiastro/images/eye.png +0 -0
  57. setiastro/images/fliphorizontal.png +0 -0
  58. setiastro/images/flipvertical.png +0 -0
  59. setiastro/images/font.png +0 -0
  60. setiastro/images/freqsep.png +0 -0
  61. setiastro/images/functionbundle.png +0 -0
  62. setiastro/images/graxpert.png +0 -0
  63. setiastro/images/green.png +0 -0
  64. setiastro/images/gridicon.png +0 -0
  65. setiastro/images/halo.png +0 -0
  66. setiastro/images/hdr.png +0 -0
  67. setiastro/images/histogram.png +0 -0
  68. setiastro/images/hubble.png +0 -0
  69. setiastro/images/imagecombine.png +0 -0
  70. setiastro/images/invert.png +0 -0
  71. setiastro/images/isophote.png +0 -0
  72. setiastro/images/isophote_demo_figure.png +0 -0
  73. setiastro/images/isophote_demo_image.png +0 -0
  74. setiastro/images/isophote_demo_model.png +0 -0
  75. setiastro/images/isophote_demo_residual.png +0 -0
  76. setiastro/images/jwstpupil.png +0 -0
  77. setiastro/images/linearfit.png +0 -0
  78. setiastro/images/livestacking.png +0 -0
  79. setiastro/images/mask.png +0 -0
  80. setiastro/images/maskapply.png +0 -0
  81. setiastro/images/maskcreate.png +0 -0
  82. setiastro/images/maskremove.png +0 -0
  83. setiastro/images/morpho.png +0 -0
  84. setiastro/images/mosaic.png +0 -0
  85. setiastro/images/multiscale_decomp.png +0 -0
  86. setiastro/images/nbtorgb.png +0 -0
  87. setiastro/images/neutral.png +0 -0
  88. setiastro/images/nuke.png +0 -0
  89. setiastro/images/openfile.png +0 -0
  90. setiastro/images/pedestal.png +0 -0
  91. setiastro/images/pen.png +0 -0
  92. setiastro/images/pixelmath.png +0 -0
  93. setiastro/images/platesolve.png +0 -0
  94. setiastro/images/ppp.png +0 -0
  95. setiastro/images/pro.png +0 -0
  96. setiastro/images/project.png +0 -0
  97. setiastro/images/psf.png +0 -0
  98. setiastro/images/redo.png +0 -0
  99. setiastro/images/redoicon.png +0 -0
  100. setiastro/images/rescale.png +0 -0
  101. setiastro/images/rgbalign.png +0 -0
  102. setiastro/images/rgbcombo.png +0 -0
  103. setiastro/images/rgbextract.png +0 -0
  104. setiastro/images/rotate180.png +0 -0
  105. setiastro/images/rotateclockwise.png +0 -0
  106. setiastro/images/rotatecounterclockwise.png +0 -0
  107. setiastro/images/satellite.png +0 -0
  108. setiastro/images/script.png +0 -0
  109. setiastro/images/selectivecolor.png +0 -0
  110. setiastro/images/simbad.png +0 -0
  111. setiastro/images/slot0.png +0 -0
  112. setiastro/images/slot1.png +0 -0
  113. setiastro/images/slot2.png +0 -0
  114. setiastro/images/slot3.png +0 -0
  115. setiastro/images/slot4.png +0 -0
  116. setiastro/images/slot5.png +0 -0
  117. setiastro/images/slot6.png +0 -0
  118. setiastro/images/slot7.png +0 -0
  119. setiastro/images/slot8.png +0 -0
  120. setiastro/images/slot9.png +0 -0
  121. setiastro/images/spcc.png +0 -0
  122. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  123. setiastro/images/spinner.gif +0 -0
  124. setiastro/images/stacking.png +0 -0
  125. setiastro/images/staradd.png +0 -0
  126. setiastro/images/staralign.png +0 -0
  127. setiastro/images/starnet.png +0 -0
  128. setiastro/images/starregistration.png +0 -0
  129. setiastro/images/starspike.png +0 -0
  130. setiastro/images/starstretch.png +0 -0
  131. setiastro/images/statstretch.png +0 -0
  132. setiastro/images/supernova.png +0 -0
  133. setiastro/images/uhs.png +0 -0
  134. setiastro/images/undoicon.png +0 -0
  135. setiastro/images/upscale.png +0 -0
  136. setiastro/images/viewbundle.png +0 -0
  137. setiastro/images/whitebalance.png +0 -0
  138. setiastro/images/wimi_icon_256x256.png +0 -0
  139. setiastro/images/wimilogo.png +0 -0
  140. setiastro/images/wims.png +0 -0
  141. setiastro/images/wrench_icon.png +0 -0
  142. setiastro/images/xisfliberator.png +0 -0
  143. setiastro/saspro/__init__.py +20 -0
  144. setiastro/saspro/__main__.py +809 -0
  145. setiastro/saspro/_generated/__init__.py +7 -0
  146. setiastro/saspro/_generated/build_info.py +2 -0
  147. setiastro/saspro/abe.py +1295 -0
  148. setiastro/saspro/abe_preset.py +196 -0
  149. setiastro/saspro/aberration_ai.py +694 -0
  150. setiastro/saspro/aberration_ai_preset.py +224 -0
  151. setiastro/saspro/accel_installer.py +218 -0
  152. setiastro/saspro/accel_workers.py +30 -0
  153. setiastro/saspro/add_stars.py +621 -0
  154. setiastro/saspro/astrobin_exporter.py +1007 -0
  155. setiastro/saspro/astrospike.py +153 -0
  156. setiastro/saspro/astrospike_python.py +1839 -0
  157. setiastro/saspro/autostretch.py +196 -0
  158. setiastro/saspro/backgroundneutral.py +560 -0
  159. setiastro/saspro/batch_convert.py +325 -0
  160. setiastro/saspro/batch_renamer.py +519 -0
  161. setiastro/saspro/blemish_blaster.py +488 -0
  162. setiastro/saspro/blink_comparator_pro.py +2926 -0
  163. setiastro/saspro/bundles.py +61 -0
  164. setiastro/saspro/bundles_dock.py +114 -0
  165. setiastro/saspro/cheat_sheet.py +178 -0
  166. setiastro/saspro/clahe.py +342 -0
  167. setiastro/saspro/comet_stacking.py +1377 -0
  168. setiastro/saspro/common_tr.py +107 -0
  169. setiastro/saspro/config.py +38 -0
  170. setiastro/saspro/config_bootstrap.py +40 -0
  171. setiastro/saspro/config_manager.py +316 -0
  172. setiastro/saspro/continuum_subtract.py +1617 -0
  173. setiastro/saspro/convo.py +1397 -0
  174. setiastro/saspro/convo_preset.py +414 -0
  175. setiastro/saspro/copyastro.py +187 -0
  176. setiastro/saspro/cosmicclarity.py +1564 -0
  177. setiastro/saspro/cosmicclarity_preset.py +407 -0
  178. setiastro/saspro/crop_dialog_pro.py +956 -0
  179. setiastro/saspro/crop_preset.py +189 -0
  180. setiastro/saspro/curve_editor_pro.py +2544 -0
  181. setiastro/saspro/curves_preset.py +375 -0
  182. setiastro/saspro/debayer.py +670 -0
  183. setiastro/saspro/debug_utils.py +29 -0
  184. setiastro/saspro/dnd_mime.py +35 -0
  185. setiastro/saspro/doc_manager.py +2641 -0
  186. setiastro/saspro/exoplanet_detector.py +2166 -0
  187. setiastro/saspro/file_utils.py +284 -0
  188. setiastro/saspro/fitsmodifier.py +745 -0
  189. setiastro/saspro/fix_bom.py +32 -0
  190. setiastro/saspro/free_torch_memory.py +48 -0
  191. setiastro/saspro/frequency_separation.py +1343 -0
  192. setiastro/saspro/function_bundle.py +1594 -0
  193. setiastro/saspro/generate_translations.py +2378 -0
  194. setiastro/saspro/ghs_dialog_pro.py +660 -0
  195. setiastro/saspro/ghs_preset.py +284 -0
  196. setiastro/saspro/graxpert.py +634 -0
  197. setiastro/saspro/graxpert_preset.py +287 -0
  198. setiastro/saspro/gui/__init__.py +0 -0
  199. setiastro/saspro/gui/main_window.py +8567 -0
  200. setiastro/saspro/gui/mixins/__init__.py +33 -0
  201. setiastro/saspro/gui/mixins/dock_mixin.py +263 -0
  202. setiastro/saspro/gui/mixins/file_mixin.py +443 -0
  203. setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
  204. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  205. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  206. setiastro/saspro/gui/mixins/menu_mixin.py +361 -0
  207. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  208. setiastro/saspro/gui/mixins/toolbar_mixin.py +1457 -0
  209. setiastro/saspro/gui/mixins/update_mixin.py +309 -0
  210. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  211. setiastro/saspro/halobgon.py +462 -0
  212. setiastro/saspro/header_viewer.py +448 -0
  213. setiastro/saspro/headless_utils.py +88 -0
  214. setiastro/saspro/histogram.py +753 -0
  215. setiastro/saspro/history_explorer.py +939 -0
  216. setiastro/saspro/i18n.py +156 -0
  217. setiastro/saspro/image_combine.py +414 -0
  218. setiastro/saspro/image_peeker_pro.py +1601 -0
  219. setiastro/saspro/imageops/__init__.py +37 -0
  220. setiastro/saspro/imageops/mdi_snap.py +292 -0
  221. setiastro/saspro/imageops/scnr.py +36 -0
  222. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  223. setiastro/saspro/imageops/stretch.py +244 -0
  224. setiastro/saspro/isophote.py +1179 -0
  225. setiastro/saspro/layers.py +208 -0
  226. setiastro/saspro/layers_dock.py +714 -0
  227. setiastro/saspro/lazy_imports.py +193 -0
  228. setiastro/saspro/legacy/__init__.py +2 -0
  229. setiastro/saspro/legacy/image_manager.py +2226 -0
  230. setiastro/saspro/legacy/numba_utils.py +3659 -0
  231. setiastro/saspro/legacy/xisf.py +1071 -0
  232. setiastro/saspro/linear_fit.py +534 -0
  233. setiastro/saspro/live_stacking.py +1830 -0
  234. setiastro/saspro/log_bus.py +5 -0
  235. setiastro/saspro/logging_config.py +460 -0
  236. setiastro/saspro/luminancerecombine.py +309 -0
  237. setiastro/saspro/main_helpers.py +201 -0
  238. setiastro/saspro/mask_creation.py +928 -0
  239. setiastro/saspro/masks_core.py +56 -0
  240. setiastro/saspro/mdi_widgets.py +353 -0
  241. setiastro/saspro/memory_utils.py +666 -0
  242. setiastro/saspro/metadata_patcher.py +75 -0
  243. setiastro/saspro/mfdeconv.py +3826 -0
  244. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  245. setiastro/saspro/mfdeconvcudnn.py +3263 -0
  246. setiastro/saspro/mfdeconvsport.py +2382 -0
  247. setiastro/saspro/minorbodycatalog.py +567 -0
  248. setiastro/saspro/morphology.py +382 -0
  249. setiastro/saspro/multiscale_decomp.py +1290 -0
  250. setiastro/saspro/nbtorgb_stars.py +531 -0
  251. setiastro/saspro/numba_utils.py +3044 -0
  252. setiastro/saspro/numba_warmup.py +141 -0
  253. setiastro/saspro/ops/__init__.py +9 -0
  254. setiastro/saspro/ops/command_help_dialog.py +623 -0
  255. setiastro/saspro/ops/command_runner.py +217 -0
  256. setiastro/saspro/ops/commands.py +1594 -0
  257. setiastro/saspro/ops/script_editor.py +1102 -0
  258. setiastro/saspro/ops/scripts.py +1413 -0
  259. setiastro/saspro/ops/settings.py +679 -0
  260. setiastro/saspro/parallel_utils.py +554 -0
  261. setiastro/saspro/pedestal.py +121 -0
  262. setiastro/saspro/perfect_palette_picker.py +1070 -0
  263. setiastro/saspro/pipeline.py +110 -0
  264. setiastro/saspro/pixelmath.py +1600 -0
  265. setiastro/saspro/plate_solver.py +2444 -0
  266. setiastro/saspro/project_io.py +797 -0
  267. setiastro/saspro/psf_utils.py +136 -0
  268. setiastro/saspro/psf_viewer.py +549 -0
  269. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  270. setiastro/saspro/remove_green.py +314 -0
  271. setiastro/saspro/remove_stars.py +1625 -0
  272. setiastro/saspro/remove_stars_preset.py +404 -0
  273. setiastro/saspro/resources.py +477 -0
  274. setiastro/saspro/rgb_combination.py +207 -0
  275. setiastro/saspro/rgb_extract.py +19 -0
  276. setiastro/saspro/rgbalign.py +723 -0
  277. setiastro/saspro/runtime_imports.py +7 -0
  278. setiastro/saspro/runtime_torch.py +754 -0
  279. setiastro/saspro/save_options.py +72 -0
  280. setiastro/saspro/selective_color.py +1552 -0
  281. setiastro/saspro/sfcc.py +1430 -0
  282. setiastro/saspro/shortcuts.py +3043 -0
  283. setiastro/saspro/signature_insert.py +1099 -0
  284. setiastro/saspro/stacking_suite.py +18181 -0
  285. setiastro/saspro/star_alignment.py +7420 -0
  286. setiastro/saspro/star_alignment_preset.py +329 -0
  287. setiastro/saspro/star_metrics.py +49 -0
  288. setiastro/saspro/star_spikes.py +681 -0
  289. setiastro/saspro/star_stretch.py +470 -0
  290. setiastro/saspro/stat_stretch.py +506 -0
  291. setiastro/saspro/status_log_dock.py +78 -0
  292. setiastro/saspro/subwindow.py +3267 -0
  293. setiastro/saspro/supernovaasteroidhunter.py +1716 -0
  294. setiastro/saspro/swap_manager.py +99 -0
  295. setiastro/saspro/torch_backend.py +89 -0
  296. setiastro/saspro/torch_rejection.py +434 -0
  297. setiastro/saspro/translations/de_translations.py +3733 -0
  298. setiastro/saspro/translations/es_translations.py +3923 -0
  299. setiastro/saspro/translations/fr_translations.py +3842 -0
  300. setiastro/saspro/translations/integrate_translations.py +234 -0
  301. setiastro/saspro/translations/it_translations.py +3662 -0
  302. setiastro/saspro/translations/ja_translations.py +3585 -0
  303. setiastro/saspro/translations/pt_translations.py +3853 -0
  304. setiastro/saspro/translations/saspro_de.qm +0 -0
  305. setiastro/saspro/translations/saspro_de.ts +253 -0
  306. setiastro/saspro/translations/saspro_es.qm +0 -0
  307. setiastro/saspro/translations/saspro_es.ts +12520 -0
  308. setiastro/saspro/translations/saspro_fr.qm +0 -0
  309. setiastro/saspro/translations/saspro_fr.ts +12514 -0
  310. setiastro/saspro/translations/saspro_it.qm +0 -0
  311. setiastro/saspro/translations/saspro_it.ts +12520 -0
  312. setiastro/saspro/translations/saspro_ja.qm +0 -0
  313. setiastro/saspro/translations/saspro_ja.ts +257 -0
  314. setiastro/saspro/translations/saspro_pt.qm +0 -0
  315. setiastro/saspro/translations/saspro_pt.ts +257 -0
  316. setiastro/saspro/translations/saspro_zh.qm +0 -0
  317. setiastro/saspro/translations/saspro_zh.ts +12520 -0
  318. setiastro/saspro/translations/zh_translations.py +3659 -0
  319. setiastro/saspro/versioning.py +71 -0
  320. setiastro/saspro/view_bundle.py +1555 -0
  321. setiastro/saspro/wavescale_hdr.py +624 -0
  322. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  323. setiastro/saspro/wavescalede.py +658 -0
  324. setiastro/saspro/wavescalede_preset.py +230 -0
  325. setiastro/saspro/wcs_update.py +374 -0
  326. setiastro/saspro/whitebalance.py +456 -0
  327. setiastro/saspro/widgets/__init__.py +48 -0
  328. setiastro/saspro/widgets/common_utilities.py +306 -0
  329. setiastro/saspro/widgets/graphics_views.py +122 -0
  330. setiastro/saspro/widgets/image_utils.py +518 -0
  331. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  332. setiastro/saspro/widgets/spinboxes.py +275 -0
  333. setiastro/saspro/widgets/themed_buttons.py +13 -0
  334. setiastro/saspro/widgets/wavelet_utils.py +299 -0
  335. setiastro/saspro/window_shelf.py +185 -0
  336. setiastro/saspro/xisf.py +1123 -0
  337. setiastrosuitepro-1.6.1.dist-info/METADATA +267 -0
  338. setiastrosuitepro-1.6.1.dist-info/RECORD +342 -0
  339. setiastrosuitepro-1.6.1.dist-info/WHEEL +4 -0
  340. setiastrosuitepro-1.6.1.dist-info/entry_points.txt +6 -0
  341. setiastrosuitepro-1.6.1.dist-info/licenses/LICENSE +674 -0
  342. setiastrosuitepro-1.6.1.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,1007 @@
1
+ # pro/astrobin_exporter.py
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import re
6
+ import io
7
+ import csv
8
+ import webbrowser
9
+ import shutil
10
+ from typing import List, Dict, Tuple, Optional
11
+ from collections import defaultdict
12
+ from datetime import datetime, timezone, timedelta
13
+
14
+ import numpy as np
15
+ from astropy.io import fits
16
+
17
+ from PyQt6.QtCore import Qt, QTimer, QSettings
18
+ from PyQt6.QtGui import (
19
+ QFontMetrics, QIntValidator, QDoubleValidator,
20
+ QStandardItemModel, QStandardItem
21
+ )
22
+ from PyQt6.QtWidgets import (
23
+ QDialog, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton,
24
+ QGroupBox, QGridLayout, QSplitter, QListWidget, QListWidgetItem, QFileDialog,
25
+ QTreeWidget, QTreeWidgetItem, QHeaderView, QAbstractItemView, QTableWidget,
26
+ QTableWidgetItem, QTextEdit, QDialogButtonBox, QComboBox, QSpinBox, QCheckBox,
27
+ QSpacerItem, QSizePolicy, QStyledItemDelegate, QToolButton, QMessageBox, QCompleter
28
+ )
29
+ from PyQt6.QtGui import QGuiApplication
30
+
31
+ # Optional XISF support (skip .xisf if not importable)
32
+ try:
33
+ from setiastro.saspro.legacy.xisf import XISF
34
+ except Exception:
35
+ XISF = None # handled gracefully below
36
+
37
+ # ---- constants --------------------------------------------------------------
38
+
39
+ ASTROBIN_FILTER_URL = "https://app.astrobin.com/equipment/explorer/filter?page=1"
40
+
41
+ # Try to honor your project path name if present; otherwise, fall back next to this file.
42
+
43
+
44
+ # ---- delegates / completer -------------------------------------------------
45
+
46
+ class _IdOnlyCompleter(QCompleter):
47
+ """
48
+ Shows 'ID — Brand — Name' in the popup, but inserts only the numeric ID.
49
+ """
50
+ def pathFromIndex(self, index):
51
+ return index.data(Qt.ItemDataRole.UserRole) or super().pathFromIndex(index)
52
+
53
+ class _AstrobinIdDelegate(QStyledItemDelegate):
54
+ """
55
+ QLineEdit with int validator + optional completer for the AstroBin ID column.
56
+ """
57
+ def __init__(self, parent=None, completer: Optional[QCompleter] = None):
58
+ super().__init__(parent)
59
+ self._completer = completer
60
+
61
+ def createEditor(self, parent, option, index):
62
+ editor = QLineEdit(parent)
63
+ editor.setPlaceholderText(self.tr("e.g. 4408"))
64
+ editor.setValidator(QIntValidator(1, 999_999_999, editor))
65
+ if self._completer is not None:
66
+ editor.setCompleter(self._completer)
67
+ return editor
68
+
69
+ # ---- Filter ID editor ------------------------------------------------------
70
+
71
+
72
+
73
+ class FilterIdDialog(QDialog):
74
+ """
75
+ Editable table: local filter name ↔ AstroBin numeric ID.
76
+ Loads/saves mapping in QSettings key: astrobin_exporter/filter_map
77
+ Also supports an offline CSV for ID lookup / completion.
78
+ """
79
+
80
+ BLANK_ROWS = 6
81
+
82
+ def __init__(self, parent, filters_in_data: List[str], settings: QSettings,
83
+ current_map: Optional[Dict[str, str]] = None,
84
+ offline_csv_default: Optional[str] = None):
85
+ super().__init__(parent)
86
+ self.setWindowTitle(self.tr("AstroBin Filter IDs"))
87
+ self.settings = settings
88
+ self._offline_csv_default = offline_csv_default
89
+ base_names = sorted({f for f in (filters_in_data or []) if f and f != "Unknown"}, key=str.lower)
90
+ stored_map = self._load_mapping()
91
+ if current_map:
92
+ stored_map = {**stored_map, **current_map}
93
+ all_names = sorted(set(base_names) | set(stored_map.keys()), key=str.lower)
94
+
95
+ root = QVBoxLayout(self)
96
+
97
+ # Help row
98
+ help_row = QHBoxLayout()
99
+ help_label = QLabel(self.tr("Edit filter names and their AstroBin numeric IDs."))
100
+ help_btn = QToolButton(self)
101
+ help_btn.setText("?")
102
+ help_btn.setToolTip(self.tr("Open AstroBin Equipment Explorer (Filters)"))
103
+ help_btn.clicked.connect(lambda: webbrowser.open(ASTROBIN_FILTER_URL))
104
+
105
+ self.load_db_btn = QPushButton(self)
106
+ self.load_db_btn.setToolTip(self.tr("Search or load the offline filters database."))
107
+ self.load_db_btn.clicked.connect(self._on_offline_action)
108
+
109
+ help_row.addWidget(help_label)
110
+ help_row.addStretch(1)
111
+ help_row.addWidget(self.load_db_btn)
112
+ help_row.addWidget(help_btn)
113
+ root.addLayout(help_row)
114
+
115
+ # Table
116
+ self.table = QTableWidget(self)
117
+ self.table.setColumnCount(2)
118
+ self.table.setHorizontalHeaderLabels([self.tr("Filter name"), self.tr("AstroBin ID")])
119
+ self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
120
+ self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
121
+ self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
122
+ self.table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
123
+ self.table.setEditTriggers(QAbstractItemView.EditTrigger.AllEditTriggers)
124
+
125
+ # Offline DB → completer
126
+ self._load_offline_db()
127
+ self._id_completer = self._make_id_completer()
128
+ self.table.setItemDelegateForColumn(1, _AstrobinIdDelegate(self.table, completer=self._id_completer))
129
+ self._update_offline_button_text()
130
+
131
+ # Fill rows
132
+ rows = len(all_names) + self.BLANK_ROWS
133
+ self.table.setRowCount(rows)
134
+ r = 0
135
+ for name in all_names:
136
+ name_item = QTableWidgetItem(name)
137
+ name_item.setFlags(name_item.flags() | Qt.ItemFlag.ItemIsEditable)
138
+ id_item = QTableWidgetItem(str(stored_map.get(name, "")))
139
+ id_item.setFlags(id_item.flags() | Qt.ItemFlag.ItemIsEditable)
140
+ id_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
141
+ self.table.setItem(r, 0, name_item)
142
+ self.table.setItem(r, 1, id_item)
143
+ r += 1
144
+
145
+ while r < rows:
146
+ self.table.setItem(r, 0, QTableWidgetItem(""))
147
+ self.table.setItem(r, 1, QTableWidgetItem(""))
148
+ self.table.item(r, 0).setFlags(self.table.item(r, 0).flags() | Qt.ItemFlag.ItemIsEditable)
149
+ self.table.item(r, 1).setFlags(self.table.item(r, 1).flags() | Qt.ItemFlag.ItemIsEditable)
150
+ self.table.item(r, 1).setTextAlignment(Qt.AlignmentFlag.AlignCenter)
151
+ r += 1
152
+
153
+ root.addWidget(self.table, 1)
154
+
155
+ # Row actions
156
+ row_actions = QHBoxLayout()
157
+ self.btn_add = QPushButton(self.tr("Add row")); self.btn_add.clicked.connect(self._add_row)
158
+ self.btn_del = QPushButton(self.tr("Delete selected")); self.btn_del.clicked.connect(self._delete_selected_rows)
159
+ row_actions.addWidget(self.btn_add); row_actions.addWidget(self.btn_del); row_actions.addStretch(1)
160
+ root.addLayout(row_actions)
161
+
162
+ # OK/Cancel
163
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
164
+ btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
165
+ root.addWidget(btns)
166
+
167
+ self.table.setCurrentCell(0, 0)
168
+
169
+ # --- settings map helpers ---
170
+ def _load_mapping(self) -> Dict[str, str]:
171
+ self.settings.beginGroup("astrobin_exporter")
172
+ raw = self.settings.value("filter_map", "")
173
+ self.settings.endGroup()
174
+ mapping: Dict[str, str] = {}
175
+ if isinstance(raw, str) and raw:
176
+ for chunk in raw.split(";"):
177
+ if "=" in chunk:
178
+ k, v = chunk.split("=", 1)
179
+ k, v = k.strip(), v.strip()
180
+ if k:
181
+ mapping[k] = v
182
+ return mapping
183
+
184
+ def mapping(self) -> Dict[str, str]:
185
+ mp: Dict[str, str] = {}
186
+ rows = self.table.rowCount()
187
+ for r in range(rows):
188
+ name_item = self.table.item(r, 0)
189
+ id_item = self.table.item(r, 1)
190
+ name = (name_item.text().strip() if name_item else "")
191
+ fid = (id_item.text().strip() if id_item else "")
192
+ if not name:
193
+ continue
194
+ if fid and fid.isdigit():
195
+ mp[name] = fid
196
+ return mp
197
+
198
+ def _add_row(self):
199
+ r = self.table.rowCount()
200
+ self.table.insertRow(r)
201
+ self.table.setItem(r, 0, QTableWidgetItem(""))
202
+ self.table.setItem(r, 1, QTableWidgetItem(""))
203
+ # make editable + center the ID
204
+ self.table.item(r, 0).setFlags(self.table.item(r, 0).flags() | Qt.ItemFlag.ItemIsEditable)
205
+ self.table.item(r, 1).setFlags(self.table.item(r, 1).flags() | Qt.ItemFlag.ItemIsEditable)
206
+ self.table.item(r, 1).setTextAlignment(Qt.AlignmentFlag.AlignCenter)
207
+ self.table.scrollToBottom()
208
+ self.table.setCurrentCell(r, 0)
209
+
210
+ def _delete_selected_rows(self):
211
+ rows = sorted({i.row() for i in self.table.selectedIndexes()}, reverse=True)
212
+ for r in rows:
213
+ self.table.removeRow(r)
214
+
215
+ def save_to_settings(self):
216
+ mp = self.mapping()
217
+ blob = ";".join(f"{k}={v}" for k, v in mp.items())
218
+ self.settings.beginGroup("astrobin_exporter")
219
+ self.settings.setValue("filter_map", blob)
220
+ self.settings.endGroup()
221
+
222
+ # --- offline DB (CSV) ---
223
+ def _find_offline_csv(self) -> Optional[str]:
224
+ # 0) explicit override from parent
225
+ if self._offline_csv_default and os.path.isfile(self._offline_csv_default):
226
+ return self._offline_csv_default
227
+
228
+ # 1) user-specified in settings
229
+ self.settings.beginGroup("astrobin_exporter")
230
+ saved = self.settings.value("offline_filters_csv", "")
231
+ self.settings.endGroup()
232
+ if isinstance(saved, str) and saved and os.path.isfile(saved):
233
+ return saved
234
+
235
+ # 2) module default (if you kept it)
236
+ if os.path.isfile(self._offline_csv_default):
237
+ return self._offline_csv_default
238
+ return None
239
+
240
+ def _update_offline_button_text(self):
241
+ self.load_db_btn.setText(self.tr("Search offline DB…") if getattr(self, "_offline_rows", None) else self.tr("Load offline DB…"))
242
+
243
+ def _on_offline_action(self):
244
+ if getattr(self, "_offline_rows", None):
245
+ self._open_offline_search()
246
+ else:
247
+ self._browse_offline_db()
248
+ self._id_completer = self._make_id_completer()
249
+ self.table.setItemDelegateForColumn(1, _AstrobinIdDelegate(self.table, completer=self._id_completer))
250
+ self._update_offline_button_text()
251
+
252
+ def _browse_offline_db(self):
253
+ path, _ = QFileDialog.getOpenFileName(self, self.tr("Select AstroBin Filters CSV"), "", self.tr("CSV files (*.csv);;All files (*)"))
254
+ if not path:
255
+ return
256
+ self._load_offline_db(path)
257
+ self._id_completer = self._make_id_completer()
258
+ self.table.setItemDelegateForColumn(1, _AstrobinIdDelegate(self.table, completer=self._id_completer))
259
+
260
+ def _load_offline_db(self, csv_path: Optional[str] = None) -> List[Dict]:
261
+ if not csv_path:
262
+ csv_path = self._find_offline_csv()
263
+ self._offline_rows = []
264
+ if not csv_path:
265
+ return self._offline_rows
266
+
267
+ try:
268
+ with open(csv_path, newline="", encoding="utf-8") as f:
269
+ rdr = csv.DictReader(f)
270
+ for row in rdr:
271
+ fid = (row.get("id") or "").strip()
272
+ if not fid.isdigit():
273
+ continue
274
+ self._offline_rows.append({
275
+ "id": fid,
276
+ "brand": (row.get("brand") or "").strip(),
277
+ "name": (row.get("name") or "").strip()
278
+ })
279
+ self.settings.beginGroup("astrobin_exporter")
280
+ self.settings.setValue("offline_filters_csv", csv_path)
281
+ self.settings.endGroup()
282
+ except Exception as e:
283
+ print(f"[WARN] Failed to load offline CSV: {e}")
284
+ return self._offline_rows
285
+
286
+ def _make_id_completer(self) -> Optional[QCompleter]:
287
+ rows = getattr(self, "_offline_rows", None) or []
288
+ if not rows:
289
+ return None
290
+ model = QStandardItemModel()
291
+ for r in rows:
292
+ fid = r["id"]; brand = r.get("brand") or ""; name = r.get("name") or ""
293
+ disp = f"{fid} — {brand} — {name}".strip(" —")
294
+ it = QStandardItem(disp)
295
+ it.setData(fid, Qt.ItemDataRole.UserRole)
296
+ model.appendRow(it)
297
+ comp = _IdOnlyCompleter(model, self)
298
+ comp.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
299
+ comp.setFilterMode(Qt.MatchFlag.MatchContains)
300
+ comp.setCompletionRole(Qt.ItemDataRole.DisplayRole)
301
+ return comp
302
+
303
+ def _open_offline_search(self):
304
+ if not getattr(self, "_offline_rows", None):
305
+ QMessageBox.information(self, self.tr("No DB"), self.tr("Offline filters database not loaded yet."))
306
+ return
307
+
308
+ dlg = QDialog(self); dlg.setWindowTitle(self.tr("Search AstroBin Filters (offline)"))
309
+ v = QVBoxLayout(dlg)
310
+ q = QLineEdit(dlg); q.setPlaceholderText(self.tr("Search ID, brand, or name…"))
311
+ v.addWidget(q)
312
+
313
+ tbl = QTableWidget(dlg); tbl.setColumnCount(3)
314
+ tbl.setHorizontalHeaderLabels([self.tr("ID"), self.tr("Brand"), self.tr("Name")])
315
+ hdr = tbl.horizontalHeader()
316
+ hdr.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
317
+ hdr.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
318
+ hdr.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
319
+ tbl.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
320
+ tbl.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
321
+ v.addWidget(tbl, 1)
322
+
323
+ rows = sorted(self._offline_rows, key=lambda r: (r.get("brand","").lower(), r.get("name","").lower()))
324
+ tbl.setRowCount(len(rows))
325
+ for r, data in enumerate(rows):
326
+ for c, key in enumerate(("id","brand","name")):
327
+ it = QTableWidgetItem(data.get(key,""))
328
+ if c == 0: it.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
329
+ tbl.setItem(r, c, it)
330
+
331
+ dlg.resize(520, 360)
332
+ tbl.resizeColumnsToContents()
333
+ hdr.resizeSection(0, 90)
334
+ hdr.resizeSection(1, 120)
335
+
336
+ def apply_filter(text: str):
337
+ t = (text or "").lower()
338
+ for r in range(tbl.rowCount()):
339
+ row_txt = " ".join((tbl.item(r, c).text() if tbl.item(r, c) else "") for c in range(3)).lower()
340
+ tbl.setRowHidden(r, t not in row_txt)
341
+
342
+ q.textChanged.connect(apply_filter)
343
+
344
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=dlg)
345
+ v.addWidget(btns)
346
+ btns.accepted.connect(dlg.accept)
347
+ btns.rejected.connect(dlg.reject)
348
+ tbl.doubleClicked.connect(lambda *_: dlg.accept())
349
+
350
+ if dlg.exec() == QDialog.DialogCode.Accepted:
351
+ r = tbl.currentRow()
352
+ if r >= 0:
353
+ fid = tbl.item(r, 0).text()
354
+ cur = self.table.currentRow()
355
+ if cur < 0: cur = 0
356
+ item = QTableWidgetItem(fid)
357
+ item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
358
+ self.table.setItem(cur, 1, item)
359
+ self.table.setCurrentCell(cur, 1)
360
+ self.table.editItem(item)
361
+
362
+ # ---- Main Exporter ---------------------------------------------------------
363
+
364
+ class AstrobinExportTab(QWidget):
365
+ """
366
+ Left: file picker + tree (Object→Filter→Exposure)
367
+ Right: global inputs + aggregated table + CSV preview + copy CSV.
368
+ """
369
+ def __init__(self, parent=None, offline_filters_csv: Optional[str] = None):
370
+ super().__init__(parent)
371
+ self.settings = QSettings()
372
+ self.file_paths: List[str] = []
373
+ self.records: List[dict] = []
374
+ self.rows: List[dict] = []
375
+ self._filter_map: Dict[str, str] = self._load_filter_map()
376
+ self._offline_csv_default = offline_filters_csv
377
+
378
+ self._build_ui()
379
+ self._load_defaults()
380
+
381
+ # ---------- UI ----------
382
+ def _build_ui(self):
383
+ root = QHBoxLayout(self)
384
+ splitter = QSplitter(Qt.Orientation.Horizontal, self)
385
+ root.addWidget(splitter)
386
+
387
+ # LEFT
388
+ left = QWidget(self); lyt = QVBoxLayout(left)
389
+ self.info_lbl = QLabel(self.tr("Load FITS via 'Select Folder…' or 'Add Files…' to begin."))
390
+ lyt.addWidget(self.info_lbl)
391
+
392
+ btn_row = QHBoxLayout()
393
+ self.btn_open = QPushButton(self.tr("Select Folder…")); self.btn_open.clicked.connect(self.open_directory)
394
+ self.btn_add_files = QPushButton(self.tr("Add Files…")); self.btn_add_files.clicked.connect(self.open_files)
395
+ self.btn_clear = QPushButton(self.tr("Clear")); self.btn_clear.clicked.connect(self.clear_images)
396
+ btn_row.addWidget(self.btn_open); btn_row.addWidget(self.btn_add_files); btn_row.addWidget(self.btn_clear)
397
+ btn_row.addStretch(1)
398
+ lyt.addLayout(btn_row)
399
+
400
+ self.tree = QTreeWidget(self)
401
+ self.tree.setColumnCount(1)
402
+ self.tree.setHeaderLabels([self.tr("Files (Object → Filter → Exposure)")])
403
+ hdr = self.tree.header()
404
+ hdr.setSectionResizeMode(0, QHeaderView.ResizeMode.Interactive)
405
+ hdr.setStretchLastSection(False)
406
+ self.tree.setTextElideMode(Qt.TextElideMode.ElideNone)
407
+ self.tree.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
408
+ self.tree.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
409
+ self.tree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
410
+ self.tree.itemChanged.connect(self._on_tree_item_changed)
411
+ lyt.addWidget(self.tree)
412
+
413
+ splitter.addWidget(left)
414
+
415
+ # RIGHT
416
+ right = QWidget(self); rlyt = QVBoxLayout(right)
417
+
418
+ form_box = QGroupBox(self.tr("Global inputs (used if FITS headers are missing/zero)"))
419
+ grid = QGridLayout(form_box)
420
+
421
+ # row 0
422
+ grid.addWidget(QLabel(self.tr("f/number")), 0, 0)
423
+ self.fnum_edit = QLineEdit(self); self.fnum_edit.setPlaceholderText(self.tr("e.g. 4.0"))
424
+ self.fnum_edit.setValidator(QDoubleValidator(0.0, 999.0, 2, self)); self.fnum_edit.textChanged.connect(self._recompute)
425
+ grid.addWidget(self.fnum_edit, 0, 1)
426
+
427
+ grid.addWidget(QLabel(self.tr("Darks (#)")), 0, 2)
428
+ self.darks_edit = QLineEdit(self); self._setup_int_line(self.darks_edit, 0, 999999)
429
+ grid.addWidget(self.darks_edit, 0, 3)
430
+
431
+ grid.addWidget(QLabel(self.tr("Flats (#)")), 0, 4)
432
+ self.flats_edit = QLineEdit(self); self._setup_int_line(self.flats_edit, 0, 999999)
433
+ grid.addWidget(self.flats_edit, 0, 5)
434
+
435
+ # row 1
436
+ grid.addWidget(QLabel(self.tr("Flat-darks (#)")), 1, 0)
437
+ self.flatdarks_edit = QLineEdit(self); self._setup_int_line(self.flatdarks_edit, 0, 999999)
438
+ grid.addWidget(self.flatdarks_edit, 1, 1)
439
+
440
+ grid.addWidget(QLabel(self.tr("Bias (#)")), 1, 2)
441
+ self.bias_edit = QLineEdit(self); self._setup_int_line(self.bias_edit, 0, 999999)
442
+ grid.addWidget(self.bias_edit, 1, 3)
443
+
444
+ grid.addWidget(QLabel(self.tr("Bortle")), 1, 4)
445
+ self.bortle_edit = QLineEdit(self); self.bortle_edit.setPlaceholderText(self.tr("0–9"))
446
+ self.bortle_edit.setValidator(QIntValidator(0, 9, self)); self.bortle_edit.textChanged.connect(self._recompute)
447
+ grid.addWidget(self.bortle_edit, 1, 5)
448
+
449
+ # row 2
450
+ grid.addWidget(QLabel(self.tr("Mean SQM")), 2, 0)
451
+ self.mean_sqm_edit = QLineEdit(self); self.mean_sqm_edit.setPlaceholderText(self.tr("e.g. 21.30"))
452
+ self.mean_sqm_edit.setValidator(QDoubleValidator(0.0, 25.0, 2, self)); self.mean_sqm_edit.textChanged.connect(self._recompute)
453
+ grid.addWidget(self.mean_sqm_edit, 2, 1)
454
+
455
+ grid.addWidget(QLabel(self.tr("Mean FWHM")), 2, 2)
456
+ self.mean_fwhm_edit = QLineEdit(self); self.mean_fwhm_edit.setPlaceholderText(self.tr("e.g. 2.10"))
457
+ self.mean_fwhm_edit.setValidator(QDoubleValidator(0.0, 50.0, 2, self)); self.mean_fwhm_edit.textChanged.connect(self._recompute)
458
+ grid.addWidget(self.mean_fwhm_edit, 2, 3)
459
+
460
+ self.noon_cb = QCheckBox(self.tr("Group nights noon → noon (local time)"))
461
+ self.noon_cb.setToolTip(self.tr("Prevents splitting a single observing night at midnight."))
462
+ self.noon_cb.setChecked(self.settings.value("astrobin_exporter/noon_to_noon", True, type=bool))
463
+ self.noon_cb.toggled.connect(self._recompute)
464
+ grid.addWidget(self.noon_cb, 2, 4, 1, 2)
465
+
466
+ grid.addItem(QSpacerItem(0, 0, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum), 2, 4, 1, 2)
467
+
468
+ # Filter mapping row
469
+ map_row = QHBoxLayout()
470
+ self.filter_summary = QLabel(self._filters_summary_text()); map_row.addWidget(self.filter_summary)
471
+ self.btn_edit_filters = QPushButton(self.tr("Manage Filter IDs…")); self.btn_edit_filters.clicked.connect(self._edit_filters)
472
+ map_row.addWidget(self.btn_edit_filters)
473
+ qmark = QToolButton(self); qmark.setText("?")
474
+ qmark.setToolTip(self.tr("Open AstroBin Equipment Explorer (Filters)"))
475
+ qmark.clicked.connect(lambda: webbrowser.open(ASTROBIN_FILTER_URL))
476
+ map_row.addWidget(qmark); map_row.addStretch(1)
477
+ map_wrap = QWidget(self); map_wrap.setLayout(map_row)
478
+ grid.addWidget(map_wrap, 3, 0, 1, 6)
479
+
480
+ rlyt.addWidget(form_box)
481
+
482
+ # Aggregated table
483
+ self.table = QTableWidget(self)
484
+ cols = [self.tr('date'), self.tr('filter'), self.tr('number'), self.tr('duration'), self.tr('gain'), self.tr('iso'), self.tr('binning'), self.tr('sensorCooling'),
485
+ self.tr('fNumber'), self.tr('darks'), self.tr('flats'), self.tr('flatDarks'), self.tr('bias'), self.tr('bortle'), self.tr('meanSqm'), self.tr('meanFwhm'), self.tr('temperature')]
486
+ self.table.setColumnCount(len(cols)); self.table.setHorizontalHeaderLabels(cols)
487
+ hdr_tbl = self.table.horizontalHeader()
488
+ hdr_tbl.setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
489
+ hdr_tbl.setStretchLastSection(False)
490
+ hdr_tbl.setMinimumSectionSize(50)
491
+ self.table.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
492
+ self.table.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
493
+ rlyt.addWidget(self.table, 1)
494
+
495
+ # CSV preview
496
+ rlyt.addWidget(QLabel("CSV Preview:"))
497
+ self.csv_view = QTextEdit(self); self.csv_view.setReadOnly(True)
498
+ rlyt.addWidget(self.csv_view, 1)
499
+
500
+ # Actions
501
+ act_row = QHBoxLayout()
502
+ self.btn_refresh = QPushButton(self.tr("Recompute")); self.btn_refresh.clicked.connect(self._recompute)
503
+ self.btn_copy_csv = QPushButton(self.tr("Copy CSV")); self.btn_copy_csv.clicked.connect(self._copy_csv_to_clipboard)
504
+ act_row.addWidget(self.btn_refresh); act_row.addWidget(self.btn_copy_csv); act_row.addStretch(1)
505
+ rlyt.addLayout(act_row)
506
+
507
+ splitter.addWidget(right)
508
+ splitter.setSizes([360, 680])
509
+ self.setLayout(root)
510
+
511
+ self.info_lbl.setText(self.tr("Load FITS/XISF via 'Select Folder…' or 'Add Files…' to begin."))
512
+
513
+ def _setup_int_line(self, line: QLineEdit, lo: int, hi: int):
514
+ line.setValidator(QIntValidator(lo, hi, self))
515
+ line.setPlaceholderText("0")
516
+ line.textChanged.connect(self._recompute)
517
+
518
+ # ---------- settings ----------
519
+ def _load_defaults(self):
520
+ self.settings.beginGroup("astrobin_exporter")
521
+ self.fnum_edit.setText(str(self.settings.value("fnumber", "")))
522
+ self.darks_edit.setText(str(self.settings.value("darks", "")))
523
+ self.flats_edit.setText(str(self.settings.value("flats", "")))
524
+ self.flatdarks_edit.setText(str(self.settings.value("flatdarks", "")))
525
+ self.bias_edit.setText(str(self.settings.value("bias", "")))
526
+ self.bortle_edit.setText(str(self.settings.value("bortle", "")))
527
+ self.mean_sqm_edit.setText(str(self.settings.value("mean_sqm", "")))
528
+ self.mean_fwhm_edit.setText(str(self.settings.value("mean_fwhm", "")))
529
+ self.noon_cb.setChecked(self.settings.value("noon_to_noon", True, type=bool))
530
+ self._last_dir = str(self.settings.value("last_dir", "")) or ""
531
+ self.settings.endGroup()
532
+
533
+ def _save_defaults(self):
534
+ self.settings.beginGroup("astrobin_exporter")
535
+ self.settings.setValue("fnumber", self.fnum_edit.text().strip())
536
+ self.settings.setValue("darks", self.darks_edit.text().strip())
537
+ self.settings.setValue("flats", self.flats_edit.text().strip())
538
+ self.settings.setValue("flatdarks", self.flatdarks_edit.text().strip())
539
+ self.settings.setValue("bias", self.bias_edit.text().strip())
540
+ self.settings.setValue("bortle", self.bortle_edit.text().strip())
541
+ self.settings.setValue("mean_sqm", self.mean_sqm_edit.text().strip())
542
+ self.settings.setValue("mean_fwhm", self.mean_fwhm_edit.text().strip())
543
+ self.settings.setValue("noon_to_noon", self.noon_cb.isChecked())
544
+ self.settings.endGroup()
545
+
546
+ def _get_last_dir(self) -> str:
547
+ return getattr(self, "_last_dir", "") or ""
548
+
549
+ def _save_last_dir(self, path: str):
550
+ if not path: return
551
+ self._last_dir = path
552
+ self.settings.beginGroup("astrobin_exporter")
553
+ self.settings.setValue("last_dir", path)
554
+ self.settings.endGroup()
555
+
556
+ # ---------- file I/O ----------
557
+ def clear_images(self):
558
+ self.file_paths.clear(); self.records.clear(); self.rows.clear()
559
+ self.tree.blockSignals(True); self.tree.clear(); self.tree.blockSignals(False)
560
+ self.table.setRowCount(0); self.csv_view.clear()
561
+ self.info_lbl.setText(self.tr("Cleared. Load FITS via 'Select Folder…' or 'Add Files…' to begin."))
562
+
563
+ def open_directory(self):
564
+ start = self._get_last_dir() or ""
565
+ directory = QFileDialog.getExistingDirectory(self, self.tr("Select Folder Containing FITS/XISF Files"), start)
566
+ if not directory: return
567
+ self._save_last_dir(directory)
568
+
569
+ paths = []
570
+ for root, _, files in os.walk(directory):
571
+ for fn in files:
572
+ if fn.lower().endswith((".fit", ".fits", ".xisf")):
573
+ paths.append(os.path.join(root, fn))
574
+ paths.sort(key=self._natural_key)
575
+ if not paths:
576
+ QMessageBox.information(self, self.tr("No Images"), self.tr("No .fit/.fits/.xisf files found."))
577
+ return
578
+ self.file_paths = paths
579
+ self._read_headers(); self._build_tree(); self._recompute()
580
+
581
+ def open_files(self):
582
+ start = self._get_last_dir() or ""
583
+ paths, _ = QFileDialog.getOpenFileNames(
584
+ self, self.tr("Select FITS/XISF Files"), start, self.tr("FITS/XISF (*.fit *.fits *.xisf);;All Files (*)")
585
+ )
586
+ if not paths: return
587
+ self._save_last_dir(os.path.dirname(paths[0]))
588
+
589
+ new_paths = [p for p in paths if p not in self.file_paths]
590
+ if not new_paths:
591
+ QMessageBox.information(self, self.tr("No New Files"), self.tr("All selected files are already in the list."))
592
+ return
593
+
594
+ self.file_paths = sorted(set(self.file_paths + new_paths), key=self._natural_key)
595
+ self._read_headers(); self._build_tree(); self._recompute()
596
+
597
+ # ---------- header reading ----------
598
+ def _xisf_first_kw(self, image_meta: dict, key: str, default=None):
599
+ try:
600
+ vals = (image_meta.get("FITSKeywords") or {}).get(key, [])
601
+ if vals: return vals[0].get("value", default)
602
+ except Exception:
603
+ pass
604
+ return default
605
+
606
+ def _xisf_search_props(self, image_meta: dict, substrings, default=None):
607
+ props = image_meta.get("XISFProperties") or {}
608
+ if isinstance(substrings, str):
609
+ substrings = [substrings]
610
+ for k, v in props.items():
611
+ lk = k.lower()
612
+ if any(s in lk for s in substrings):
613
+ return v.get("value", default)
614
+ return default
615
+
616
+ def _xisf_flatten_fits_keywords(self, image_meta: dict) -> dict:
617
+ out = {}
618
+ try:
619
+ kmap = image_meta.get("FITSKeywords") or {}
620
+ for k, arr in kmap.items():
621
+ if arr:
622
+ out[k] = arr[0].get("value", None)
623
+ except Exception:
624
+ pass
625
+ return out
626
+
627
+ def _read_headers(self):
628
+ from pathlib import Path
629
+ self.records.clear()
630
+ ok, bad, skipped_xisf = 0, 0, 0
631
+
632
+ for fp in self.file_paths:
633
+ try:
634
+ ext = Path(fp).suffix.lower()
635
+ if ext in (".fit", ".fits"):
636
+ with fits.open(fp, memmap=False) as hdul:
637
+ h = hdul[0].header
638
+
639
+ exposure = h.get("EXPOSURE", h.get("EXPTIME", 0.0))
640
+ binning = self._derive_binning(h)
641
+ rec = {
642
+ "PATH": fp, "NAME": os.path.basename(fp),
643
+ "OBJECT": str(h.get("OBJECT", "Unknown")),
644
+ "FILTER": str(h.get("FILTER", "Unknown")),
645
+ "EXPOSURE": self._safe_float(exposure),
646
+ "GAIN": str(h.get("GAIN", "0")),
647
+ "ISO": str(h.get("ISO", "0")),
648
+ "BINNING": binning,
649
+ "CCD_TEMP": self._safe_float(h.get("CCD-TEMP", 0.0)),
650
+ "FOCTEMP": self._safe_float(h.get("FOCTEMP", 0.0)),
651
+ "DARK": str(h.get("DARK", "0")),
652
+ "FLAT": str(h.get("FLAT", "0")),
653
+ "FLATDARK": str(h.get("FLATDARK", "0")),
654
+ "BIAS": str(h.get("BIAS", "0")),
655
+ "BORTLE": str(h.get("BORTLE", "0")),
656
+ "MEAN_SQM": str(h.get("MEAN_SQM", "0")),
657
+ "MEAN_FWHM": str(h.get("MEAN_FWHM", "0")),
658
+ "DATE": self._to_date_only(str(h.get("DATE-OBS", "0"))),
659
+ "DATEOBS": str(h.get("DATE-OBS", "")),
660
+ }
661
+ self.records.append(rec); ok += 1
662
+
663
+ elif ext == ".xisf":
664
+ if XISF is None:
665
+ skipped_xisf += 1
666
+ continue
667
+ xisf = XISF(fp)
668
+ image_meta = xisf.get_images_metadata()[0]
669
+ flat = self._xisf_flatten_fits_keywords(image_meta)
670
+ exposure = flat.get("EXPOSURE", flat.get("EXPTIME", 0.0))
671
+ filt_name = str(flat.get("FILTER", "")) or \
672
+ str(self._xisf_search_props(image_meta, ["filter", "channel", "band"], default="Unknown"))
673
+ gain_val = flat.get("GAIN", None)
674
+ if gain_val is None:
675
+ gain_val = self._xisf_search_props(image_meta, "gain", default="0")
676
+ iso_val = flat.get("ISO", None)
677
+ if iso_val is None:
678
+ iso_val = self._xisf_search_props(image_meta, "iso", default="0")
679
+ binning = self._derive_binning(flat)
680
+ ccd_temp = self._safe_float(
681
+ flat.get("CCD-TEMP", self._xisf_search_props(image_meta, ["ccd-temp","sensor","temperature"], default=0.0))
682
+ )
683
+ foc_temp = self._safe_float(flat.get("FOCTEMP", 0.0))
684
+ dateobs = str(flat.get("DATE-OBS", "")) or ""
685
+
686
+ rec = {
687
+ "PATH": fp, "NAME": os.path.basename(fp),
688
+ "OBJECT": str(flat.get("OBJECT", self._xisf_search_props(image_meta, "object", default="Unknown"))),
689
+ "FILTER": filt_name or "Unknown",
690
+ "EXPOSURE": self._safe_float(exposure),
691
+ "GAIN": str(gain_val if gain_val is not None else "0"),
692
+ "ISO": str(iso_val if iso_val is not None else "0"),
693
+ "BINNING": binning,
694
+ "CCD_TEMP": ccd_temp,
695
+ "FOCTEMP": foc_temp,
696
+ "DARK": str(flat.get("DARK", "0")),
697
+ "FLAT": str(flat.get("FLAT", "0")),
698
+ "FLATDARK": str(flat.get("FLATDARK", "0")),
699
+ "BIAS": str(flat.get("BIAS", "0")),
700
+ "BORTLE": str(flat.get("BORTLE", "0")),
701
+ "MEAN_SQM": str(flat.get("MEAN_SQM", "0")),
702
+ "MEAN_FWHM": str(flat.get("MEAN_FWHM", "0")),
703
+ "DATE": self._to_date_only(dateobs) if dateobs else "0",
704
+ "DATEOBS": dateobs,
705
+ }
706
+ self.records.append(rec); ok += 1
707
+
708
+ except Exception as e:
709
+ print(f"[WARN] Failed to read {fp}: {e}")
710
+ bad += 1
711
+
712
+ msg = self.tr("Loaded {0} file(s)").format(ok)
713
+ if bad: msg += self.tr(" ({0} failed)").format(bad)
714
+ if skipped_xisf: msg += self.tr(" — skipped {0} XISF (reader unavailable)").format(skipped_xisf)
715
+ self.info_lbl.setText(msg + ".")
716
+
717
+ # ---------- helpers ----------
718
+ @staticmethod
719
+ def _natural_key(path: str):
720
+ name = os.path.basename(path)
721
+ return [int(tok) if tok.isdigit() else tok.lower() for tok in re.split(r'(\d+)', name)]
722
+
723
+ @staticmethod
724
+ def _safe_float(x, default=0.0) -> float:
725
+ try: return float(x)
726
+ except Exception: return float(default)
727
+
728
+ @staticmethod
729
+ def _derive_binning(h) -> str:
730
+ for k in ("XBINNING", "XBIN", "CCDXBIN"):
731
+ if k in h:
732
+ try: return str(int(float(h[k])))
733
+ except Exception: return str(h[k])
734
+ return "0"
735
+
736
+ @staticmethod
737
+ def _to_date_only(date_obs: str) -> str:
738
+ if not date_obs or date_obs == "0":
739
+ return "0"
740
+ return date_obs.split("T")[0].strip()
741
+
742
+ def _parse_date_obs(self, s: str) -> Optional[datetime]:
743
+ s = (s or "").strip()
744
+ if not s or s == "0": return None
745
+ s = s.replace("Z", "+00:00")
746
+ try:
747
+ dt = datetime.fromisoformat(s)
748
+ except Exception:
749
+ return None
750
+ if dt.tzinfo is None:
751
+ dt = dt.replace(tzinfo=timezone.utc)
752
+ return dt
753
+
754
+ def _night_date_str(self, date_obs: str, noon_to_noon: bool) -> str:
755
+ dt = self._parse_date_obs(date_obs)
756
+ if not dt:
757
+ return (date_obs.split("T")[0] if date_obs else "0")
758
+ local_tz = datetime.now().astimezone().tzinfo
759
+ ldt = dt.astimezone(local_tz)
760
+ if noon_to_noon:
761
+ ldt = ldt - timedelta(hours=12)
762
+ return ldt.date().isoformat()
763
+
764
+ def _build_tree(self):
765
+ self.tree.blockSignals(True); self.tree.clear()
766
+
767
+ grouped: Dict[Tuple[str, str, float], List[dict]] = defaultdict(list)
768
+ for rec in self.records:
769
+ key = (rec["OBJECT"], rec["FILTER"], rec["EXPOSURE"])
770
+ grouped[key].append(rec)
771
+
772
+ by_obj: Dict[str, Dict[str, Dict[float, List[dict]]]] = defaultdict(lambda: defaultdict(dict))
773
+ for (obj, filt, exp), lst in grouped.items():
774
+ by_obj[obj].setdefault(filt, {})
775
+ by_obj[obj][filt][exp] = lst
776
+
777
+ for obj in sorted(by_obj, key=str.lower):
778
+ obj_item = QTreeWidgetItem([self.tr("Object: {0}").format(obj)])
779
+ obj_item.setFlags(obj_item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
780
+ obj_item.setCheckState(0, Qt.CheckState.Checked)
781
+ self.tree.addTopLevelItem(obj_item); obj_item.setExpanded(True)
782
+
783
+ for filt in sorted(by_obj[obj], key=str.lower):
784
+ filt_item = QTreeWidgetItem([self.tr("Filter: {0}").format(filt)])
785
+ filt_item.setFlags(filt_item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
786
+ filt_item.setCheckState(0, Qt.CheckState.Checked)
787
+ obj_item.addChild(filt_item); filt_item.setExpanded(True)
788
+
789
+ for exp in sorted(by_obj[obj][filt].keys(), key=lambda e: str(e)):
790
+ exp_item = QTreeWidgetItem([self.tr("Exposure: {0}").format(exp)])
791
+ exp_item.setData(0, Qt.ItemDataRole.UserRole, float(exp))
792
+ exp_item.setFlags(exp_item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
793
+ exp_item.setCheckState(0, Qt.CheckState.Checked)
794
+ filt_item.addChild(exp_item)
795
+ for rec in by_obj[obj][filt][exp]:
796
+ leaf = QTreeWidgetItem([rec["NAME"]])
797
+ leaf.setData(0, Qt.ItemDataRole.UserRole, rec["PATH"])
798
+ leaf.setFlags(leaf.flags() | Qt.ItemFlag.ItemIsUserCheckable)
799
+ leaf.setCheckState(0, Qt.CheckState.Checked)
800
+ exp_item.addChild(leaf)
801
+
802
+ self.tree.blockSignals(False)
803
+ self.tree.header().setStretchLastSection(False)
804
+ self.tree.resizeColumnToContents(0)
805
+ self.tree.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
806
+ self.tree.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
807
+ self.tree.setTextElideMode(Qt.TextElideMode.ElideNone)
808
+
809
+ def _has_gain(self, v) -> bool:
810
+ s = str(v).strip()
811
+ if not s: return False
812
+ try: return float(s) > 0.0
813
+ except Exception: return False
814
+
815
+ def _on_tree_item_changed(self, item: QTreeWidgetItem, _col: int):
816
+ state = item.checkState(0)
817
+ for i in range(item.childCount()):
818
+ ch = item.child(i)
819
+ ch.setCheckState(0, state)
820
+ self._recompute()
821
+
822
+ # ---------- aggregation & CSV ----------
823
+ def _included_paths(self) -> set:
824
+ paths = set()
825
+ def recurse(node: QTreeWidgetItem):
826
+ if node.childCount() == 0:
827
+ if node.checkState(0) == Qt.CheckState.Checked:
828
+ p = node.data(0, Qt.ItemDataRole.UserRole)
829
+ if isinstance(p, str): paths.add(p)
830
+ return
831
+ for i in range(node.childCount()):
832
+ recurse(node.child(i))
833
+ root = self.tree.invisibleRootItem()
834
+ for i in range(root.childCount()):
835
+ recurse(root.child(i))
836
+ return paths
837
+
838
+ def _fallback(self, header_val: str, global_val: str) -> str:
839
+ hv = (header_val or "").strip(); gv = (global_val or "").strip()
840
+ if hv in ("", "0", "0.0") and gv != "": return gv
841
+ return hv or "0"
842
+
843
+ def _recompute(self):
844
+ self._save_defaults()
845
+ noon_to_noon = self.noon_cb.isChecked()
846
+
847
+ selected = self._included_paths()
848
+ if not selected:
849
+ self.rows = []; self._refresh_table(); self._refresh_csv_text(); return
850
+
851
+ agg = defaultdict(lambda: {
852
+ 'date': '0', 'filter': '0', 'number': 0, 'duration': 0, 'gain': '0', 'iso': '0',
853
+ 'binning': '0', 'sensorCooling': 0, 'fNumber': '0', 'darks': '0', 'flats': '0',
854
+ 'flatDarks': '0', 'bias': '0', 'bortle': '0', 'meanSqm': '0', 'meanFwhm': '0',
855
+ 'temperature_sum': 0.0, 'temp_count': 0
856
+ })
857
+
858
+ fnum = self.fnum_edit.text().strip() or "0"
859
+ g_darks = self.darks_edit.text().strip()
860
+ g_flats = self.flats_edit.text().strip()
861
+ g_flatdarks = self.flatdarks_edit.text().strip()
862
+ g_bias = self.bias_edit.text().strip()
863
+ g_bortle = self.bortle_edit.text().strip()
864
+ g_sqm = self.mean_sqm_edit.text().strip()
865
+ g_fwhm = self.mean_fwhm_edit.text().strip()
866
+
867
+ for rec in self.records:
868
+ if rec["PATH"] not in selected: continue
869
+ date = self._night_date_str(rec.get("DATEOBS",""), noon_to_noon)
870
+ filt_name = rec["FILTER"] or "0"
871
+ filt_id = self._filter_map.get(filt_name, filt_name)
872
+ exposure = rec["EXPOSURE"] or 0.0
873
+ key = (date, str(filt_id), float(exposure))
874
+
875
+ item = agg[key]
876
+ item['date'] = date
877
+ item['filter'] = str(filt_id)
878
+ item['duration'] = exposure
879
+ item['gain'] = rec["GAIN"]
880
+ item['iso'] = rec["ISO"]
881
+ item['binning'] = rec["BINNING"]
882
+ item['sensorCooling'] = int(round(rec["CCD_TEMP"])) if rec["CCD_TEMP"] else 0
883
+ item['fNumber'] = fnum
884
+
885
+ item['darks'] = self._fallback(rec["DARK"], g_darks)
886
+ item['flats'] = self._fallback(rec["FLAT"], g_flats)
887
+ item['flatDarks'] = self._fallback(rec["FLATDARK"], g_flatdarks)
888
+ item['bias'] = self._fallback(rec["BIAS"], g_bias)
889
+ item['bortle'] = self._fallback(rec["BORTLE"], g_bortle)
890
+ item['meanSqm'] = self._fallback(rec["MEAN_SQM"], g_sqm)
891
+ item['meanFwhm'] = self._fallback(rec["MEAN_FWHM"], g_fwhm)
892
+
893
+ if rec["FOCTEMP"]:
894
+ item['temperature_sum'] += float(rec["FOCTEMP"])
895
+ item['temp_count'] += 1
896
+ item['number'] += 1
897
+
898
+ out = []
899
+ for (_date, _fid, _exp), v in agg.items():
900
+ temp = int(round(v['temperature_sum'] / v['temp_count'])) if v['temp_count'] > 0 else 0
901
+ row = {
902
+ 'date': v['date'], 'filter': v['filter'], 'number': v['number'],
903
+ 'duration': v['duration'], 'gain': v['gain'], 'iso': v['iso'],
904
+ 'binning': v['binning'], 'sensorCooling': v['sensorCooling'], 'fNumber': v['fNumber'],
905
+ 'darks': v['darks'], 'flats': v['flats'], 'flatDarks': v['flatDarks'],
906
+ 'bias': v['bias'], 'bortle': v['bortle'], 'meanSqm': v['meanSqm'],
907
+ 'meanFwhm': v['meanFwhm'], 'temperature': temp
908
+ }
909
+ if self._has_gain(row['gain']):
910
+ row['iso'] = "" # if gain is present, blank ISO
911
+ out.append(row)
912
+
913
+ out.sort(key=lambda r: (r['date'], r['filter'], float(r['duration'])))
914
+ self.rows = out
915
+ self._refresh_table(); self._refresh_csv_text()
916
+
917
+ def _refresh_table(self):
918
+ cols = [self.tr('date'), self.tr('filter'), self.tr('number'), self.tr('duration'), self.tr('gain'), self.tr('iso'), self.tr('binning'), self.tr('sensorCooling'),
919
+ self.tr('fNumber'), self.tr('darks'), self.tr('flats'), self.tr('flatDarks'), self.tr('bias'), self.tr('bortle'), self.tr('meanSqm'), self.tr('meanFwhm'), self.tr('temperature')]
920
+ self.table.setRowCount(len(self.rows))
921
+ self.table.setColumnCount(len(cols))
922
+ self.table.setHorizontalHeaderLabels(cols)
923
+ for r, row in enumerate(self.rows):
924
+ for c, key in enumerate(cols):
925
+ item = QTableWidgetItem(str(row.get(key, "")))
926
+ if key == "filter" and not str(row.get(key, "")).isdigit():
927
+ item.setForeground(Qt.GlobalColor.red)
928
+ self.table.setItem(r, c, item)
929
+ self.table.resizeColumnsToContents()
930
+
931
+ def _rows_to_csv_str(self) -> str:
932
+ base_fields = ['date','filter','number','duration','gain','iso','binning',
933
+ 'sensorCooling','fNumber','darks','flats','flatDarks','bias',
934
+ 'bortle','meanSqm','meanFwhm','temperature']
935
+ drop_iso = any(self._has_gain(r.get('gain', '')) for r in (self.rows or []))
936
+ fieldnames = [f for f in base_fields if f != 'iso'] if drop_iso else base_fields
937
+ buf = io.StringIO()
938
+ writer = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction='ignore')
939
+ writer.writeheader(); writer.writerows(self.rows or [])
940
+ return buf.getvalue()
941
+
942
+ def _refresh_csv_text(self):
943
+ self.csv_view.setPlainText(self._rows_to_csv_str())
944
+
945
+ def _copy_csv_to_clipboard(self):
946
+ txt = self._rows_to_csv_str()
947
+ if not txt.strip():
948
+ QMessageBox.information(self, self.tr("Nothing to copy"), self.tr("There is no CSV content yet."))
949
+ return
950
+ QGuiApplication.clipboard().setText(txt)
951
+ QMessageBox.information(self, self.tr("Copied"), self.tr("CSV copied to clipboard."))
952
+
953
+ # ---------- filter map ----------
954
+ def _load_filter_map(self) -> Dict[str, str]:
955
+ defaults = {"Ha":"4408", "OIII":"4413", "SII":"4418", "L":"4450", "R":"4455", "G":"4445", "B":"4440"}
956
+ self.settings.beginGroup("astrobin_exporter")
957
+ raw = self.settings.value("filter_map", "")
958
+ self.settings.endGroup()
959
+ if not raw:
960
+ blob = ";".join(f"{k}={v}" for k, v in defaults.items())
961
+ self.settings.beginGroup("astrobin_exporter")
962
+ self.settings.setValue("filter_map", blob)
963
+ self.settings.endGroup()
964
+ return defaults.copy()
965
+ mapping: Dict[str, str] = {}
966
+ for chunk in str(raw).split(";"):
967
+ if "=" in chunk:
968
+ k, v = chunk.split("=", 1)
969
+ k, v = k.strip(), v.strip()
970
+ if k and v.isdigit():
971
+ mapping[k] = v
972
+ return mapping
973
+
974
+ def _filters_summary_text(self) -> str:
975
+ if not self._filter_map: return self.tr("No mappings set")
976
+ pairs = sorted(self._filter_map.items(), key=lambda kv: kv[0].lower())
977
+ return ", ".join(f"{k}→{v}" for k, v in pairs)
978
+
979
+ def _edit_filters(self):
980
+ names_in_data = sorted({rec.get("FILTER","Unknown") for rec in self.records if rec.get("FILTER")}, key=str.lower)
981
+ dlg = FilterIdDialog(
982
+ self,
983
+ names_in_data,
984
+ self.settings,
985
+ current_map=self._filter_map,
986
+ offline_csv_default=self._offline_csv_default # <— pass override
987
+ )
988
+ if dlg.exec() == QDialog.DialogCode.Accepted:
989
+ dlg.save_to_settings()
990
+ self._filter_map = self._load_filter_map()
991
+ self.filter_summary.setText(self._filters_summary_text())
992
+ self._recompute()
993
+
994
+ # ---- wrapper dialog ---------------------------------------------------------
995
+
996
+ class AstrobinExporterDialog(QDialog):
997
+ def __init__(self, parent=None, offline_filters_csv: Optional[str] = None):
998
+ super().__init__(parent)
999
+ self.setWindowTitle(self.tr("AstroBin Exporter"))
1000
+ self.resize(980, 640)
1001
+ v = QVBoxLayout(self)
1002
+ self.tab = AstrobinExportTab(self, offline_filters_csv=offline_filters_csv)
1003
+ v.addWidget(self.tab, 1)
1004
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close, parent=self)
1005
+ btns.rejected.connect(self.reject)
1006
+ btns.accepted.connect(self.accept)
1007
+ v.addWidget(btns)