setiastrosuitepro 1.6.0__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 (174) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/saspro/__init__.py +20 -0
  3. setiastro/saspro/__main__.py +784 -0
  4. setiastro/saspro/_generated/__init__.py +7 -0
  5. setiastro/saspro/_generated/build_info.py +2 -0
  6. setiastro/saspro/abe.py +1295 -0
  7. setiastro/saspro/abe_preset.py +196 -0
  8. setiastro/saspro/aberration_ai.py +694 -0
  9. setiastro/saspro/aberration_ai_preset.py +224 -0
  10. setiastro/saspro/accel_installer.py +218 -0
  11. setiastro/saspro/accel_workers.py +30 -0
  12. setiastro/saspro/add_stars.py +621 -0
  13. setiastro/saspro/astrobin_exporter.py +1007 -0
  14. setiastro/saspro/astrospike.py +153 -0
  15. setiastro/saspro/astrospike_python.py +1839 -0
  16. setiastro/saspro/autostretch.py +196 -0
  17. setiastro/saspro/backgroundneutral.py +560 -0
  18. setiastro/saspro/batch_convert.py +325 -0
  19. setiastro/saspro/batch_renamer.py +519 -0
  20. setiastro/saspro/blemish_blaster.py +488 -0
  21. setiastro/saspro/blink_comparator_pro.py +2923 -0
  22. setiastro/saspro/bundles.py +61 -0
  23. setiastro/saspro/bundles_dock.py +114 -0
  24. setiastro/saspro/cheat_sheet.py +168 -0
  25. setiastro/saspro/clahe.py +342 -0
  26. setiastro/saspro/comet_stacking.py +1377 -0
  27. setiastro/saspro/config.py +38 -0
  28. setiastro/saspro/config_bootstrap.py +40 -0
  29. setiastro/saspro/config_manager.py +316 -0
  30. setiastro/saspro/continuum_subtract.py +1617 -0
  31. setiastro/saspro/convo.py +1397 -0
  32. setiastro/saspro/convo_preset.py +414 -0
  33. setiastro/saspro/copyastro.py +187 -0
  34. setiastro/saspro/cosmicclarity.py +1564 -0
  35. setiastro/saspro/cosmicclarity_preset.py +407 -0
  36. setiastro/saspro/crop_dialog_pro.py +948 -0
  37. setiastro/saspro/crop_preset.py +189 -0
  38. setiastro/saspro/curve_editor_pro.py +2544 -0
  39. setiastro/saspro/curves_preset.py +375 -0
  40. setiastro/saspro/debayer.py +670 -0
  41. setiastro/saspro/debug_utils.py +29 -0
  42. setiastro/saspro/dnd_mime.py +35 -0
  43. setiastro/saspro/doc_manager.py +2634 -0
  44. setiastro/saspro/exoplanet_detector.py +2166 -0
  45. setiastro/saspro/file_utils.py +284 -0
  46. setiastro/saspro/fitsmodifier.py +744 -0
  47. setiastro/saspro/free_torch_memory.py +48 -0
  48. setiastro/saspro/frequency_separation.py +1343 -0
  49. setiastro/saspro/function_bundle.py +1594 -0
  50. setiastro/saspro/ghs_dialog_pro.py +660 -0
  51. setiastro/saspro/ghs_preset.py +284 -0
  52. setiastro/saspro/graxpert.py +634 -0
  53. setiastro/saspro/graxpert_preset.py +287 -0
  54. setiastro/saspro/gui/__init__.py +0 -0
  55. setiastro/saspro/gui/main_window.py +8494 -0
  56. setiastro/saspro/gui/mixins/__init__.py +33 -0
  57. setiastro/saspro/gui/mixins/dock_mixin.py +263 -0
  58. setiastro/saspro/gui/mixins/file_mixin.py +445 -0
  59. setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
  60. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  61. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  62. setiastro/saspro/gui/mixins/menu_mixin.py +361 -0
  63. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  64. setiastro/saspro/gui/mixins/toolbar_mixin.py +1324 -0
  65. setiastro/saspro/gui/mixins/update_mixin.py +309 -0
  66. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  67. setiastro/saspro/halobgon.py +462 -0
  68. setiastro/saspro/header_viewer.py +445 -0
  69. setiastro/saspro/headless_utils.py +88 -0
  70. setiastro/saspro/histogram.py +753 -0
  71. setiastro/saspro/history_explorer.py +939 -0
  72. setiastro/saspro/image_combine.py +414 -0
  73. setiastro/saspro/image_peeker_pro.py +1596 -0
  74. setiastro/saspro/imageops/__init__.py +37 -0
  75. setiastro/saspro/imageops/mdi_snap.py +292 -0
  76. setiastro/saspro/imageops/scnr.py +36 -0
  77. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  78. setiastro/saspro/imageops/stretch.py +244 -0
  79. setiastro/saspro/isophote.py +1179 -0
  80. setiastro/saspro/layers.py +208 -0
  81. setiastro/saspro/layers_dock.py +714 -0
  82. setiastro/saspro/lazy_imports.py +193 -0
  83. setiastro/saspro/legacy/__init__.py +2 -0
  84. setiastro/saspro/legacy/image_manager.py +2226 -0
  85. setiastro/saspro/legacy/numba_utils.py +3659 -0
  86. setiastro/saspro/legacy/xisf.py +1071 -0
  87. setiastro/saspro/linear_fit.py +534 -0
  88. setiastro/saspro/live_stacking.py +1830 -0
  89. setiastro/saspro/log_bus.py +5 -0
  90. setiastro/saspro/logging_config.py +460 -0
  91. setiastro/saspro/luminancerecombine.py +309 -0
  92. setiastro/saspro/main_helpers.py +201 -0
  93. setiastro/saspro/mask_creation.py +928 -0
  94. setiastro/saspro/masks_core.py +56 -0
  95. setiastro/saspro/mdi_widgets.py +353 -0
  96. setiastro/saspro/memory_utils.py +666 -0
  97. setiastro/saspro/metadata_patcher.py +75 -0
  98. setiastro/saspro/mfdeconv.py +3826 -0
  99. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  100. setiastro/saspro/mfdeconvcudnn.py +3263 -0
  101. setiastro/saspro/mfdeconvsport.py +2382 -0
  102. setiastro/saspro/minorbodycatalog.py +567 -0
  103. setiastro/saspro/morphology.py +382 -0
  104. setiastro/saspro/multiscale_decomp.py +1290 -0
  105. setiastro/saspro/nbtorgb_stars.py +531 -0
  106. setiastro/saspro/numba_utils.py +3044 -0
  107. setiastro/saspro/numba_warmup.py +141 -0
  108. setiastro/saspro/ops/__init__.py +9 -0
  109. setiastro/saspro/ops/command_help_dialog.py +623 -0
  110. setiastro/saspro/ops/command_runner.py +217 -0
  111. setiastro/saspro/ops/commands.py +1594 -0
  112. setiastro/saspro/ops/script_editor.py +1102 -0
  113. setiastro/saspro/ops/scripts.py +1413 -0
  114. setiastro/saspro/ops/settings.py +560 -0
  115. setiastro/saspro/parallel_utils.py +554 -0
  116. setiastro/saspro/pedestal.py +121 -0
  117. setiastro/saspro/perfect_palette_picker.py +1053 -0
  118. setiastro/saspro/pipeline.py +110 -0
  119. setiastro/saspro/pixelmath.py +1600 -0
  120. setiastro/saspro/plate_solver.py +2435 -0
  121. setiastro/saspro/project_io.py +797 -0
  122. setiastro/saspro/psf_utils.py +136 -0
  123. setiastro/saspro/psf_viewer.py +549 -0
  124. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  125. setiastro/saspro/remove_green.py +314 -0
  126. setiastro/saspro/remove_stars.py +1625 -0
  127. setiastro/saspro/remove_stars_preset.py +404 -0
  128. setiastro/saspro/resources.py +472 -0
  129. setiastro/saspro/rgb_combination.py +207 -0
  130. setiastro/saspro/rgb_extract.py +19 -0
  131. setiastro/saspro/rgbalign.py +723 -0
  132. setiastro/saspro/runtime_imports.py +7 -0
  133. setiastro/saspro/runtime_torch.py +754 -0
  134. setiastro/saspro/save_options.py +72 -0
  135. setiastro/saspro/selective_color.py +1552 -0
  136. setiastro/saspro/sfcc.py +1425 -0
  137. setiastro/saspro/shortcuts.py +2807 -0
  138. setiastro/saspro/signature_insert.py +1099 -0
  139. setiastro/saspro/stacking_suite.py +17712 -0
  140. setiastro/saspro/star_alignment.py +7420 -0
  141. setiastro/saspro/star_alignment_preset.py +329 -0
  142. setiastro/saspro/star_metrics.py +49 -0
  143. setiastro/saspro/star_spikes.py +681 -0
  144. setiastro/saspro/star_stretch.py +470 -0
  145. setiastro/saspro/stat_stretch.py +502 -0
  146. setiastro/saspro/status_log_dock.py +78 -0
  147. setiastro/saspro/subwindow.py +3267 -0
  148. setiastro/saspro/supernovaasteroidhunter.py +1712 -0
  149. setiastro/saspro/swap_manager.py +99 -0
  150. setiastro/saspro/torch_backend.py +89 -0
  151. setiastro/saspro/torch_rejection.py +434 -0
  152. setiastro/saspro/view_bundle.py +1555 -0
  153. setiastro/saspro/wavescale_hdr.py +624 -0
  154. setiastro/saspro/wavescale_hdr_preset.py +100 -0
  155. setiastro/saspro/wavescalede.py +657 -0
  156. setiastro/saspro/wavescalede_preset.py +228 -0
  157. setiastro/saspro/wcs_update.py +374 -0
  158. setiastro/saspro/whitebalance.py +456 -0
  159. setiastro/saspro/widgets/__init__.py +48 -0
  160. setiastro/saspro/widgets/common_utilities.py +305 -0
  161. setiastro/saspro/widgets/graphics_views.py +122 -0
  162. setiastro/saspro/widgets/image_utils.py +518 -0
  163. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  164. setiastro/saspro/widgets/spinboxes.py +275 -0
  165. setiastro/saspro/widgets/themed_buttons.py +13 -0
  166. setiastro/saspro/widgets/wavelet_utils.py +299 -0
  167. setiastro/saspro/window_shelf.py +185 -0
  168. setiastro/saspro/xisf.py +1123 -0
  169. setiastrosuitepro-1.6.0.dist-info/METADATA +266 -0
  170. setiastrosuitepro-1.6.0.dist-info/RECORD +174 -0
  171. setiastrosuitepro-1.6.0.dist-info/WHEEL +4 -0
  172. setiastrosuitepro-1.6.0.dist-info/entry_points.txt +6 -0
  173. setiastrosuitepro-1.6.0.dist-info/licenses/LICENSE +674 -0
  174. setiastrosuitepro-1.6.0.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,519 @@
