batplot 1.8.1__py3-none-any.whl → 1.8.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.
Potentially problematic release.
This version of batplot might be problematic. Click here for more details.
- batplot/__init__.py +1 -1
- batplot/args.py +2 -0
- batplot/batch.py +23 -0
- batplot/batplot.py +101 -12
- batplot/cpc_interactive.py +25 -3
- batplot/electrochem_interactive.py +20 -4
- batplot/interactive.py +19 -15
- batplot/modes.py +12 -12
- batplot/operando_ec_interactive.py +4 -4
- batplot/session.py +218 -0
- batplot/style.py +21 -2
- batplot/version_check.py +1 -1
- {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/METADATA +1 -1
- batplot-1.8.3.dist-info/RECORD +75 -0
- {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/top_level.txt +1 -0
- batplot_backup_20251221_101150/__init__.py +5 -0
- batplot_backup_20251221_101150/args.py +625 -0
- batplot_backup_20251221_101150/batch.py +1176 -0
- batplot_backup_20251221_101150/batplot.py +3589 -0
- batplot_backup_20251221_101150/cif.py +823 -0
- batplot_backup_20251221_101150/cli.py +149 -0
- batplot_backup_20251221_101150/color_utils.py +547 -0
- batplot_backup_20251221_101150/config.py +198 -0
- batplot_backup_20251221_101150/converters.py +204 -0
- batplot_backup_20251221_101150/cpc_interactive.py +4409 -0
- batplot_backup_20251221_101150/electrochem_interactive.py +4520 -0
- batplot_backup_20251221_101150/interactive.py +3894 -0
- batplot_backup_20251221_101150/manual.py +323 -0
- batplot_backup_20251221_101150/modes.py +799 -0
- batplot_backup_20251221_101150/operando.py +603 -0
- batplot_backup_20251221_101150/operando_ec_interactive.py +5487 -0
- batplot_backup_20251221_101150/plotting.py +228 -0
- batplot_backup_20251221_101150/readers.py +2607 -0
- batplot_backup_20251221_101150/session.py +2951 -0
- batplot_backup_20251221_101150/style.py +1441 -0
- batplot_backup_20251221_101150/ui.py +790 -0
- batplot_backup_20251221_101150/utils.py +1046 -0
- batplot_backup_20251221_101150/version_check.py +253 -0
- batplot-1.8.1.dist-info/RECORD +0 -52
- {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/WHEEL +0 -0
- {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/entry_points.txt +0 -0
- {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,1046 @@
|
|
|
1
|
+
"""Utility helpers for batplot.
|
|
2
|
+
|
|
3
|
+
This module provides file organization and text formatting utilities used
|
|
4
|
+
throughout batplot. It handles cross-platform file dialogs, directory management,
|
|
5
|
+
and text formatting.
|
|
6
|
+
|
|
7
|
+
MAIN FUNCTIONS:
|
|
8
|
+
--------------
|
|
9
|
+
1. **Directory Management**: Create and manage subdirectories (e.g., Figures/)
|
|
10
|
+
for organized output when batch processing files.
|
|
11
|
+
|
|
12
|
+
2. **File Dialogs**: Cross-platform folder/file picker dialogs:
|
|
13
|
+
- macOS: Uses AppleScript (native, no dependencies)
|
|
14
|
+
- Windows/Linux: Uses tkinter (if available)
|
|
15
|
+
- Linux fallback: Uses zenity/kdialog (if available)
|
|
16
|
+
|
|
17
|
+
3. **Text Normalization**: Format labels for matplotlib rendering:
|
|
18
|
+
- Handles LaTeX/mathtext syntax
|
|
19
|
+
- Escapes special characters
|
|
20
|
+
- Ensures proper rendering in plots
|
|
21
|
+
|
|
22
|
+
4. **Overwrite Protection**: Ask user before overwriting existing files
|
|
23
|
+
(prevents accidental data loss).
|
|
24
|
+
|
|
25
|
+
CROSS-PLATFORM COMPATIBILITY:
|
|
26
|
+
----------------------------
|
|
27
|
+
This module handles differences between operating systems:
|
|
28
|
+
- macOS: Uses AppleScript (avoids tkinter crashes)
|
|
29
|
+
- Windows: Uses tkinter (standard GUI library)
|
|
30
|
+
- Linux: Tries tkinter first, falls back to zenity/kdialog
|
|
31
|
+
|
|
32
|
+
All dialogs gracefully degrade: if GUI dialogs aren't available, functions
|
|
33
|
+
return None and calling code can fall back to manual input.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import os
|
|
37
|
+
import sys
|
|
38
|
+
import shutil
|
|
39
|
+
import subprocess
|
|
40
|
+
import time
|
|
41
|
+
from typing import Optional, List, Tuple
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _ask_directory_dialog(initialdir: Optional[str] = None) -> Optional[str]:
|
|
45
|
+
"""Open a folder picker dialog with per-platform helpers.
|
|
46
|
+
|
|
47
|
+
On macOS it uses AppleScript (osascript), avoiding the fragile Tk backend.
|
|
48
|
+
On other platforms it tries tkinter first, then optional desktop helpers.
|
|
49
|
+
Returns ``None`` if the dialog isn't available or the user cancels.
|
|
50
|
+
"""
|
|
51
|
+
initialdir = os.path.abspath(initialdir or os.getcwd())
|
|
52
|
+
if not os.path.isdir(initialdir):
|
|
53
|
+
initialdir = os.path.expanduser("~")
|
|
54
|
+
|
|
55
|
+
# macOS: ONLY use osascript dialog to avoid Tk crashes (never use tkinter on macOS)
|
|
56
|
+
if sys.platform.startswith("darwin"):
|
|
57
|
+
try:
|
|
58
|
+
path = _ask_directory_dialog_macos(initialdir)
|
|
59
|
+
# path will be None if user canceled, dialog failed, or path invalid
|
|
60
|
+
# This is expected behavior - will fall back to manual input
|
|
61
|
+
return path
|
|
62
|
+
except Exception:
|
|
63
|
+
# If AppleScript fails with an exception, return None (will fall back to manual input)
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
# Windows/Linux: Try tkinter first
|
|
67
|
+
if not sys.platform.startswith("darwin"):
|
|
68
|
+
try:
|
|
69
|
+
path = _ask_directory_dialog_tk(initialdir)
|
|
70
|
+
if path:
|
|
71
|
+
return path
|
|
72
|
+
except Exception:
|
|
73
|
+
# If tkinter fails, continue to other methods
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
# Linux desktop fallback via zenity/kdialog if available
|
|
77
|
+
if sys.platform.startswith("linux"):
|
|
78
|
+
try:
|
|
79
|
+
path = _ask_directory_dialog_zenity(initialdir)
|
|
80
|
+
if path:
|
|
81
|
+
return path
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _ask_directory_dialog_macos(initialdir: str) -> Optional[str]:
|
|
89
|
+
"""Use AppleScript (osascript) to show the native folder picker on macOS.
|
|
90
|
+
|
|
91
|
+
Returns the selected folder path, or None if user cancels or if any error occurs.
|
|
92
|
+
"""
|
|
93
|
+
if not shutil.which("osascript"):
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
prompt = "Select a folder"
|
|
97
|
+
# Build AppleScript - use a single error handler to avoid syntax issues
|
|
98
|
+
# Error -128 is user cancel, which is expected behavior
|
|
99
|
+
if os.path.isdir(initialdir):
|
|
100
|
+
# Use a variable for the path to avoid quoting issues
|
|
101
|
+
script_parts = [
|
|
102
|
+
f'set initialPath to "{initialdir}"',
|
|
103
|
+
"try",
|
|
104
|
+
" set defaultLocation to POSIX file initialPath",
|
|
105
|
+
f' set theFolder to choose folder with prompt "{prompt}" default location defaultLocation',
|
|
106
|
+
" return POSIX path of theFolder",
|
|
107
|
+
"on error errMsg number errNum",
|
|
108
|
+
" if errNum is -128 then",
|
|
109
|
+
' return ""',
|
|
110
|
+
" else",
|
|
111
|
+
' return ""',
|
|
112
|
+
" end if",
|
|
113
|
+
"end try"
|
|
114
|
+
]
|
|
115
|
+
script = "\n".join(script_parts)
|
|
116
|
+
else:
|
|
117
|
+
script_parts = [
|
|
118
|
+
"try",
|
|
119
|
+
f' set theFolder to choose folder with prompt "{prompt}"',
|
|
120
|
+
" return POSIX path of theFolder",
|
|
121
|
+
"on error errMsg number errNum",
|
|
122
|
+
" if errNum is -128 then",
|
|
123
|
+
' return ""',
|
|
124
|
+
" else",
|
|
125
|
+
' return ""',
|
|
126
|
+
" end if",
|
|
127
|
+
"end try"
|
|
128
|
+
]
|
|
129
|
+
script = "\n".join(script_parts)
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
# Run AppleScript - pass script via stdin instead of -e for better multi-line support
|
|
133
|
+
# The dialog should appear and block until user responds
|
|
134
|
+
res = subprocess.run(
|
|
135
|
+
["osascript"],
|
|
136
|
+
input=script,
|
|
137
|
+
capture_output=True,
|
|
138
|
+
text=True,
|
|
139
|
+
check=False,
|
|
140
|
+
timeout=300, # 5 minute timeout (user might take time to navigate)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Check return code
|
|
144
|
+
if res.returncode == 0:
|
|
145
|
+
selection = res.stdout.strip()
|
|
146
|
+
# Empty string means user canceled (error -128 was caught and returned "")
|
|
147
|
+
if not selection:
|
|
148
|
+
return None
|
|
149
|
+
# Validate that the selected path exists and is a directory
|
|
150
|
+
if os.path.isdir(selection):
|
|
151
|
+
return selection
|
|
152
|
+
# Path doesn't exist (shouldn't happen, but be safe)
|
|
153
|
+
return None
|
|
154
|
+
else:
|
|
155
|
+
# AppleScript returned an error code (non-zero)
|
|
156
|
+
# This could be a syntax error or other issue
|
|
157
|
+
# The dialog might not have appeared at all
|
|
158
|
+
# Return None to fall back to manual input
|
|
159
|
+
return None
|
|
160
|
+
except subprocess.TimeoutExpired:
|
|
161
|
+
# Dialog timed out (user took too long or dialog didn't appear)
|
|
162
|
+
return None
|
|
163
|
+
except FileNotFoundError:
|
|
164
|
+
# osascript not found (shouldn't happen since we check with shutil.which)
|
|
165
|
+
return None
|
|
166
|
+
except Exception:
|
|
167
|
+
# Any other error (permission issues, etc.)
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _ask_directory_dialog_tk(initialdir: str) -> Optional[str]:
|
|
172
|
+
"""Tkinter-based folder picker (Windows/Linux only - never used on macOS)."""
|
|
173
|
+
# Never use tkinter on macOS to avoid crashes
|
|
174
|
+
if sys.platform.startswith("darwin"):
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
root = None
|
|
178
|
+
try:
|
|
179
|
+
import tkinter as tk
|
|
180
|
+
from tkinter import filedialog
|
|
181
|
+
except Exception:
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
root = tk.Tk()
|
|
186
|
+
root.withdraw()
|
|
187
|
+
# Suppress any window updates that might cause issues
|
|
188
|
+
root.update_idletasks()
|
|
189
|
+
try:
|
|
190
|
+
root.attributes('-topmost', True)
|
|
191
|
+
except Exception:
|
|
192
|
+
pass
|
|
193
|
+
folder = filedialog.askdirectory(
|
|
194
|
+
title="Select a folder",
|
|
195
|
+
initialdir=initialdir,
|
|
196
|
+
mustexist=False,
|
|
197
|
+
)
|
|
198
|
+
result = folder if folder else None
|
|
199
|
+
return result
|
|
200
|
+
except Exception as e:
|
|
201
|
+
# Silently fail - will fall back to manual input
|
|
202
|
+
return None
|
|
203
|
+
finally:
|
|
204
|
+
if root is not None:
|
|
205
|
+
try:
|
|
206
|
+
root.quit()
|
|
207
|
+
except Exception:
|
|
208
|
+
pass
|
|
209
|
+
try:
|
|
210
|
+
root.destroy()
|
|
211
|
+
except Exception:
|
|
212
|
+
pass
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _ask_directory_dialog_zenity(initialdir: str) -> Optional[str]:
|
|
216
|
+
"""Use zenity/kdialog on Linux if available."""
|
|
217
|
+
cmd = None
|
|
218
|
+
if shutil.which("zenity"):
|
|
219
|
+
cmd = [
|
|
220
|
+
"zenity",
|
|
221
|
+
"--file-selection",
|
|
222
|
+
"--directory",
|
|
223
|
+
f"--filename={initialdir.rstrip(os.sep) + os.sep}",
|
|
224
|
+
"--title=Select a folder",
|
|
225
|
+
]
|
|
226
|
+
elif shutil.which("kdialog"):
|
|
227
|
+
cmd = [
|
|
228
|
+
"kdialog",
|
|
229
|
+
"--getexistingdirectory",
|
|
230
|
+
initialdir,
|
|
231
|
+
"--title",
|
|
232
|
+
"Select a folder",
|
|
233
|
+
]
|
|
234
|
+
if not cmd:
|
|
235
|
+
return None
|
|
236
|
+
try:
|
|
237
|
+
res = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
238
|
+
if res.returncode == 0:
|
|
239
|
+
selection = res.stdout.strip()
|
|
240
|
+
return selection or None
|
|
241
|
+
except Exception:
|
|
242
|
+
pass
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _ask_file_dialog(initialdir: Optional[str] = None, filetypes: Optional[Tuple[str, ...]] = None) -> Optional[str]:
|
|
247
|
+
"""Open a platform-aware file picker dialog."""
|
|
248
|
+
initialdir = os.path.abspath(initialdir or os.getcwd())
|
|
249
|
+
if not os.path.isdir(initialdir):
|
|
250
|
+
initialdir = os.path.expanduser("~")
|
|
251
|
+
|
|
252
|
+
# macOS: use AppleScript for file selection
|
|
253
|
+
if sys.platform.startswith("darwin"):
|
|
254
|
+
return _ask_file_dialog_macos(initialdir)
|
|
255
|
+
|
|
256
|
+
# Windows/Linux: try tkinter first
|
|
257
|
+
if not sys.platform.startswith("darwin"):
|
|
258
|
+
try:
|
|
259
|
+
path = _ask_file_dialog_tk(initialdir, filetypes=filetypes)
|
|
260
|
+
if path:
|
|
261
|
+
return path
|
|
262
|
+
except Exception:
|
|
263
|
+
pass
|
|
264
|
+
|
|
265
|
+
# Linux desktop fallback via zenity/kdialog if available
|
|
266
|
+
if sys.platform.startswith("linux"):
|
|
267
|
+
try:
|
|
268
|
+
path = _ask_file_dialog_zenity(initialdir, filetypes=filetypes)
|
|
269
|
+
if path:
|
|
270
|
+
return path
|
|
271
|
+
except Exception:
|
|
272
|
+
pass
|
|
273
|
+
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _ask_file_dialog_macos(initialdir: str) -> Optional[str]:
|
|
278
|
+
if not shutil.which("osascript"):
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
script_parts = [
|
|
282
|
+
f'set initialPath to "{initialdir}"',
|
|
283
|
+
"try",
|
|
284
|
+
" set defaultLocation to POSIX file initialPath",
|
|
285
|
+
' set theFile to choose file with prompt "Select a style file" default location defaultLocation',
|
|
286
|
+
" return POSIX path of theFile",
|
|
287
|
+
"on error errMsg number errNum",
|
|
288
|
+
" if errNum is -128 then",
|
|
289
|
+
' return ""',
|
|
290
|
+
" else",
|
|
291
|
+
' return ""',
|
|
292
|
+
" end if",
|
|
293
|
+
"end try"
|
|
294
|
+
]
|
|
295
|
+
script = "\n".join(script_parts)
|
|
296
|
+
try:
|
|
297
|
+
res = subprocess.run(
|
|
298
|
+
["osascript"],
|
|
299
|
+
input=script,
|
|
300
|
+
capture_output=True,
|
|
301
|
+
text=True,
|
|
302
|
+
check=False,
|
|
303
|
+
timeout=300,
|
|
304
|
+
)
|
|
305
|
+
if res.returncode == 0:
|
|
306
|
+
selection = res.stdout.strip()
|
|
307
|
+
if selection and os.path.isfile(selection):
|
|
308
|
+
return selection
|
|
309
|
+
return None
|
|
310
|
+
return None
|
|
311
|
+
except Exception:
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _ask_file_dialog_tk(initialdir: str, filetypes: Optional[Tuple[str, ...]] = None) -> Optional[str]:
|
|
316
|
+
if sys.platform.startswith("darwin"):
|
|
317
|
+
return None
|
|
318
|
+
root = None
|
|
319
|
+
try:
|
|
320
|
+
import tkinter as tk
|
|
321
|
+
from tkinter import filedialog
|
|
322
|
+
except Exception:
|
|
323
|
+
return None
|
|
324
|
+
try:
|
|
325
|
+
root = tk.Tk()
|
|
326
|
+
root.withdraw()
|
|
327
|
+
root.update_idletasks()
|
|
328
|
+
try:
|
|
329
|
+
root.attributes('-topmost', True)
|
|
330
|
+
except Exception:
|
|
331
|
+
pass
|
|
332
|
+
tk_filetypes = [("All files", "*.*")]
|
|
333
|
+
if filetypes:
|
|
334
|
+
patterns = " ".join(f"*{ext}" if ext.startswith('.') else f"*.{ext}" for ext in filetypes)
|
|
335
|
+
tk_filetypes.insert(0, ("Style files", patterns))
|
|
336
|
+
file_path = filedialog.askopenfilename(
|
|
337
|
+
title="Select a style file",
|
|
338
|
+
initialdir=initialdir,
|
|
339
|
+
filetypes=tk_filetypes,
|
|
340
|
+
)
|
|
341
|
+
return file_path or None
|
|
342
|
+
except Exception:
|
|
343
|
+
return None
|
|
344
|
+
finally:
|
|
345
|
+
if root is not None:
|
|
346
|
+
try:
|
|
347
|
+
root.quit()
|
|
348
|
+
except Exception:
|
|
349
|
+
pass
|
|
350
|
+
try:
|
|
351
|
+
root.destroy()
|
|
352
|
+
except Exception:
|
|
353
|
+
pass
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _ask_file_dialog_zenity(initialdir: str, filetypes: Optional[Tuple[str, ...]] = None) -> Optional[str]:
|
|
357
|
+
cmd = None
|
|
358
|
+
if shutil.which("zenity"):
|
|
359
|
+
filename_arg = f"--filename={os.path.join(initialdir.rstrip(os.sep), '')}"
|
|
360
|
+
zenity_cmd = [
|
|
361
|
+
"zenity",
|
|
362
|
+
"--file-selection",
|
|
363
|
+
"--title=Select a style file",
|
|
364
|
+
filename_arg,
|
|
365
|
+
]
|
|
366
|
+
if filetypes:
|
|
367
|
+
patterns = " ".join(f"*{ext}" if ext.startswith('.') else f"*.{ext}" for ext in filetypes)
|
|
368
|
+
zenity_cmd.append(f"--file-filter=Style files | {patterns}")
|
|
369
|
+
cmd = zenity_cmd
|
|
370
|
+
elif shutil.which("kdialog"):
|
|
371
|
+
pattern = " ".join(f"*{ext}" if ext.startswith('.') else f"*.{ext}" for ext in (filetypes or ()))
|
|
372
|
+
if not pattern:
|
|
373
|
+
pattern = "*"
|
|
374
|
+
cmd = [
|
|
375
|
+
"kdialog",
|
|
376
|
+
"--getopenfilename",
|
|
377
|
+
initialdir,
|
|
378
|
+
pattern,
|
|
379
|
+
"--title",
|
|
380
|
+
"Select a style file",
|
|
381
|
+
]
|
|
382
|
+
if cmd is None:
|
|
383
|
+
return None
|
|
384
|
+
try:
|
|
385
|
+
res = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
386
|
+
if res.returncode == 0:
|
|
387
|
+
selection = res.stdout.strip()
|
|
388
|
+
if selection and os.path.isfile(selection):
|
|
389
|
+
return selection
|
|
390
|
+
return None
|
|
391
|
+
except Exception:
|
|
392
|
+
return None
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def ensure_subdirectory(subdir_name: str, base_path: str = None) -> str:
|
|
396
|
+
"""Ensure subdirectory exists and return its path.
|
|
397
|
+
|
|
398
|
+
Creates a subdirectory if it doesn't exist. Used to organize output files
|
|
399
|
+
into Figures/, Styles/, and Projects/ folders.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
subdir_name: Name of subdirectory ('Figures', 'Styles', or 'Projects')
|
|
403
|
+
base_path: Base directory (defaults to current working directory)
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
Full path to the subdirectory (or base_path if creation fails)
|
|
407
|
+
|
|
408
|
+
Example:
|
|
409
|
+
>>> ensure_subdirectory('Figures', '/home/user/data')
|
|
410
|
+
'/home/user/data/Figures'
|
|
411
|
+
"""
|
|
412
|
+
# Use current directory if no base path specified
|
|
413
|
+
if base_path is None:
|
|
414
|
+
base_path = os.getcwd()
|
|
415
|
+
|
|
416
|
+
# Build full path to subdirectory
|
|
417
|
+
subdir_path = os.path.join(base_path, subdir_name)
|
|
418
|
+
|
|
419
|
+
# Create directory if it doesn't exist
|
|
420
|
+
# exist_ok=True prevents error if directory already exists
|
|
421
|
+
try:
|
|
422
|
+
os.makedirs(subdir_path, exist_ok=True)
|
|
423
|
+
except Exception as e:
|
|
424
|
+
# If creation fails (permissions, etc.), warn and fall back to base directory
|
|
425
|
+
print(f"Warning: Could not create {subdir_name} directory: {e}")
|
|
426
|
+
return base_path
|
|
427
|
+
|
|
428
|
+
return subdir_path
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def get_organized_path(filename: str, file_type: str, base_path: str = None) -> str:
|
|
432
|
+
"""Get the appropriate path for a file based on its type.
|
|
433
|
+
|
|
434
|
+
This function helps organize output files into subdirectories:
|
|
435
|
+
- Figures go into Figures/
|
|
436
|
+
- Styles go into Styles/
|
|
437
|
+
- Projects go into Projects/
|
|
438
|
+
|
|
439
|
+
If the filename already contains a directory path, it's used as-is.
|
|
440
|
+
|
|
441
|
+
Args:
|
|
442
|
+
filename: The filename (can include path like 'output/fig.svg')
|
|
443
|
+
file_type: 'figure', 'style', or 'project'
|
|
444
|
+
base_path: Base directory (defaults to current working directory)
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
Full path with appropriate subdirectory
|
|
448
|
+
|
|
449
|
+
Example:
|
|
450
|
+
>>> get_organized_path('plot.svg', 'figure')
|
|
451
|
+
'./Figures/plot.svg'
|
|
452
|
+
>>> get_organized_path('/tmp/plot.svg', 'figure')
|
|
453
|
+
'/tmp/plot.svg' # Already has path, use as-is
|
|
454
|
+
"""
|
|
455
|
+
# If filename already has a directory component, respect user's choice
|
|
456
|
+
# os.path.dirname returns '' for bare filenames, non-empty for paths
|
|
457
|
+
if os.path.dirname(filename):
|
|
458
|
+
return filename
|
|
459
|
+
|
|
460
|
+
# Map file type to subdirectory name
|
|
461
|
+
subdir_map = {
|
|
462
|
+
'figure': 'Figures',
|
|
463
|
+
'style': 'Styles',
|
|
464
|
+
'project': 'Projects'
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
subdir_name = subdir_map.get(file_type)
|
|
468
|
+
if not subdir_name:
|
|
469
|
+
# Unknown file type, just use current directory without subdirectory
|
|
470
|
+
if base_path is None:
|
|
471
|
+
base_path = os.getcwd()
|
|
472
|
+
return os.path.join(base_path, filename)
|
|
473
|
+
|
|
474
|
+
# Ensure subdirectory exists and get its path
|
|
475
|
+
subdir_path = ensure_subdirectory(subdir_name, base_path)
|
|
476
|
+
return os.path.join(subdir_path, filename)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
STYLE_FILE_EXTENSIONS = ('.bps', '.bpsg', '.bpcfg')
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def list_files_in_subdirectory(extensions: tuple, file_type: str, base_path: str = None) -> list:
|
|
483
|
+
"""List files with given extensions in the appropriate subdirectory.
|
|
484
|
+
|
|
485
|
+
Used by interactive menus to show available files for import/load operations.
|
|
486
|
+
For example, listing all .json style files in Styles/ directory.
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
extensions: Tuple of file extensions (e.g., ('.svg', '.png', '.pdf'))
|
|
490
|
+
Case-insensitive matching
|
|
491
|
+
file_type: 'figure', 'style', or 'project' - determines which subdirectory
|
|
492
|
+
base_path: Base directory (defaults to current working directory)
|
|
493
|
+
|
|
494
|
+
Returns:
|
|
495
|
+
List of (filename, full_path) tuples sorted alphabetically by filename
|
|
496
|
+
Empty list if directory doesn't exist or can't be read
|
|
497
|
+
|
|
498
|
+
Example:
|
|
499
|
+
>>> list_files_in_subdirectory(('.json',), 'style')
|
|
500
|
+
[('mystyle.json', './Styles/mystyle.json'), ...]
|
|
501
|
+
"""
|
|
502
|
+
if base_path is None:
|
|
503
|
+
base_path = os.getcwd()
|
|
504
|
+
|
|
505
|
+
# Map file type to subdirectory name (same as get_organized_path)
|
|
506
|
+
subdir_map = {
|
|
507
|
+
'figure': 'Figures',
|
|
508
|
+
'style': 'Styles',
|
|
509
|
+
'project': 'Projects'
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
subdir_name = subdir_map.get(file_type)
|
|
513
|
+
if not subdir_name:
|
|
514
|
+
# Unknown type, list from current directory
|
|
515
|
+
folder = base_path
|
|
516
|
+
else:
|
|
517
|
+
# Build path to subdirectory
|
|
518
|
+
folder = os.path.join(base_path, subdir_name)
|
|
519
|
+
# Create directory if it doesn't exist (for first-time users)
|
|
520
|
+
try:
|
|
521
|
+
os.makedirs(folder, exist_ok=True)
|
|
522
|
+
except Exception:
|
|
523
|
+
# If creation fails, fall back to base directory
|
|
524
|
+
folder = base_path
|
|
525
|
+
|
|
526
|
+
# Scan directory for matching files
|
|
527
|
+
files = []
|
|
528
|
+
try:
|
|
529
|
+
all_files = os.listdir(folder)
|
|
530
|
+
for f in all_files:
|
|
531
|
+
# Case-insensitive extension matching
|
|
532
|
+
if f.lower().endswith(extensions):
|
|
533
|
+
files.append((f, os.path.join(folder, f)))
|
|
534
|
+
except Exception:
|
|
535
|
+
# If directory can't be read, return empty list
|
|
536
|
+
# Don't crash - user can still work without listing files
|
|
537
|
+
pass
|
|
538
|
+
|
|
539
|
+
# Sort alphabetically by filename for consistent display
|
|
540
|
+
return sorted(files, key=lambda x: x[0])
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def convert_label_shortcuts(text: str) -> str:
|
|
544
|
+
"""Convert shortcut syntax to LaTeX format for labels.
|
|
545
|
+
|
|
546
|
+
Converts {super(...)} and {sub(...)} shortcuts to LaTeX superscript/subscript format.
|
|
547
|
+
This allows easier input of mathematical notation without typing full LaTeX.
|
|
548
|
+
|
|
549
|
+
Args:
|
|
550
|
+
text: Label text that may contain {super(...)} or {sub(...)} shortcuts
|
|
551
|
+
|
|
552
|
+
Returns:
|
|
553
|
+
Text with shortcuts converted to LaTeX format (uses \\mathrm{} to prevent italic rendering).
|
|
554
|
+
|
|
555
|
+
Examples:
|
|
556
|
+
>>> convert_label_shortcuts("g{super(-1)}")
|
|
557
|
+
"g$^{\\mathrm{-1}}$"
|
|
558
|
+
>>> convert_label_shortcuts("Li{sub(2)}FeSeO")
|
|
559
|
+
"Li$_{\\mathrm{2}}$FeSeO"
|
|
560
|
+
>>> convert_label_shortcuts("H{sub(2)}O")
|
|
561
|
+
"H$_{\\mathrm{2}}$O"
|
|
562
|
+
"""
|
|
563
|
+
if not text:
|
|
564
|
+
return text
|
|
565
|
+
|
|
566
|
+
import re
|
|
567
|
+
|
|
568
|
+
# Convert {super(...)} to $^{\mathrm{...}}$ to prevent italic rendering
|
|
569
|
+
# Pattern matches {super(anything inside)}
|
|
570
|
+
# Use \mathrm{} to ensure non-italic rendering unless explicitly specified
|
|
571
|
+
# Need to escape backslashes in replacement string for LaTeX commands
|
|
572
|
+
text = re.sub(r'\{super\(([^)]+)\)\}', r'$^{\\mathrm{\1}}$', text)
|
|
573
|
+
|
|
574
|
+
# Convert {sub(...)} to $_{\mathrm{...}}$ to prevent italic rendering
|
|
575
|
+
# Pattern matches {sub(anything inside)}
|
|
576
|
+
# Use \mathrm{} to ensure non-italic rendering unless explicitly specified
|
|
577
|
+
# Need to escape backslashes in replacement string for LaTeX commands
|
|
578
|
+
text = re.sub(r'\{sub\(([^)]+)\)\}', r'$_{\\mathrm{\1}}$', text)
|
|
579
|
+
|
|
580
|
+
return text
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def normalize_label_text(text: str) -> str:
|
|
584
|
+
"""Normalize axis label text for proper matplotlib rendering.
|
|
585
|
+
|
|
586
|
+
Converts various representations of superscripts and special characters
|
|
587
|
+
into matplotlib-compatible LaTeX format. Primarily handles Angstrom units
|
|
588
|
+
with inverse exponents (Å⁻¹ → Å$^{-1}$).
|
|
589
|
+
|
|
590
|
+
Args:
|
|
591
|
+
text: Raw label text that may contain Unicode or LaTeX notation
|
|
592
|
+
|
|
593
|
+
Returns:
|
|
594
|
+
Normalized text with proper matplotlib math mode formatting
|
|
595
|
+
|
|
596
|
+
Example:
|
|
597
|
+
>>> normalize_label_text("Q (Å⁻¹)")
|
|
598
|
+
"Q (Å$^{-1}$)"
|
|
599
|
+
"""
|
|
600
|
+
if not text:
|
|
601
|
+
return text
|
|
602
|
+
|
|
603
|
+
# Convert Unicode superscript minus to LaTeX math mode
|
|
604
|
+
text = text.replace("Å⁻¹", "Å$^{-1}$")
|
|
605
|
+
# Handle various spacing variations
|
|
606
|
+
text = text.replace("Å ^-1", "Å$^{-1}$")
|
|
607
|
+
text = text.replace("Å^-1", "Å$^{-1}$")
|
|
608
|
+
# Handle LaTeX \AA command variations
|
|
609
|
+
text = text.replace(r"\AA⁻¹", r"\AA$^{-1}$")
|
|
610
|
+
|
|
611
|
+
return text
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _confirm_overwrite(path: str, auto_suffix: bool = True):
|
|
615
|
+
"""Ask user before overwriting an existing file.
|
|
616
|
+
|
|
617
|
+
Provides three behaviors depending on context:
|
|
618
|
+
1. File doesn't exist → return path as-is
|
|
619
|
+
2. Interactive terminal → ask user for confirmation or alternative filename
|
|
620
|
+
3. Non-interactive (pipe/script) → auto-append suffix to avoid overwrite
|
|
621
|
+
|
|
622
|
+
This prevents accidental data loss while allowing automation in scripts.
|
|
623
|
+
|
|
624
|
+
Args:
|
|
625
|
+
path: Full path to the file that might be overwritten
|
|
626
|
+
auto_suffix: If True, automatically add _1, _2, etc. in non-interactive mode
|
|
627
|
+
If False, return None to cancel in non-interactive mode
|
|
628
|
+
|
|
629
|
+
Returns:
|
|
630
|
+
- Path to use (original or modified)
|
|
631
|
+
- None to cancel the operation
|
|
632
|
+
|
|
633
|
+
Example:
|
|
634
|
+
>>> _confirm_overwrite('plot.svg')
|
|
635
|
+
# If file exists and user is interactive: prompts "Overwrite? [y/N]:"
|
|
636
|
+
# If file exists and running in script: returns 'plot_1.svg'
|
|
637
|
+
"""
|
|
638
|
+
try:
|
|
639
|
+
# If file doesn't exist, no confirmation needed
|
|
640
|
+
if not os.path.exists(path):
|
|
641
|
+
return path
|
|
642
|
+
|
|
643
|
+
# Check if running in non-interactive context (pipe, script, background)
|
|
644
|
+
if not sys.stdin.isatty():
|
|
645
|
+
# Non-interactive: can't ask user, so auto-suffix or cancel
|
|
646
|
+
if not auto_suffix:
|
|
647
|
+
return None
|
|
648
|
+
|
|
649
|
+
# Generate unique filename by appending _1, _2, etc.
|
|
650
|
+
base, ext = os.path.splitext(path)
|
|
651
|
+
k = 1
|
|
652
|
+
new_path = f"{base}_{k}{ext}"
|
|
653
|
+
# Keep incrementing until we find an unused name (max 1000 to prevent infinite loop)
|
|
654
|
+
while os.path.exists(new_path) and k < 1000:
|
|
655
|
+
k += 1
|
|
656
|
+
new_path = f"{base}_{k}{ext}"
|
|
657
|
+
return new_path
|
|
658
|
+
|
|
659
|
+
# Interactive mode: ask user what to do
|
|
660
|
+
ans = input(f"File '{path}' exists. Overwrite? [y/N]: ").strip().lower()
|
|
661
|
+
if ans == 'y':
|
|
662
|
+
return path
|
|
663
|
+
|
|
664
|
+
# User said no, ask for alternative filename
|
|
665
|
+
alt = input("Enter new filename (blank=cancel): ").strip()
|
|
666
|
+
if not alt:
|
|
667
|
+
# User wants to cancel
|
|
668
|
+
return None
|
|
669
|
+
|
|
670
|
+
# If user didn't provide extension, copy from original
|
|
671
|
+
if not os.path.splitext(alt)[1] and os.path.splitext(path)[1]:
|
|
672
|
+
alt += os.path.splitext(path)[1]
|
|
673
|
+
|
|
674
|
+
# Check if alternative also exists
|
|
675
|
+
if os.path.exists(alt):
|
|
676
|
+
print("Chosen alternative also exists; action canceled.")
|
|
677
|
+
return None
|
|
678
|
+
|
|
679
|
+
return alt
|
|
680
|
+
|
|
681
|
+
except Exception:
|
|
682
|
+
# If anything goes wrong (KeyboardInterrupt, etc.), just use original path
|
|
683
|
+
# Better to risk overwrite than crash
|
|
684
|
+
return path
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def choose_save_path(file_paths: list, purpose: str = "saving") -> Optional[str]:
|
|
688
|
+
"""Prompt user to choose a base directory for saving artifacts.
|
|
689
|
+
|
|
690
|
+
Always shows the current working directory and every unique directory that
|
|
691
|
+
contains an input file. The user can pick from the numbered list or type a
|
|
692
|
+
custom path manually. Returning ``None`` indicates the caller should cancel
|
|
693
|
+
the pending save/export operation.
|
|
694
|
+
|
|
695
|
+
Args:
|
|
696
|
+
file_paths: List of file paths associated with the current figure/session.
|
|
697
|
+
Only existing files contribute directory options.
|
|
698
|
+
purpose: Short description used in prompts (e.g., "figure export").
|
|
699
|
+
|
|
700
|
+
Returns:
|
|
701
|
+
Absolute path chosen by the user, or ``None`` if the selection
|
|
702
|
+
was canceled. Defaults to the current working directory if the
|
|
703
|
+
user simply presses Enter.
|
|
704
|
+
"""
|
|
705
|
+
try:
|
|
706
|
+
cwd = os.getcwd()
|
|
707
|
+
file_paths = file_paths or []
|
|
708
|
+
|
|
709
|
+
# Build ordered mapping of directories → input files originating there
|
|
710
|
+
dir_map = {}
|
|
711
|
+
for fpath in file_paths:
|
|
712
|
+
try:
|
|
713
|
+
if not fpath:
|
|
714
|
+
continue
|
|
715
|
+
abs_path = os.path.abspath(fpath)
|
|
716
|
+
if not os.path.exists(abs_path):
|
|
717
|
+
continue
|
|
718
|
+
fdir = os.path.dirname(abs_path)
|
|
719
|
+
if not fdir:
|
|
720
|
+
continue
|
|
721
|
+
dir_map.setdefault(fdir, [])
|
|
722
|
+
dir_map[fdir].append(os.path.basename(abs_path) or abs_path)
|
|
723
|
+
except Exception:
|
|
724
|
+
continue
|
|
725
|
+
|
|
726
|
+
cwd_files = dir_map.pop(cwd, [])
|
|
727
|
+
options = [{
|
|
728
|
+
'path': cwd,
|
|
729
|
+
'label': "Current directory (terminal)",
|
|
730
|
+
'files': cwd_files,
|
|
731
|
+
}]
|
|
732
|
+
for dir_path, files in sorted(dir_map.items()):
|
|
733
|
+
options.append({
|
|
734
|
+
'path': dir_path,
|
|
735
|
+
'label': "Input file directory",
|
|
736
|
+
'files': files,
|
|
737
|
+
})
|
|
738
|
+
|
|
739
|
+
print(f"\nSave location options for {purpose}:")
|
|
740
|
+
for idx, opt in enumerate(options, start=1):
|
|
741
|
+
extra = ""
|
|
742
|
+
if opt['files']:
|
|
743
|
+
preview = ", ".join(opt['files'][:2])
|
|
744
|
+
if len(opt['files']) > 2:
|
|
745
|
+
preview += ", ..."
|
|
746
|
+
extra = f" (input files: {preview})"
|
|
747
|
+
label = f"{opt['label']}: {opt['path']}"
|
|
748
|
+
print(f" {idx}. {label}{extra}")
|
|
749
|
+
print(" c. Custom path")
|
|
750
|
+
print(" q. Cancel (return to menu)")
|
|
751
|
+
|
|
752
|
+
max_choice = len(options)
|
|
753
|
+
while True:
|
|
754
|
+
try:
|
|
755
|
+
choice = input(f"Choose path for {purpose} (1-{max_choice}, Enter=1): ").strip()
|
|
756
|
+
except KeyboardInterrupt:
|
|
757
|
+
print("\nCanceled path selection.")
|
|
758
|
+
return None
|
|
759
|
+
|
|
760
|
+
if not choice:
|
|
761
|
+
return cwd
|
|
762
|
+
|
|
763
|
+
low = choice.lower()
|
|
764
|
+
if low == 'q':
|
|
765
|
+
print("Canceled path selection.")
|
|
766
|
+
return None
|
|
767
|
+
if low == 'c':
|
|
768
|
+
# Try to open folder picker dialog first
|
|
769
|
+
dialog_path = None
|
|
770
|
+
try:
|
|
771
|
+
dialog_path = _ask_directory_dialog(initialdir=cwd)
|
|
772
|
+
except Exception as e:
|
|
773
|
+
# Dialog failed - fall back to manual input
|
|
774
|
+
dialog_path = None
|
|
775
|
+
|
|
776
|
+
if dialog_path:
|
|
777
|
+
# User selected a folder via dialog
|
|
778
|
+
try:
|
|
779
|
+
os.makedirs(dialog_path, exist_ok=True)
|
|
780
|
+
return dialog_path
|
|
781
|
+
except Exception as e:
|
|
782
|
+
print(f"Could not use directory: {e}")
|
|
783
|
+
# Fall through to manual input
|
|
784
|
+
|
|
785
|
+
# Fallback to manual input if dialog unavailable or canceled
|
|
786
|
+
print("(Dialog unavailable or canceled, enter path manually)")
|
|
787
|
+
try:
|
|
788
|
+
manual = input("Enter directory path (q=cancel): ").strip()
|
|
789
|
+
except (KeyboardInterrupt, EOFError):
|
|
790
|
+
print("\nCanceled path selection.")
|
|
791
|
+
return None
|
|
792
|
+
if not manual or manual.lower() == 'q':
|
|
793
|
+
continue
|
|
794
|
+
manual_path = os.path.abspath(os.path.expanduser(manual))
|
|
795
|
+
try:
|
|
796
|
+
os.makedirs(manual_path, exist_ok=True)
|
|
797
|
+
except Exception as e:
|
|
798
|
+
print(f"Could not use directory: {e}")
|
|
799
|
+
continue
|
|
800
|
+
return manual_path
|
|
801
|
+
if choice.isdigit():
|
|
802
|
+
num = int(choice)
|
|
803
|
+
if 1 <= num <= max_choice:
|
|
804
|
+
return options[num - 1]['path']
|
|
805
|
+
print(f"Invalid number. Enter between 1 and {max_choice}.")
|
|
806
|
+
continue
|
|
807
|
+
# Treat any other input as a manual path entry
|
|
808
|
+
manual_path = os.path.abspath(os.path.expanduser(choice))
|
|
809
|
+
try:
|
|
810
|
+
os.makedirs(manual_path, exist_ok=True)
|
|
811
|
+
except Exception as e:
|
|
812
|
+
print(f"Could not use directory: {e}")
|
|
813
|
+
continue
|
|
814
|
+
return manual_path
|
|
815
|
+
except Exception as e:
|
|
816
|
+
print(f"Error in path selection: {e}. Using current directory.")
|
|
817
|
+
return os.getcwd()
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def ensure_exact_case_filename(target_path: str) -> str:
|
|
821
|
+
"""Ensure a file is saved with the exact case specified, even on case-insensitive filesystems.
|
|
822
|
+
|
|
823
|
+
This function handles case-insensitive filesystems (macOS, Windows) by ensuring that
|
|
824
|
+
if a file exists with different case, it is removed first so the new file can be created
|
|
825
|
+
with the exact case specified by the user.
|
|
826
|
+
|
|
827
|
+
On case-sensitive filesystems (Linux, Unix), this function is safe but has no effect
|
|
828
|
+
since files with different case are treated as different files.
|
|
829
|
+
|
|
830
|
+
Args:
|
|
831
|
+
target_path: The desired file path with exact case
|
|
832
|
+
|
|
833
|
+
Returns:
|
|
834
|
+
The same path (for compatibility)
|
|
835
|
+
"""
|
|
836
|
+
folder = os.path.dirname(target_path)
|
|
837
|
+
desired_basename = os.path.basename(target_path)
|
|
838
|
+
|
|
839
|
+
if not folder or not desired_basename:
|
|
840
|
+
return target_path
|
|
841
|
+
|
|
842
|
+
try:
|
|
843
|
+
# Check if file already exists with exact case
|
|
844
|
+
if os.path.exists(target_path):
|
|
845
|
+
# Check if the actual filename on disk matches the desired case
|
|
846
|
+
existing_files = os.listdir(folder)
|
|
847
|
+
for existing_file in existing_files:
|
|
848
|
+
# If same name (case-insensitive) but different case, we need to fix it
|
|
849
|
+
if existing_file.lower() == desired_basename.lower() and existing_file != desired_basename:
|
|
850
|
+
existing_path = os.path.join(folder, existing_file)
|
|
851
|
+
# Delete the existing file with wrong case
|
|
852
|
+
# This is safe on case-insensitive filesystems and has no effect on case-sensitive ones
|
|
853
|
+
try:
|
|
854
|
+
if os.path.exists(existing_path):
|
|
855
|
+
os.remove(existing_path)
|
|
856
|
+
except Exception:
|
|
857
|
+
# Ignore errors (e.g., permission issues, file in use)
|
|
858
|
+
pass
|
|
859
|
+
break
|
|
860
|
+
except Exception:
|
|
861
|
+
# If we can't check/list the directory, just return the path as-is
|
|
862
|
+
# This is safe and ensures we don't break on permission errors
|
|
863
|
+
pass
|
|
864
|
+
|
|
865
|
+
return target_path
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _normalize_extension(ext: str) -> str:
|
|
869
|
+
if not ext:
|
|
870
|
+
return ext
|
|
871
|
+
ext = ext.strip().lower()
|
|
872
|
+
if not ext.startswith('.'):
|
|
873
|
+
ext = '.' + ext
|
|
874
|
+
return ext
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def _has_valid_extension(filename: str, extensions: Tuple[str, ...]) -> bool:
|
|
878
|
+
name = filename.lower()
|
|
879
|
+
return any(name.endswith(ext) for ext in extensions)
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def choose_style_file(file_paths: List[str], purpose: str = "style import", extensions: Optional[Tuple[str, ...]] = None) -> Optional[str]:
|
|
883
|
+
"""Select a style file (.bps/.bpsg/.bpcfg) from known directories or via dialog."""
|
|
884
|
+
extensions = tuple(_normalize_extension(ext) for ext in (extensions or STYLE_FILE_EXTENSIONS))
|
|
885
|
+
if not extensions:
|
|
886
|
+
extensions = STYLE_FILE_EXTENSIONS
|
|
887
|
+
|
|
888
|
+
search_dirs: List[str] = []
|
|
889
|
+
seen_dirs = set()
|
|
890
|
+
|
|
891
|
+
def _add_dir(path: str):
|
|
892
|
+
if not path:
|
|
893
|
+
return
|
|
894
|
+
abs_path = os.path.abspath(path)
|
|
895
|
+
if abs_path in seen_dirs:
|
|
896
|
+
return
|
|
897
|
+
if os.path.isdir(abs_path):
|
|
898
|
+
seen_dirs.add(abs_path)
|
|
899
|
+
search_dirs.append(abs_path)
|
|
900
|
+
|
|
901
|
+
_add_dir(os.getcwd())
|
|
902
|
+
for fpath in file_paths or []:
|
|
903
|
+
try:
|
|
904
|
+
if not fpath:
|
|
905
|
+
continue
|
|
906
|
+
abs_path = os.path.abspath(fpath)
|
|
907
|
+
if not os.path.exists(abs_path):
|
|
908
|
+
continue
|
|
909
|
+
directory = os.path.dirname(abs_path)
|
|
910
|
+
_add_dir(directory)
|
|
911
|
+
except Exception:
|
|
912
|
+
continue
|
|
913
|
+
if not search_dirs:
|
|
914
|
+
search_dirs.append(os.getcwd())
|
|
915
|
+
|
|
916
|
+
style_candidates = []
|
|
917
|
+
seen_files = set()
|
|
918
|
+
|
|
919
|
+
def _collect_from_directory(directory: str):
|
|
920
|
+
if not os.path.isdir(directory):
|
|
921
|
+
return
|
|
922
|
+
try:
|
|
923
|
+
entries = sorted(os.listdir(directory))
|
|
924
|
+
except Exception:
|
|
925
|
+
return
|
|
926
|
+
for entry in entries:
|
|
927
|
+
full_path = os.path.join(directory, entry)
|
|
928
|
+
if not os.path.isfile(full_path):
|
|
929
|
+
continue
|
|
930
|
+
if not _has_valid_extension(entry, extensions):
|
|
931
|
+
continue
|
|
932
|
+
norm = os.path.abspath(full_path)
|
|
933
|
+
if norm in seen_files:
|
|
934
|
+
continue
|
|
935
|
+
seen_files.add(norm)
|
|
936
|
+
style_candidates.append({
|
|
937
|
+
'name': entry,
|
|
938
|
+
'path': norm,
|
|
939
|
+
'location': directory,
|
|
940
|
+
})
|
|
941
|
+
|
|
942
|
+
for base_dir in search_dirs:
|
|
943
|
+
_collect_from_directory(base_dir)
|
|
944
|
+
styles_dir = os.path.join(base_dir, 'Styles')
|
|
945
|
+
if styles_dir != base_dir:
|
|
946
|
+
_collect_from_directory(styles_dir)
|
|
947
|
+
|
|
948
|
+
print(f"\nSearching for style files for {purpose} in:")
|
|
949
|
+
for dir_path in search_dirs:
|
|
950
|
+
print(f" - {dir_path}")
|
|
951
|
+
def _format_file_timestamp(filepath: str) -> str:
|
|
952
|
+
"""Format file modification time for display."""
|
|
953
|
+
try:
|
|
954
|
+
mtime = os.path.getmtime(filepath)
|
|
955
|
+
return time.strftime("%Y-%m-%d %H:%M", time.localtime(mtime))
|
|
956
|
+
except Exception:
|
|
957
|
+
return ""
|
|
958
|
+
|
|
959
|
+
if style_candidates:
|
|
960
|
+
print("\nAvailable style files:")
|
|
961
|
+
for idx, cand in enumerate(style_candidates, start=1):
|
|
962
|
+
timestamp = _format_file_timestamp(cand['path'])
|
|
963
|
+
if timestamp:
|
|
964
|
+
print(f" {idx}. {cand['name']} ({timestamp}) (in {cand['location']})")
|
|
965
|
+
else:
|
|
966
|
+
print(f" {idx}. {cand['name']} (in {cand['location']})")
|
|
967
|
+
else:
|
|
968
|
+
print("\nNo style files found in scanned directories.")
|
|
969
|
+
|
|
970
|
+
search_locations = []
|
|
971
|
+
added_locations = set()
|
|
972
|
+
for base_dir in search_dirs:
|
|
973
|
+
if os.path.isdir(base_dir) and base_dir not in added_locations:
|
|
974
|
+
search_locations.append(base_dir)
|
|
975
|
+
added_locations.add(base_dir)
|
|
976
|
+
styles_dir = os.path.join(base_dir, 'Styles')
|
|
977
|
+
if os.path.isdir(styles_dir) and styles_dir not in added_locations:
|
|
978
|
+
search_locations.append(styles_dir)
|
|
979
|
+
added_locations.add(styles_dir)
|
|
980
|
+
|
|
981
|
+
def _resolve_manual_path(user_input: str) -> Optional[str]:
|
|
982
|
+
raw = os.path.expanduser(user_input.strip())
|
|
983
|
+
candidate_paths = []
|
|
984
|
+
if os.path.isabs(raw):
|
|
985
|
+
candidate_paths.append(os.path.abspath(raw))
|
|
986
|
+
else:
|
|
987
|
+
for loc in search_locations or [os.getcwd()]:
|
|
988
|
+
candidate_paths.append(os.path.abspath(os.path.join(loc, raw)))
|
|
989
|
+
resolved: List[str] = []
|
|
990
|
+
seen = set()
|
|
991
|
+
for cand in candidate_paths:
|
|
992
|
+
if cand not in seen:
|
|
993
|
+
seen.add(cand)
|
|
994
|
+
resolved.append(cand)
|
|
995
|
+
needs_ext = not _has_valid_extension(cand, extensions)
|
|
996
|
+
if needs_ext:
|
|
997
|
+
for ext in extensions:
|
|
998
|
+
if cand.lower().endswith(ext):
|
|
999
|
+
continue
|
|
1000
|
+
alt = cand + ext
|
|
1001
|
+
if alt not in seen:
|
|
1002
|
+
seen.add(alt)
|
|
1003
|
+
resolved.append(alt)
|
|
1004
|
+
for path in resolved:
|
|
1005
|
+
if os.path.isfile(path) and _has_valid_extension(path, extensions):
|
|
1006
|
+
return path
|
|
1007
|
+
return None
|
|
1008
|
+
|
|
1009
|
+
while True:
|
|
1010
|
+
try:
|
|
1011
|
+
choice = input("Select style file (number/path/c=custom/q=cancel): ").strip()
|
|
1012
|
+
except (KeyboardInterrupt, EOFError):
|
|
1013
|
+
print("\nStyle import canceled.")
|
|
1014
|
+
return None
|
|
1015
|
+
|
|
1016
|
+
if not choice:
|
|
1017
|
+
print("Style import canceled.")
|
|
1018
|
+
return None
|
|
1019
|
+
|
|
1020
|
+
low = choice.lower()
|
|
1021
|
+
if low == 'q':
|
|
1022
|
+
print("Style import canceled.")
|
|
1023
|
+
return None
|
|
1024
|
+
if low == 'c':
|
|
1025
|
+
dialog_path = _ask_file_dialog(initialdir=search_dirs[0], filetypes=extensions)
|
|
1026
|
+
if not dialog_path:
|
|
1027
|
+
print("No file selected.")
|
|
1028
|
+
continue
|
|
1029
|
+
dialog_path = os.path.abspath(dialog_path)
|
|
1030
|
+
if not os.path.isfile(dialog_path):
|
|
1031
|
+
print("Selected file does not exist.")
|
|
1032
|
+
continue
|
|
1033
|
+
if not _has_valid_extension(dialog_path, extensions):
|
|
1034
|
+
print("Selected file is not a recognized style file.")
|
|
1035
|
+
continue
|
|
1036
|
+
return dialog_path
|
|
1037
|
+
if choice.isdigit() and style_candidates:
|
|
1038
|
+
idx = int(choice)
|
|
1039
|
+
if 1 <= idx <= len(style_candidates):
|
|
1040
|
+
return style_candidates[idx - 1]['path']
|
|
1041
|
+
print("Invalid number. Try again.")
|
|
1042
|
+
continue
|
|
1043
|
+
path = _resolve_manual_path(choice)
|
|
1044
|
+
if path:
|
|
1045
|
+
return path
|
|
1046
|
+
print("File not found. Enter another value or use 'c' for custom dialog.")
|