1
+ # pro/batch_renamer.py
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import re
6
+ import shutil
7
+ from typing import List
8
+ from collections import defaultdict
9
+ from datetime import datetime, timezone
10
+
11
+ from astropy.io import fits
12
+
13
+ from PyQt6.QtCore import Qt, QTimer
14
+ from PyQt6.QtGui import QFontMetrics
15
+ from PyQt6.QtWidgets import (
16
+ QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QGroupBox,
17
+ QSplitter, QWidget, QListWidget, QListWidgetItem,
18
+ QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView,
19
+ QDialogButtonBox, QComboBox, QSpinBox, QCheckBox, QFileDialog, QListWidget, QMessageBox
20
+ )
21
+ from PyQt6.QtCore import QSettings
22
+
23
+
24
+ class BatchRenamerDialog(QDialog):
25
+ r"""
26
+ Batch rename files using a template like:
27
+ LIGHT_{FILTER}_{EXPOSURE:.0f}s_{DATE-OBS:%Y%m%d}_{#03}.{ext}
28
+
29
+ Supports:
30
+ - Any FITS keyword in braces: {FILTER}, {EXPOSURE}, {OBJECT}, …
31
+ - Optional format spec: {EXPOSURE:.1f}, {DATE-OBS:%Y%m%d}
32
+ - Counter: {#} or {#03} (zero-padded width)
33
+ - Extension placeholder: {ext} (original extension, no dot)
34
+ - Filters with pipes, e.g. {OBJECT|re:(\w+)|upper}
35
+ """
36
+ def __init__(self, parent=None):
37
+ super().__init__(parent)
38
+ self.setWindowTitle("Batch Rename from FITS")
39
+ self.settings = QSettings()
40
+ self.files: list[str] = []
41
+ self.headers: dict[str, fits.Header] = {}
42
+ self.union_keys: list[str] = []
43
+
44
+ # PyQt6-safe window flags
45
+ self.setWindowFlag(Qt.WindowType.WindowSystemMenuHint, True)
46
+ self.setWindowFlag(Qt.WindowType.WindowTitleHint, True)
47
+ self.setWindowFlag(Qt.WindowType.WindowMinMaxButtonsHint, True)
48
+ self.setWindowFlag(Qt.WindowType.WindowContextHelpButtonHint, False)
49
+ self.setSizeGripEnabled(True)
50
+
51
+ self._build_ui()
52
+ self._load_settings()
53
+
54
+ # ---------- UI ----------
55
+ def _build_ui(self):
56
+ root = QVBoxLayout(self)
57
+
58
+ # Top: source and destination
59
+ io_row = QHBoxLayout()
60
+ self.src_edit = QLineEdit(self)
61
+ self.src_edit.setPlaceholderText("Select a folder or add files…")
62
+ btn_scan = QPushButton("Scan Folder…", self); btn_scan.clicked.connect(self._scan_folder)
63
+ btn_add = QPushButton("Add Files…", self); btn_add.clicked.connect(self._add_files)
64
+ btn_clear = QPushButton("Clear Selections", self); btn_clear.clicked.connect(self._clear_selection)
65
+ io_row.addWidget(QLabel("Source:"))
66
+ io_row.addWidget(self.src_edit, 1)
67
+ io_row.addWidget(btn_scan)
68
+ io_row.addWidget(btn_add)
69
+ io_row.addWidget(btn_clear)
70
+
71
+ self.dest_edit = QLineEdit(self)
72
+ self.dest_edit.setPlaceholderText("(optional) Rename into this folder; leave empty to rename in place")
73
+ btn_dest = QPushButton("Browse…", self); btn_dest.clicked.connect(self._pick_dest)
74
+ io_row2 = QHBoxLayout()
75
+ io_row2.addWidget(QLabel("Destination:"))
76
+ io_row2.addWidget(self.dest_edit, 1)
77
+ io_row2.addWidget(btn_dest)
78
+
79
+ root.addLayout(io_row); root.addLayout(io_row2)
80
+
81
+ # Middle: template & options
82
+ pat_box = QGroupBox("Filename pattern")
83
+ pat_lay = QHBoxLayout(pat_box)
84
+
85
+ self.pattern_edit = QLineEdit(self)
86
+ self.pattern_edit.setPlaceholderText("e.g. LIGHT_{FILTER}_{EXPOSURE:.0f}s_{DATE-OBS:%Y%m%d}_{#03}.{ext}")
87
+ self.pattern_edit.textChanged.connect(self._refresh_preview)
88
+
89
+ self.lower_cb = QCheckBox("lowercase", self); self.lower_cb.toggled.connect(self._refresh_preview)
90
+ self.slug_cb = QCheckBox("spaces→_", self); self.slug_cb.toggled.connect(self._refresh_preview)
91
+ self.keep_ext_cb = QCheckBox("append .{ext} if missing", self); self.keep_ext_cb.setChecked(True)
92
+ self.index_start = QSpinBox(self); self.index_start.setRange(0, 999999); self.index_start.setValue(1)
93
+ self.index_start.valueChanged.connect(self._refresh_preview)
94
+
95
+ self.token_combo = QComboBox(self)
96
+ self.token_combo.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents)
97
+ self.token_combo.setMinimumContentsLength(12)
98
+ self.token_combo.setEditable(False)
99
+ self.token_combo.setToolTip("Insert token")
100
+ self.token_combo.activated.connect(
101
+ lambda idx: self._insert_token(self.token_combo.itemText(idx))
102
+ )
103
+
104
+ insert_btn = QPushButton("Insert", self)
105
+ insert_btn.clicked.connect(lambda: self._insert_token(self.token_combo.currentText()))
106
+
107
+ pat_lay.addWidget(QLabel("Template:")); pat_lay.addWidget(self.pattern_edit, 1)
108
+ pat_lay.addWidget(self.token_combo); pat_lay.addWidget(insert_btn)
109
+ pat_lay.addWidget(self.lower_cb); pat_lay.addWidget(self.slug_cb)
110
+ pat_lay.addWidget(QLabel("Index start:")); pat_lay.addWidget(self.index_start)
111
+ pat_lay.addWidget(self.keep_ext_cb)
112
+
113
+ root.addWidget(pat_box)
114
+
115
+ # Splitter: keys list | table
116
+ split = QSplitter(Qt.Orientation.Horizontal, self)
117
+
118
+ # Keys list
119
+ left = QWidget(self); lyt = QVBoxLayout(left)
120
+ left.setFixedWidth(180)
121
+ self.keys_list = QListWidget(self)
122
+ self.keys_list.itemDoubleClicked.connect(self._insert_key_from_list)
123
+ lyt.addWidget(QLabel("Available FITS keywords (double-click to insert):"))
124
+ lyt.addWidget(self.keys_list, 1)
125
+ split.addWidget(left)
126
+
127
+ # Table
128
+ right = QWidget(self); rlyt = QVBoxLayout(right)
129
+ self.table = QTableWidget(self); self.table.setColumnCount(4)
130
+ self.table.setHorizontalHeaderLabels(["Old path", "→", "New name", "Status"])
131
+ self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
132
+ self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
133
+ self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
134
+ self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
135
+ self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
136
+ self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
137
+ try:
138
+ self.table.setTextElideMode(Qt.TextElideMode.ElideMiddle) # PyQt6
139
+ except AttributeError:
140
+ pass
141
+ rlyt.addWidget(QLabel("Preview"))
142
+ rlyt.addWidget(self.table, 1)
143
+ split.addWidget(right)
144
+ split.setSizes([250, 700])
145
+
146
+ root.addWidget(split, 1)
147
+ split.setStretchFactor(0, 0)
148
+ split.setStretchFactor(1, 1)
149
+ split.setCollapsible(0, False)
150
+
151
+ # Buttons
152
+ btns = QDialogButtonBox(self)
153
+ self.btn_preview = btns.addButton("Preview", QDialogButtonBox.ButtonRole.ActionRole)
154
+ self.btn_rename = btns.addButton("Rename", QDialogButtonBox.ButtonRole.AcceptRole)
155
+ self.btn_close = btns.addButton(QDialogButtonBox.StandardButton.Close)
156
+ self.btn_preview.clicked.connect(self._refresh_preview)
157
+ self.btn_rename.clicked.connect(self._do_rename)
158
+ self.btn_close.clicked.connect(self.close)
159
+ root.addWidget(btns)
160
+
161
+ self._populate_token_keywords()
162
+
163
+ # ---------- settings ----------
164
+ def _load_settings(self):
165
+ self.settings.beginGroup("batchrename")
166
+ self.src_edit.setText(self.settings.value("last_dir", "", type=str) or "")
167
+ self.dest_edit.setText(self.settings.value("dest_dir", "", type=str) or "")
168
+ self.pattern_edit.setText(self.settings.value(
169
+ "pattern", "LIGHT_{FILTER}_{EXPOSURE:.0f}s_{DATE-OBS:%Y%m%d}_{#03}.{ext}", type=str))
170
+ self.lower_cb.setChecked(self.settings.value("lower", False, type=bool))
171
+ self.slug_cb.setChecked(self.settings.value("slug", True, type=bool))
172
+ self.keep_ext_cb.setChecked(self.settings.value("keep_ext", True, type=bool))
173
+ self.index_start.setValue(self.settings.value("index_start", 1, type=int))
174
+ self.settings.endGroup()
175
+ if self.src_edit.text():
176
+ self._scan_existing(self.src_edit.text())
177
+
178
+ def _save_settings(self):
179
+ self.settings.beginGroup("batchrename")
180
+ self.settings.setValue("last_dir", self.src_edit.text().strip())
181
+ self.settings.setValue("dest_dir", self.dest_edit.text().strip())
182
+ self.settings.setValue("pattern", self.pattern_edit.text().strip())
183
+ self.settings.setValue("lower", self.lower_cb.isChecked())
184
+ self.settings.setValue("slug", self.slug_cb.isChecked())
185
+ self.settings.setValue("keep_ext", self.keep_ext_cb.isChecked())
186
+ self.settings.setValue("index_start", self.index_start.value())
187
+ self.settings.endGroup()
188
+
189
+ # ---------- file loading ----------
190
+ def _scan_folder(self):
191
+ start = self.src_edit.text().strip()
192
+ path = QFileDialog.getExistingDirectory(self, "Select Folder", start or "")
193
+ if not path: return
194
+ self.src_edit.setText(path)
195
+ self._scan_existing(path)
196
+ self._save_settings()
197
+
198
+ def _scan_existing(self, path: str):
199
+ paths = []
200
+ for root, _, files in os.walk(path):
201
+ for f in files:
202
+ if f.lower().endswith((".fits", ".fit", ".fts", ".fz")):
203
+ paths.append(os.path.join(root, f))
204
+ paths.sort()
205
+ self._set_files(paths)
206
+
207
+ def _add_files(self):
208
+ start = self.src_edit.text().strip() or ""
209
+ files, _ = QFileDialog.getOpenFileNames(
210
+ self, "Add FITS files", start, "FITS files (*.fit *.fits *.fts *.fz);;All files (*)"
211
+ )
212
+ if not files: return
213
+ new = sorted(set(self.files) | set(files))
214
+ self._set_files(new)
215
+ if not self.src_edit.text() and files:
216
+ self.src_edit.setText(os.path.dirname(files[0]))
217
+ self._save_settings()
218
+
219
+ def _set_files(self, paths: List[str]):
220
+ self.files = paths
221
+ self.headers.clear()
222
+ union = set()
223
+ ok, bad = 0, 0
224
+ for p in self.files:
225
+ try:
226
+ with fits.open(p, memmap=False) as hdul:
227
+ h = hdul[0].header
228
+ self.headers[p] = h
229
+ union.update([str(k) for k in h.keys()])
230
+ ok += 1
231
+ except Exception:
232
+ bad += 1
233
+ self.union_keys = sorted(union)
234
+ self._rebuild_keys_list()
235
+ self._fill_table_rows()
236
+ self._refresh_preview()
237
+
238
+ def _pick_dest(self):
239
+ start = self.dest_edit.text().strip() or self.src_edit.text().strip()
240
+ d = QFileDialog.getExistingDirectory(self, "Choose Destination Folder", start or "")
241
+ if not d: return
242
+ self.dest_edit.setText(d)
243
+ self._save_settings()
244
+
245
+ def _autosize_combo(self, combo: QComboBox, base_padding: int = 36):
246
+ if combo.count() == 0:
247
+ combo.setMinimumWidth(160)
248
+ return
249
+ fm = QFontMetrics(combo.font())
250
+ maxw = 0
251
+ for i in range(combo.count()):
252
+ w = fm.horizontalAdvance(combo.itemText(i))
253
+ if not combo.itemIcon(i).isNull():
254
+ w += combo.iconSize().width() + 8
255
+ maxw = max(maxw, w)
256
+ width = maxw + base_padding
257
+ combo.setMinimumWidth(width)
258
+ if combo.view() is not None:
259
+ combo.view().setMinimumWidth(width)
260
+ combo.updateGeometry()
261
+
262
+ # ---------- keys & template insertion ----------
263
+ def _rebuild_keys_list(self):
264
+ self.keys_list.clear()
265
+ for k in self.union_keys:
266
+ self.keys_list.addItem(QListWidgetItem(k))
267
+ self._populate_token_keywords()
268
+
269
+ def _insert_key_from_list(self, item: QListWidgetItem):
270
+ if not item: return
271
+ self._insert_text("{"+item.text()+"}")
272
+
273
+ def _insert_token(self, token: str):
274
+ if not token: return
275
+ self._insert_text(token)
276
+
277
+ def _populate_token_keywords(self):
278
+ tokens = ["{#}", "{#03}", "{ext}"] + [f"{{{k}}}" for k in self.union_keys]
279
+ self.token_combo.blockSignals(True)
280
+ self.token_combo.clear()
281
+ self.token_combo.addItems(sorted(tokens, key=str.lower))
282
+ self.token_combo.blockSignals(False)
283
+ QTimer.singleShot(0, lambda: self._autosize_combo(self.token_combo))
284
+
285
+ def _insert_text(self, text: str):
286
+ e = self.pattern_edit
287
+ pos = e.cursorPosition()
288
+ s = e.text()
289
+ e.setText(s[:pos] + text + s[pos:])
290
+ e.setCursorPosition(pos + len(text))
291
+ self._refresh_preview()
292
+
293
+ # ---------- preview/rename ----------
294
+ def _fill_table_rows(self):
295
+ self.table.setRowCount(len(self.files))
296
+ for r, p in enumerate(self.files):
297
+ self.table.setItem(r, 0, QTableWidgetItem(p))
298
+ self.table.setItem(r, 1, QTableWidgetItem("→"))
299
+ self.table.setItem(r, 2, QTableWidgetItem(""))
300
+ self.table.setItem(r, 3, QTableWidgetItem(""))
301
+
302
+ def _clear_selection(self):
303
+ self.files = []
304
+ self.headers.clear()
305
+ self.union_keys = []
306
+ self._rebuild_keys_list()
307
+ self.table.setRowCount(0)
308
+ self._refresh_preview()
309
+
310
+ def _refresh_preview(self):
311
+ pat = self.pattern_edit.text().strip()
312
+ if not pat: return
313
+ dest = (self.dest_edit.text().strip() or None)
314
+ start_idx = self.index_start.value()
315
+ lower = self.lower_cb.isChecked()
316
+ slug = self.slug_cb.isChecked()
317
+ keep_ext = self.keep_ext_cb.isChecked()
318
+
319
+ names = []
320
+ for i, p in enumerate(self.files):
321
+ hdr = self.headers.get(p, fits.Header())
322
+ base = self._render_pattern(pat, hdr, i, start_idx, p)
323
+ if keep_ext and "{ext}" not in pat:
324
+ ext = os.path.splitext(p)[1]
325
+ if ext:
326
+ base = f"{base}{ext}"
327
+ if lower: base = base.lower()
328
+ if slug: base = self._slugify(base)
329
+ folder = dest if dest else os.path.dirname(p)
330
+ target = os.path.join(folder, base)
331
+ names.append(target)
332
+
333
+ seen = defaultdict(int)
334
+ for t in names: seen[t] += 1
335
+
336
+ for r, p in enumerate(self.files):
337
+ newp = names[r]
338
+ self._set_table_preview_row(r, p, newp, seen[newp])
339
+
340
+ def _set_table_preview_row(self, r: int, old: str, new: str, count: int):
341
+ self.table.item(r, 0).setText(old)
342
+ self.table.item(r, 2).setText(new)
343
+ status = ""
344
+ conflict = (count > 1)
345
+ if conflict: status = "name collision"
346
+ elif os.path.exists(new): status = "will overwrite"
347
+ else: status = "ok"
348
+ it = QTableWidgetItem(status)
349
+ if conflict or status == "will overwrite":
350
+ it.setForeground(Qt.GlobalColor.red)
351
+ self.table.setItem(r, 3, it)
352
+ it_old = self.table.item(r, 0)
353
+ it_new = self.table.item(r, 2)
354
+ if it_old: it_old.setToolTip(old)
355
+ if it_new: it_new.setToolTip(new)
356
+
357
+ def _do_rename(self):
358
+ n = self.table.rowCount()
359
+ targets = [self.table.item(r, 2).text() for r in range(n)]
360
+ counts = defaultdict(int)
361
+ for t in targets: counts[t] += 1
362
+ collisions = [t for t,c in counts.items() if c > 1]
363
+ if collisions:
364
+ QMessageBox.warning(self, "Collisions",
365
+ "Two or more files would map to the same name. Adjust your pattern.")
366
+ return
367
+
368
+ failures = []
369
+ for r in range(n):
370
+ oldp = self.table.item(r, 0).text()
371
+ newp = self.table.item(r, 2).text()
372
+ if oldp == newp:
373
+ continue
374
+ os.makedirs(os.path.dirname(newp), exist_ok=True)
375
+ try:
376
+ shutil.move(oldp, newp)
377
+ self.table.item(r, 3).setText("renamed")
378
+ except Exception as e:
379
+ self.table.item(r, 3).setText(f"ERROR: {e}")
380
+ self.table.item(r, 3).setForeground(Qt.GlobalColor.red)
381
+ failures.append((oldp, str(e)))
382
+
383
+ if failures:
384
+ QMessageBox.warning(self, "Done with errors",
385
+ f"Some files could not be renamed ({len(failures)} errors).")
386
+ else:
387
+ QMessageBox.information(self, "Done", "All files renamed.")
388
+ self._save_settings()
389
+ src = self.src_edit.text().strip()
390
+ if src and not self.dest_edit.text().strip():
391
+ self._scan_existing(src)
392
+
393
+ # ---------- helpers ----------
394
+ @staticmethod
395
+ def _slugify(s: str) -> str:
396
+ s = s.replace(" ", "_")
397
+ return re.sub(r"[^A-Za-z0-9._-]+", "", s)
398
+
399
+ def _render_pattern(self, pat: str, hdr: fits.Header, i: int, start_idx: int, file_path: str) -> str:
400
+ def apply_filters(text: str, filters: list[str]) -> str:
401
+ out = str(text)
402
+ for f in filters:
403
+ f = f.strip()
404
+ if f.startswith("re:"):
405
+ pattern = f[3:]
406
+ m = re.search(pattern, out)
407
+ if not m:
408
+ out = ""
409
+ else:
410
+ out = m.group(1) if m.lastindex else m.group(0)
411
+ elif f == "lower":
412
+ out = out.lower()
413
+ elif f == "upper":
414
+ out = out.upper()
415
+ elif f.startswith("slice:"):
416
+ try:
417
+ _, a, b = f.split(":", 2)
418
+ a = int(a) if a else None
419
+ b = int(b) if b else None
420
+ out = out[a:b]
421
+ except Exception:
422
+ pass
423
+ elif f == "strip":
424
+ out = out.strip()
425
+ return out
426
+
427
+ def _split_top_level_pipes(s: str) -> List[str]:
428
+ parts, buf = [], []
429
+ depth = 0
430
+ esc = False
431
+ for ch in s:
432
+ if esc:
433
+ buf.append(ch); esc = False; continue
434
+ if ch == '\\':
435
+ buf.append(ch); esc = True; continue
436
+ if ch in '([{':
437
+ depth += 1
438
+ elif ch in ')]}':
439
+ depth = max(0, depth-1)
440
+ if ch == '|' and depth == 0:
441
+ parts.append(''.join(buf)); buf = []
442
+ else:
443
+ buf.append(ch)
444
+ parts.append(''.join(buf))
445
+ return parts
446
+
447
+ token_re = re.compile(r"\{((?:[^{}]|\{[^{}]*\})+)\}")
448
+
449
+ def repl(m):
450
+ body = m.group(1)
451
+ parts = _split_top_level_pipes(body)
452
+ key_fmt = parts[0]
453
+ filters = parts[1:] if len(parts) > 1 else []
454
+
455
+ # counter?
456
+ if key_fmt.startswith("#"):
457
+ w = key_fmt[1:]
458
+ try:
459
+ pad = int(w) if w else 0
460
+ except Exception:
461
+ pad = 0
462
+ num = i + start_idx
463
+ return f"{num:0{pad}d}" if pad else str(num)
464
+
465
+ # extension?
466
+ if key_fmt.lower() == "ext":
467
+ ext = os.path.splitext(file_path)[1]
468
+ return ext.lstrip(".")
469
+
470
+ # key[:fmt]
471
+ if ":" in key_fmt:
472
+ key, fmt = key_fmt.split(":", 1)
473
+ else:
474
+ key, fmt = key_fmt, ""
475
+ key_up = key.upper()
476
+ val = hdr.get(key_up, "")
477
+ if val is None:
478
+ val = ""
479
+
480
+ # DATE-like with datetime fmt
481
+ if fmt and key_up in ("DATE-OBS", "DATE"):
482
+ s = str(val).strip().replace("Z", "+00:00")
483
+ try:
484
+ dt = datetime.fromisoformat(s)
485
+ if dt.tzinfo is None:
486
+ dt = dt.replace(tzinfo=timezone.utc)
487
+ out = dt.strftime(fmt)
488
+ except Exception:
489
+ out = str(val)
490
+ return apply_filters(out, filters)
491
+
492
+ # TIME-only keys with time fmt
493
+ if fmt and key_up in ("TIME-OBS", "UTSTART", "UTC-START"):
494
+ s = str(val).strip()
495
+ try:
496
+ if s.count(":") == 2:
497
+ tt = datetime.strptime(s, "%H:%M:%S").time()
498
+ else:
499
+ tt = datetime.strptime(s, "%H:%M").time()
500
+ out = tt.strftime(fmt)
501
+ except Exception:
502
+ try:
503
+ tt = datetime.fromisoformat(f"1970-01-01T{s}").time()
504
+ out = tt.strftime(fmt)
505
+ except Exception:
506
+ out = str(val)
507
+ return apply_filters(out, filters)
508
+
509
+ # numeric with fmt
510
+ if fmt:
511
+ try:
512
+ out = format(float(val), fmt)
513
+ return apply_filters(out, filters)
514
+ except Exception:
515
+ pass
516
+
517
+ return apply_filters(str(val), filters)
518
+
519
+ return token_re.sub(repl, pat)