python-template-project 0.1.20__py3-none-any.whl → 0.1.21__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.
@@ -17,5 +17,5 @@ __version__: str
17
17
  __version_tuple__: VERSION_TUPLE
18
18
  version_tuple: VERSION_TUPLE
19
19
 
20
- __version__ = version = '0.1.20'
21
- __version_tuple__ = version_tuple = (0, 1, 20)
20
+ __version__ = version = '0.1.21'
21
+ __version_tuple__ = version_tuple = (0, 1, 21)
@@ -16,6 +16,17 @@ except ImportError:
16
16
  SRTM_AVAILABLE = False
17
17
  srtm = None
18
18
 
19
+ # Optional fastkml import for KML reading
20
+ try:
21
+ from fastkml import kml, styles
22
+ from fastkml.features import Document, Folder, Placemark
23
+ from shapely.geometry import LineString, Point
24
+
25
+ KML_AVAILABLE = True
26
+ except ImportError:
27
+ KML_AVAILABLE = False
28
+ kml = styles = Folder = Placemark = Document = Point = LineString = None
29
+
19
30
  NAME = "python_template_project"
20
31
 
21
32
 
@@ -114,7 +125,9 @@ class BaseGPXProcessor:
114
125
  if self.output:
115
126
  output_path = Path(self.output)
116
127
  else:
117
- timestamp = datetime.now().strftime(f"{self.date_format}_%H%M")
128
+ timestamp = datetime.now().strftime(
129
+ f"{self.date_format}_%H%M%S"
130
+ ) # Added seconds for uniqueness
118
131
  output_path = Path.cwd() / f"gpx_processed_{timestamp}"
119
132
 
120
133
  output_path.mkdir(parents=True, exist_ok=True)
@@ -228,54 +241,56 @@ class BaseGPXProcessor:
228
241
  self.logger.debug(f"Full traceback:\n{traceback.format_exc()}")
229
242
  return track_points # Return original points if optimization fails
230
243
 
231
- def _get_gpx_files(self) -> list[Path]:
232
- """Get all GPX files from input (file, folder, or zip)."""
233
- gpx_files = []
234
- for input_path in self.input:
244
+ def _get_input_files(self) -> list[Path]:
245
+ """Get all GPX/KML files from input (file, folder, or zip)."""
246
+ input_files = []
247
+ for input_path_str in self.input:
235
248
  try:
236
- if not isinstance(input_path, Path):
237
- input_path = Path(input_path)
249
+ input_path = Path(input_path_str)
238
250
  self.logger.debug(f"Input path: {input_path.absolute()}")
239
251
 
240
252
  if input_path.is_file():
241
253
  if input_path.suffix.lower() == ".gpx":
242
- gpx_files.append(input_path)
254
+ input_files.append(input_path)
255
+ elif input_path.suffix.lower() == ".kml":
256
+ input_files.append(input_path)
243
257
  elif input_path.suffix.lower() == ".zip":
244
- gpx_files.extend(self._extract_gpx_from_zip(input_path))
258
+ input_files.extend(self._extract_gpx_kml_from_zip(input_path))
245
259
  elif input_path.is_dir():
246
- # Get all GPX files in directory
247
- gpx_files.extend(input_path.glob("*.gpx"))
260
+ # Get all GPX/KML files in directory
261
+ input_files.extend(input_path.glob("*.gpx"))
262
+ input_files.extend(input_path.glob("*.kml"))
248
263
 
249
- # Get GPX files from ZIP files in directory
264
+ # Get GPX/KML files from ZIP files in directory
250
265
  for zip_file in input_path.glob("*.zip"):
251
- gpx_files.extend(self._extract_gpx_from_zip(zip_file))
266
+ input_files.extend(self._extract_gpx_kml_from_zip(zip_file))
252
267
  except Exception as e:
253
268
  self.logger.error(f"Error processing input path {input_path}: {e}")
254
269
  self.logger.debug(f"Full traceback:\n{traceback.format_exc()}")
255
270
  continue
256
271
 
257
- return gpx_files
272
+ return input_files
258
273
 
259
- def _extract_gpx_from_zip(self, zip_path: Path) -> list[Path]:
260
- """Extract GPX files from ZIP archive to temporary location."""
261
- gpx_files = []
262
- temp_dir = Path.cwd() / "temp_gpx_extract"
274
+ def _extract_gpx_kml_from_zip(self, zip_path: Path) -> list[Path]:
275
+ """Extract GPX/KML files from ZIP archive to temporary location."""
276
+ extracted_files = []
277
+ temp_dir = Path.cwd() / "temp_extracted_files"
263
278
 
264
279
  try:
265
280
  temp_dir.mkdir(exist_ok=True)
266
281
 
267
282
  with zipfile.ZipFile(zip_path, "r") as zip_ref:
268
283
  for file_info in zip_ref.infolist():
269
- if file_info.filename.lower().endswith(".gpx"):
284
+ if file_info.filename.lower().endswith((".gpx", ".kml")):
270
285
  extracted_path = temp_dir / Path(file_info.filename).name
271
286
  with open(extracted_path, "wb") as f:
272
287
  f.write(zip_ref.read(file_info.filename))
273
- gpx_files.append(extracted_path)
288
+ extracted_files.append(extracted_path)
274
289
  except Exception as e:
275
290
  self.logger.error(f"Error extracting ZIP file {zip_path}: {e}")
276
291
  self.logger.debug(f"Full traceback:\n{traceback.format_exc()}")
277
292
 
278
- return gpx_files
293
+ return extracted_files
279
294
 
280
295
  def _load_gpx_file(self, gpx_path: Path) -> GPX | None:
281
296
  """Load and parse GPX file."""
@@ -290,35 +305,104 @@ class BaseGPXProcessor:
290
305
  self.logger.debug(f"Full traceback:\n{traceback.format_exc()}")
291
306
  return None
292
307
 
293
- def _save_gpx_file(self, gpx: GPX, output_path: Path, original_file: Path | None = None):
294
- """Save GPX object to file."""
308
+ def _load_kml_file(self, kml_path: Path) -> GPX | None:
309
+ """Load and parse KML file, converting it to a GPX object."""
310
+ if not KML_AVAILABLE:
311
+ self.logger.error("fastkml library is not available to process KML files.")
312
+ return None
313
+
314
+ try:
315
+ with open(kml_path, "r", encoding="utf-8") as f:
316
+ doc = f.read()
317
+
318
+ k = kml.KML()
319
+ k.from_string(doc)
320
+
321
+ gpx = gpxpy.gpx.GPX()
322
+
323
+ # Iterate through KML features and convert to GPX tracks/waypoints
324
+ for feature in k.features():
325
+ if isinstance(feature, Document) or isinstance(feature, Folder):
326
+ for sub_feature in feature.features():
327
+ self._process_kml_feature(sub_feature, gpx)
328
+ else:
329
+ self._process_kml_feature(feature, gpx)
330
+
331
+ self.logger.info(f"Successfully loaded and converted KML file {kml_path} to GPX.")
332
+ return gpx
333
+
334
+ except Exception as e:
335
+ self.logger.error(f"Error loading or converting KML file {kml_path}: {e}")
336
+ self.logger.debug(f"Full traceback:\n{traceback.format_exc()}")
337
+ return None
338
+
339
+ def _process_kml_feature(self, feature, gpx: GPX):
340
+ """Recursively process KML features to extract points and add to GPX."""
341
+ if isinstance(feature, Placemark):
342
+ if feature.geometry is not None:
343
+ if isinstance(feature.geometry, Point):
344
+ waypoint = gpxpy.gpx.GPXWaypoint(
345
+ latitude=feature.geometry.y,
346
+ longitude=feature.geometry.x,
347
+ elevation=feature.geometry.z if feature.geometry.has_z else None,
348
+ name=feature.name,
349
+ description=feature.description,
350
+ )
351
+ gpx.waypoints.append(waypoint)
352
+ elif isinstance(feature.geometry, LineString):
353
+ gpx_track = gpxpy.gpx.GPXTrack()
354
+ gpx_track.name = feature.name
355
+ gpx_segment = gpxpy.gpx.GPXTrackSegment()
356
+ for coord in feature.geometry.coords:
357
+ point = gpxpy.gpx.GPXTrackPoint(
358
+ latitude=coord[1],
359
+ longitude=coord[0],
360
+ elevation=coord[2] if len(coord) > 2 else None,
361
+ )
362
+ gpx_segment.points.append(point)
363
+ if gpx_segment.points:
364
+ gpx_track.segments.append(gpx_segment)
365
+ if gpx_track.segments:
366
+ gpx.tracks.append(gpx_track)
367
+ elif isinstance(feature, Document) or isinstance(feature, Folder):
368
+ for sub_feature in feature.features():
369
+ self._process_kml_feature(sub_feature, gpx)
370
+
371
+ def _save_gpx_file(
372
+ self, gpx: GPX, output_path: Path, original_file: Path | None = None
373
+ ) -> Path:
374
+ """Save GPX object to file and return the path."""
295
375
  try:
296
376
  with open(output_path, "w", encoding="utf-8") as f:
297
377
  f.write(gpx.to_xml())
298
378
  if original_file and original_file.exists():
299
379
  self.logger.info(
300
- f"Original gpx file size: "
301
- f"{Path(original_file).stat().st_size / 1024:.2f} KB"
380
+ f"Original file size: {Path(original_file).stat().st_size / 1024:.2f} KB"
302
381
  )
303
- self.logger.info(
304
- f"Processed gpx file size: {output_path.stat().st_size / 1024:.2f} KB"
305
- )
306
-
382
+ self.logger.info(f"Processed file size: {output_path.stat().st_size / 1024:.2f} KB")
383
+ return output_path
307
384
  except Exception as e:
308
385
  self.logger.error(f"Error saving GPX file {output_path}: {e}")
309
386
  self.logger.debug(f"Full traceback:\n{traceback.format_exc()}")
387
+ return None
310
388
 
311
- def compress_files(self):
312
- """Shrink the size of all given gpx files in self.input."""
389
+ def compress_files(self) -> list[str]:
390
+ """Shrink the size of all given gpx/kml files in self.input."""
391
+ generated_files = []
313
392
  try:
314
- gpx_files = self._get_gpx_files()
393
+ input_files = self._get_input_files()
315
394
  output_folder = self._get_output_folder()
316
395
 
317
- self.logger.info(f"Processing {len(gpx_files)} GPX files...")
396
+ self.logger.info(f"Processing {len(input_files)} GPX/KML files for compression...")
318
397
 
319
- for gpx_file in gpx_files:
398
+ for input_file in input_files:
320
399
  try:
321
- gpx = self._load_gpx_file(gpx_file)
400
+ gpx = None
401
+ if input_file.suffix.lower() == ".gpx":
402
+ gpx = self._load_gpx_file(input_file)
403
+ elif input_file.suffix.lower() == ".kml":
404
+ gpx = self._load_kml_file(input_file)
405
+
322
406
  if gpx is None:
323
407
  continue
324
408
 
@@ -340,14 +424,17 @@ class BaseGPXProcessor:
340
424
  gpx.extensions = None
341
425
 
342
426
  # Save compressed file
343
- output_path = output_folder / f"compressed_{gpx_file.name}"
344
- self._save_gpx_file(gpx, output_path, gpx_file)
345
- self.logger.info(f"Compressed: {gpx_file.name} -> {output_path}")
427
+ output_path = output_folder / f"compressed_{input_file.stem}.gpx"
428
+ saved_path = self._save_gpx_file(gpx, output_path, input_file)
429
+ if saved_path:
430
+ generated_files.append(str(saved_path))
431
+ self.logger.info(f"Compressed: {input_file.name} -> {output_path}")
346
432
 
347
433
  except Exception as e:
348
- self.logger.error(f"Error processing file {gpx_file}: {e}")
434
+ self.logger.error(f"Error processing file {input_file}: {e}")
349
435
  self.logger.debug(f"Full traceback:\n{traceback.format_exc()}")
350
436
  continue
437
+ return generated_files
351
438
 
352
439
  except Exception as e:
353
440
  self.logger.error(f"Error in compress_files: {e}")
@@ -383,41 +470,49 @@ class BaseGPXProcessor:
383
470
  self.logger.warning(f"Error optimizing waypoint: {e}")
384
471
  return waypoint
385
472
 
386
- def merge_files(self):
473
+ def merge_files(self) -> list[str]:
387
474
  """Merge all files of self.input into one gpx file with reduced resolution."""
475
+ generated_files = []
388
476
  try:
389
- gpx_files = self._get_gpx_files()
477
+ input_files = self._get_input_files()
390
478
  output_folder = self._get_output_folder()
391
479
 
392
- if not gpx_files:
393
- self.logger.error("No GPX files found to merge.")
394
- return
480
+ if not input_files:
481
+ self.logger.error("No GPX/KML files found to merge.")
482
+ return []
395
483
 
396
- self.logger.info(f"Merging {len(gpx_files)} GPX files...")
484
+ self.logger.info(f"Merging {len(input_files)} GPX/KML files...")
397
485
 
398
486
  # Create new GPX object
399
487
  merged_gpx = gpxpy.gpx.GPX()
400
488
  merged_gpx.name = "Merged GPX Tracks"
401
- merged_gpx.description = f"Merged from {len(gpx_files)} GPX files."
489
+ merged_gpx.description = f"Merged from {len(input_files)} GPX/KML files."
402
490
 
403
491
  track_counter = 1
404
492
 
405
- for gpx_file in gpx_files:
493
+ for input_file in input_files:
406
494
  try:
407
- gpx = self._load_gpx_file(gpx_file)
495
+ gpx = None
496
+ if input_file.suffix.lower() == ".gpx":
497
+ gpx = self._load_gpx_file(input_file)
498
+ elif input_file.suffix.lower() == ".kml":
499
+ gpx = self._load_kml_file(input_file)
500
+
408
501
  if gpx is None:
409
502
  continue
410
503
 
411
504
  # Add all waypoints from this file
412
505
  for waypoint in gpx.waypoints:
413
506
  waypoint = self._optimize_waypoint(waypoint)
414
- waypoint.name = f"{waypoint.name}_{track_counter}"
507
+ waypoint.name = f"{waypoint.name or 'Waypoint'}_{track_counter}"
415
508
  merged_gpx.waypoints.append(waypoint)
416
509
 
417
510
  # Add all tracks from this file
418
511
  for track in gpx.tracks:
419
512
  new_track = gpxpy.gpx.GPXTrack()
420
- new_track.name = f"{track.name or track_counter}_{gpx_file.stem}"
513
+ new_track.name = (
514
+ f"{track.name or 'Track'}_{input_file.stem}_{track_counter}"
515
+ )
421
516
 
422
517
  for segment in track.segments:
423
518
  optimized_points = self._optimize_track_points(segment.points)
@@ -433,7 +528,9 @@ class BaseGPXProcessor:
433
528
  # Add all routes from this file
434
529
  for route in gpx.routes:
435
530
  new_route = gpxpy.gpx.GPXRoute()
436
- new_route.name = f"{route.name or track_counter}_{gpx_file.stem}"
531
+ new_route.name = (
532
+ f"{route.name or 'Route'}_{input_file.stem}_{track_counter}"
533
+ )
437
534
  new_route.points = self._optimize_track_points(route.points)
438
535
 
439
536
  if new_route.points:
@@ -441,43 +538,52 @@ class BaseGPXProcessor:
441
538
  track_counter += 1
442
539
 
443
540
  except Exception as e:
444
- self.logger.error(f"Error processing file {gpx_file} during merge: {e}")
541
+ self.logger.error(f"Error processing file {input_file} during merge: {e}")
445
542
  self.logger.debug(f"Full traceback:\n{traceback.format_exc()}")
446
543
  continue
447
544
 
448
545
  # Save merged file
449
546
  output_path = output_folder / "merged_tracks.gpx"
450
- self._save_gpx_file(merged_gpx, output_path)
451
- self.logger.info(f"Merged file saved: {output_path}")
547
+ saved_path = self._save_gpx_file(merged_gpx, output_path)
548
+ if saved_path:
549
+ generated_files.append(str(saved_path))
550
+ self.logger.info(f"Merged file saved: {output_path}")
551
+ return generated_files
452
552
 
453
553
  except Exception as e:
454
554
  self.logger.error(f"Error in merge_files: {e}")
455
555
  self.logger.debug(f"Full traceback:\n{traceback.format_exc()}")
456
556
  raise
457
557
 
458
- def extract_pois(self):
558
+ def extract_pois(self) -> list[str]:
459
559
  """Merge every starting point of each track in all files
460
560
  into one gpx file with many pois."""
561
+ generated_files = []
461
562
  try:
462
- gpx_files = self._get_gpx_files()
563
+ input_files = self._get_input_files()
463
564
  output_folder = self._get_output_folder()
464
565
 
465
- if not gpx_files:
466
- self.logger.error("No GPX files found to extract POIs from.")
467
- return
566
+ if not input_files:
567
+ self.logger.error("No GPX/KML files found to extract POIs from.")
568
+ return []
468
569
 
469
- self.logger.info(f"Extracting POIs from {len(gpx_files)} GPX files...")
570
+ self.logger.info(f"Extracting POIs from {len(input_files)} GPX/KML files...")
470
571
 
471
572
  # Create new GPX object for waypoints
472
573
  poi_gpx = gpxpy.gpx.GPX()
473
574
  poi_gpx.name = "Extracted Track Starting Points"
474
- poi_gpx.description = f"Starting points extracted from {len(gpx_files)} GPX files"
575
+ poi_gpx.description = f"Starting points extracted from {len(input_files)} GPX/KML files"
475
576
 
476
577
  poi_counter = 1
477
578
 
478
- for gpx_file in gpx_files:
579
+ for input_file in input_files:
479
580
  try:
480
- gpx = self._load_gpx_file(gpx_file)
581
+ gpx = None
582
+ if input_file.suffix.lower() == ".gpx":
583
+ gpx = self._load_gpx_file(input_file)
584
+ elif input_file.suffix.lower() == ".kml":
585
+ gpx = self._load_kml_file(input_file)
586
+
481
587
  if gpx is None:
482
588
  continue
483
589
 
@@ -496,7 +602,9 @@ class BaseGPXProcessor:
496
602
 
497
603
  track_name = track.name or f"Track_{track_idx + 1}"
498
604
  waypoint.name = f"POI_{poi_counter:03d}"
499
- waypoint.description = f"Start of {track_name} from {gpx_file.name}"
605
+ waypoint.description = (
606
+ f"Start of {track_name} from {input_file.name}"
607
+ )
500
608
  waypoint.type = "Track Start"
501
609
 
502
610
  poi_gpx.waypoints.append(waypoint)
@@ -516,7 +624,7 @@ class BaseGPXProcessor:
516
624
 
517
625
  route_name = route.name or f"Route_{route_idx + 1}"
518
626
  waypoint.name = f"POI_{poi_counter:03d}"
519
- waypoint.description = f"Start of {route_name} from {gpx_file.name}"
627
+ waypoint.description = f"Start of {route_name} from {input_file.name}"
520
628
  waypoint.type = "Route Start"
521
629
 
522
630
  poi_gpx.waypoints.append(waypoint)
@@ -524,24 +632,27 @@ class BaseGPXProcessor:
524
632
 
525
633
  except Exception as e:
526
634
  self.logger.error(
527
- f"Error processing file {gpx_file} during POI extraction: {e}"
635
+ f"Error processing file {input_file} during POI extraction: {e}"
528
636
  )
529
637
  self.logger.debug(f"Full traceback:\n{traceback.format_exc()}")
530
638
  continue
531
639
 
532
640
  # Save POI file
533
641
  output_path = output_folder / "extracted_pois.gpx"
534
- self._save_gpx_file(poi_gpx, output_path)
535
- self.logger.info(
536
- f"POI file saved with {len(poi_gpx.waypoints)} waypoints: {output_path}"
537
- )
642
+ saved_path = self._save_gpx_file(poi_gpx, output_path)
643
+ if saved_path:
644
+ generated_files.append(str(saved_path))
645
+ self.logger.info(
646
+ f"POI file saved with {len(poi_gpx.waypoints)} waypoints: {output_path}"
647
+ )
538
648
 
539
649
  # Clean up temporary files
540
- temp_dir = Path.cwd() / "temp_gpx_extract"
650
+ temp_dir = Path.cwd() / "temp_extracted_files" # Changed folder name
541
651
  if temp_dir.exists():
542
652
  import shutil
543
653
 
544
654
  shutil.rmtree(temp_dir)
655
+ return generated_files
545
656
 
546
657
  except Exception as e:
547
658
  self.logger.error(f"Error in extract_pois: {e}")
@@ -7,12 +7,14 @@ run gui: python -m python_template_project.gui
7
7
  """
8
8
 
9
9
  import os
10
+ import subprocess
10
11
  import sys
11
12
  import threading
12
13
  import tkinter as tk
13
14
  import traceback
14
15
  import webbrowser
15
16
  from functools import partial
17
+ from pathlib import Path
16
18
  from tkinter import filedialog, messagebox, ttk
17
19
 
18
20
  from python_template_project.config.config import ConfigParameter, ConfigParameterManager
@@ -77,6 +79,7 @@ class GuiLogWriter:
77
79
  def __init__(self, text_widget):
78
80
  self.text_widget = text_widget
79
81
  self.root = text_widget.winfo_toplevel()
82
+ self.hyperlink_tags = {} # To store clickable links
80
83
 
81
84
  def write(self, text):
82
85
  """Write text to the widget in a thread-safe manner."""
@@ -86,13 +89,57 @@ class GuiLogWriter:
86
89
  def _update_text(self, text):
87
90
  """Update the text widget (must be called from main thread)."""
88
91
  try:
92
+ current_end = self.text_widget.index(tk.END)
89
93
  self.text_widget.insert(tk.END, text)
94
+
95
+ # Check for a directory path (simplified regex for common path formats)
96
+ # This regex looks for paths that start with a drive letter (C:\), a forward slash (/)
97
+ # or a backslash (\) followed by word characters, and ends with a word character.
98
+ # This is a basic approach; more robust path detection might be needed for edge cases.
99
+ import re
100
+
101
+ path_match = re.search(
102
+ r"([A-Za-z]:[\\/][\S ]*|[\\][\\/][\S ]*|[\w/.-]+[/][\S ]*)\b", text
103
+ )
104
+ if path_match:
105
+ path = path_match.group(0).strip()
106
+ # Ensure the path exists and is a directory to make it clickable
107
+ if Path(path).is_dir():
108
+ start_index = self.text_widget.search(path, current_end, tk.END)
109
+ if start_index:
110
+ end_index = f"{start_index}+{len(path)}c"
111
+ tag_name = f"link_{len(self.hyperlink_tags)}"
112
+ self.text_widget.tag_config(tag_name, foreground="blue", underline=True)
113
+ self.text_widget.tag_bind(
114
+ tag_name, "<Button-1>", lambda e, p=path: self._open_path_in_explorer(p)
115
+ )
116
+ self.text_widget.tag_bind(
117
+ tag_name, "<Enter>", lambda e: self.text_widget.config(cursor="hand2")
118
+ )
119
+ self.text_widget.tag_bind(
120
+ tag_name, "<Leave>", lambda e: self.text_widget.config(cursor="")
121
+ )
122
+ self.text_widget.tag_add(tag_name, start_index, end_index)
123
+ self.hyperlink_tags[tag_name] = path
124
+
90
125
  self.text_widget.see(tk.END)
91
126
  self.text_widget.update_idletasks()
92
127
  except tk.TclError:
93
128
  # Widget might be destroyed
94
129
  pass
95
130
 
131
+ def _open_path_in_explorer(self, path):
132
+ """Opens the given path in the file explorer."""
133
+ try:
134
+ if sys.platform == "win32":
135
+ os.startfile(path)
136
+ elif sys.platform == "darwin":
137
+ subprocess.Popen(["open", path])
138
+ else:
139
+ subprocess.Popen(["xdg-open", path])
140
+ except Exception as e:
141
+ get_logger("gui.main").error(f"Failed to open path {path}: {e}")
142
+
96
143
  def flush(self):
97
144
  """Flush method for compatibility."""
98
145
  pass
@@ -320,7 +367,7 @@ class MainGui:
320
367
  def __init__(self, root):
321
368
  self.root = root
322
369
  self.root.title("python-template-project")
323
- self.root.geometry("800x600")
370
+ self.root.geometry("1200x600") # Increased width for new layout
324
371
 
325
372
  # Initialize configuration
326
373
  self.config_manager = ConfigParameterManager()
@@ -329,8 +376,9 @@ class MainGui:
329
376
  self.logger_manager = initialize_logging(self.config_manager)
330
377
  self.logger = get_logger("gui.main")
331
378
 
332
- # File list
333
- self.file_list = []
379
+ # File lists
380
+ self.input_files = []
381
+ self.output_files = []
334
382
 
335
383
  self._build_widgets()
336
384
  self._create_menu()
@@ -348,35 +396,55 @@ class MainGui:
348
396
  """Build the main GUI widgets."""
349
397
  # Main container
350
398
  main_frame = ttk.Frame(self.root)
351
- main_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
399
+ main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
352
400
 
353
- # Top frame for file list and run button
401
+ # Top frame for file lists and buttons
354
402
  top_frame = ttk.Frame(main_frame)
355
403
  top_frame.pack(fill=tk.BOTH, expand=True)
356
404
 
357
- # Left side - File list
358
- file_frame = ttk.LabelFrame(top_frame, text="Input Files")
359
- file_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 5))
405
+ # Left side - Input File list
406
+ input_file_frame = ttk.LabelFrame(top_frame, text="Input Files")
407
+ input_file_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 5))
360
408
 
361
- # File listbox with scrollbar
362
- listbox_frame = ttk.Frame(file_frame)
363
- listbox_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
409
+ self.input_file_listbox = tk.Listbox(input_file_frame, selectmode=tk.EXTENDED)
410
+ input_file_scrollbar = ttk.Scrollbar(
411
+ input_file_frame, orient="vertical", command=self.input_file_listbox.yview
412
+ )
413
+ self.input_file_listbox.configure(yscrollcommand=input_file_scrollbar.set)
364
414
 
365
- self.file_listbox = tk.Listbox(listbox_frame)
366
- file_scrollbar = ttk.Scrollbar(
367
- listbox_frame, orient="vertical", command=self.file_listbox.yview
415
+ self.input_file_listbox.pack(side="left", fill="both", expand=True, padx=5, pady=5)
416
+ input_file_scrollbar.pack(side="right", fill="y", pady=5)
417
+ self.input_file_listbox.bind(
418
+ "<Double-Button-1>", lambda event: self._open_selected_file(event, self.input_files)
368
419
  )
369
- self.file_listbox.configure(yscrollcommand=file_scrollbar.set)
370
420
 
371
- self.file_listbox.pack(side="left", fill="both", expand=True)
372
- file_scrollbar.pack(side="right", fill="y")
421
+ # Middle - Output File list
422
+ output_file_frame = ttk.LabelFrame(top_frame, text="Generated Files")
423
+ output_file_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(5, 5))
373
424
 
374
- # Right side - Run button and controls
425
+ self.output_file_listbox = tk.Listbox(output_file_frame)
426
+ output_file_scrollbar = ttk.Scrollbar(
427
+ output_file_frame, orient="vertical", command=self.output_file_listbox.yview
428
+ )
429
+ self.output_file_listbox.configure(yscrollcommand=output_file_scrollbar.set)
430
+
431
+ self.output_file_listbox.pack(side="left", fill="both", expand=True, padx=5, pady=5)
432
+ output_file_scrollbar.pack(side="right", fill="y", pady=5)
433
+ self.output_file_listbox.bind(
434
+ "<Double-Button-1>", lambda event: self._open_selected_file(event, self.output_files)
435
+ )
436
+
437
+ # Right side - Buttons
375
438
  button_frame = ttk.Frame(top_frame)
376
439
  button_frame.pack(side=tk.RIGHT, fill=tk.Y, padx=(5, 0))
377
440
 
378
441
  open_button = ttk.Button(button_frame, text="Open Files", command=self._open_files)
379
- open_button.pack(pady=8)
442
+ open_button.pack(pady=8, fill=tk.X)
443
+
444
+ remove_selected_button = ttk.Button(
445
+ button_frame, text="Remove Selected", command=self._remove_selected_input_files
446
+ )
447
+ remove_selected_button.pack(pady=1, fill=tk.X)
380
448
 
381
449
  # Create buttons dynamically
382
450
  self.run_buttons = {}
@@ -384,13 +452,20 @@ class MainGui:
384
452
  button = ttk.Button(
385
453
  button_frame, text=label, command=partial(self._run_processing, mode=mode)
386
454
  )
387
- button.pack(pady=1)
455
+ button.pack(pady=1, fill=tk.X)
388
456
  # Save buttons in dictionary for later access
389
457
  self.run_buttons[mode] = button
390
458
 
391
459
  # Clear files button
392
- self.clear_button = ttk.Button(button_frame, text="Clear Files", command=self._clear_files)
393
- self.clear_button.pack(pady=8)
460
+ self.clear_input_button = ttk.Button(
461
+ button_frame, text="Clear Input Files", command=self._clear_input_files
462
+ )
463
+ self.clear_input_button.pack(pady=8, fill=tk.X)
464
+
465
+ self.clear_output_button = ttk.Button(
466
+ button_frame, text="Clear Generated Files", command=self._clear_output_files
467
+ )
468
+ self.clear_output_button.pack(pady=1, fill=tk.X)
394
469
 
395
470
  # Progress bar
396
471
  self.progress = ttk.Progressbar(button_frame, mode="indeterminate")
@@ -481,57 +556,159 @@ class MainGui:
481
556
  self.log_text.delete(1.0, tk.END)
482
557
  self.logger.debug("Log display cleared")
483
558
 
484
- def _clear_files(self):
485
- """Clear the file list."""
486
- self.file_list.clear()
487
- self.file_listbox.delete(0, tk.END)
488
- self.logger.info("File list cleared")
559
+ def _clear_input_files(self):
560
+ """Clear the input file list."""
561
+ self.input_files.clear()
562
+ self.input_file_listbox.delete(0, tk.END)
563
+ self.logger.info("Input file list cleared")
564
+
565
+ def _clear_output_files(self):
566
+ """Clear the output file list."""
567
+ self.output_files.clear()
568
+ self.output_file_listbox.delete(0, tk.END)
569
+ self.logger.info("Generated file list cleared")
570
+
571
+ def _remove_selected_input_files(self):
572
+ """Remove selected files from the input file list."""
573
+ selected_indices = self.input_file_listbox.curselection()
574
+ if not selected_indices:
575
+ messagebox.showwarning("Warning", "No files selected to remove!")
576
+ return
577
+
578
+ # Delete from listbox from end to start to avoid index issues
579
+ for i in reversed(selected_indices):
580
+ self.input_file_listbox.delete(i)
581
+ del self.input_files[i]
582
+ self.logger.info(f"Removed {len(selected_indices)} selected input files.")
583
+
584
+ def _open_selected_file(self, event, file_list_source):
585
+ """Opens the selected file in the system's default application or explorer."""
586
+ selection_index = event.widget.nearest(event.y)
587
+ if selection_index == -1: # No item clicked
588
+ return
589
+
590
+ file_path_str = file_list_source[selection_index]["path"]
591
+ file_path = Path(file_path_str)
592
+
593
+ if not file_path.exists():
594
+ self.logger.error(f"File not found: {file_path}")
595
+ messagebox.showerror("Error", f"File not found: {file_path}")
596
+ return
597
+
598
+ try:
599
+ if sys.platform == "win32":
600
+ os.startfile(file_path)
601
+ elif sys.platform == "darwin":
602
+ subprocess.Popen(["open", file_path])
603
+ else:
604
+ subprocess.Popen(["xdg-open", file_path])
605
+ self.logger.info(f"Opened file: {file_path}")
606
+ except Exception as e:
607
+ self.logger.error(f"Could not open file {file_path}: {e}")
608
+ messagebox.showerror("Error", f"Could not open file {file_path}: {e}")
489
609
 
490
610
  def _open_files(self):
491
611
  """Open file dialog and add files to list."""
492
612
  files = filedialog.askopenfilenames(
493
- title="Select input files", filetypes=[("All files", "*.*"), ("Text files", "*.txt")]
613
+ title="Select input files",
614
+ filetypes=[
615
+ ("GPX/KML files", "*.gpx *.kml *.zip"), # Added KML support
616
+ ("GPX files", "*.gpx"),
617
+ ("KML files", "*.kml"),
618
+ ("ZIP files", "*.zip"),
619
+ ("All files", "*.*"),
620
+ ],
494
621
  )
495
622
 
496
623
  new_files = 0
497
- for file in files:
498
- if file not in self.file_list:
499
- self.file_list.append(file)
500
- self.file_listbox.insert(tk.END, os.path.basename(file))
501
- new_files += 1
624
+ for file_path_str in files:
625
+ file_path = Path(file_path_str)
626
+ if file_path_str not in [f["path"] for f in self.input_files]:
627
+ try:
628
+ file_size_kb = file_path.stat().st_size / 1024
629
+ self.input_files.append({"path": file_path_str, "size": file_size_kb})
630
+ self.input_file_listbox.insert(
631
+ tk.END, f"{file_path.name} ({file_size_kb:.2f} KB)"
632
+ )
633
+ new_files += 1
634
+ except Exception as e:
635
+ self.logger.warning(f"Could not get size for {file_path_str}: {e}")
636
+ self.input_files.append({"path": file_path_str, "size": 0})
637
+ self.input_file_listbox.insert(tk.END, f"{file_path.name} (N/A KB)")
502
638
 
503
639
  if new_files > 0:
504
640
  self.logger.info(f"Added {new_files} new files to processing list")
505
641
  else:
506
642
  self.logger.debug("No new files selected")
507
643
 
644
+ def _update_output_listbox(self, generated_files_info):
645
+ """Updates the output file listbox with newly generated files."""
646
+ self.output_file_listbox.delete(0, tk.END) # Clear current list
647
+ self.output_files.clear() # Clear internal list
648
+ for file_path_str in generated_files_info:
649
+ file_path = Path(file_path_str)
650
+ try:
651
+ file_size_kb = file_path.stat().st_size / 1024
652
+ self.output_files.append({"path": file_path_str, "size": file_size_kb})
653
+ self.output_file_listbox.insert(tk.END, f"{file_path.name} ({file_size_kb:.2f} KB)")
654
+ except Exception as e:
655
+ self.logger.warning(f"Could not get size for generated file {file_path_str}: {e}")
656
+ self.output_files.append({"path": file_path_str, "size": 0})
657
+ self.output_file_listbox.insert(tk.END, f"{file_path.name} (N/A KB)")
658
+
659
+ if generated_files_info:
660
+ output_dir = Path(generated_files_info[0]).parent
661
+ self.logger.info(f"Generated files saved in: {output_dir}") # Log directory
662
+
508
663
  def _run_processing(self, mode="compress_files"):
509
664
  """Run the processing in a separate thread."""
510
- if not self.file_list:
511
- self.logger.warning("No input files selected")
512
- messagebox.showwarning("Warning", "No input files selected!")
665
+ selected_indices = self.input_file_listbox.curselection()
666
+ files_to_process = []
667
+
668
+ if selected_indices:
669
+ for i in selected_indices:
670
+ files_to_process.append(self.input_files[i]["path"])
671
+ else:
672
+ files_to_process = [f["path"] for f in self.input_files]
673
+
674
+ if not files_to_process:
675
+ self.logger.warning("No input files selected or all are deselected.")
676
+ messagebox.showwarning("Warning", "No input files selected or all are deselected!")
513
677
  return
514
678
 
515
- self.logger.info(f"Starting processing of {len(self.file_list)} files in mode: {mode}")
679
+ self.logger.info(f"Starting processing of {len(files_to_process)} files in mode: {mode}")
516
680
 
517
- # Alle Buttons während der Verarbeitung deaktivieren
681
+ # Disable all buttons during processing
518
682
  for button in self.run_buttons.values():
519
683
  button.config(state="disabled")
684
+ self.clear_input_button.config(state="disabled")
685
+ self.clear_output_button.config(state="disabled")
520
686
  self.progress.start()
521
687
 
522
688
  # Run in separate thread to avoid blocking GUI
523
- thread = threading.Thread(target=self._process_files, args=(mode,), daemon=True)
689
+ thread = threading.Thread(
690
+ target=self._process_files,
691
+ args=(
692
+ mode,
693
+ files_to_process,
694
+ ),
695
+ daemon=True,
696
+ )
524
697
  thread.start()
525
698
 
526
- def _process_files(self, mode="compress_files"):
699
+ def _process_files(self, mode="compress_files", files_to_process=None):
527
700
  """Process the selected files."""
701
+ generated_files_paths = []
528
702
  try:
529
703
  self.logger.info("=== Processing Started ===")
530
704
  self.logger.info("Processing files...")
531
705
 
706
+ if files_to_process is None:
707
+ files_to_process = [] # Should not happen with the check in _run_processing
708
+
532
709
  # Create and run project
533
710
  project = BaseGPXProcessor(
534
- self.file_list,
711
+ files_to_process, # Pass selected files
535
712
  self.config_manager.cli.output.default,
536
713
  self.config_manager.cli.min_dist.default,
537
714
  self.config_manager.app.date_format.default,
@@ -540,18 +717,19 @@ class MainGui:
540
717
  )
541
718
  # implement switch case for different processing modes
542
719
  if mode == "compress_files":
543
- project.compress_files()
720
+ generated_files_paths = project.compress_files()
544
721
  elif mode == "merge_files":
545
- project.merge_files()
722
+ generated_files_paths = project.merge_files()
546
723
  elif mode == "extract_pois":
547
- project.extract_pois()
724
+ generated_files_paths = project.extract_pois()
548
725
  else:
549
726
  self.logger.warning(f"Unknown mode: {mode}")
550
727
 
551
- self.logger.info(f"Completed: {len(self.file_list)} files processed")
552
-
728
+ self.logger.info(f"Completed: {len(files_to_process)} files processed")
553
729
  self.logger.info("=== All files processed successfully! ===")
554
730
 
731
+ self.root.after(0, self._update_output_listbox, generated_files_paths)
732
+
555
733
  except Exception as err:
556
734
  self.logger.error(f"Processing failed: {err}", exc_info=True)
557
735
  # Show error dialog in main thread
@@ -567,7 +745,8 @@ class MainGui:
567
745
  """Re-enable controls after processing is finished."""
568
746
  for button in self.run_buttons.values():
569
747
  button.config(state="normal")
570
- self.clear_button.config(state="normal")
748
+ self.clear_input_button.config(state="normal")
749
+ self.clear_output_button.config(state="normal")
571
750
  self.progress.stop()
572
751
 
573
752
  def _open_settings(self):
@@ -1,14 +1,16 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-template-project
3
- Version: 0.1.20
3
+ Version: 0.1.21
4
4
  Summary: Feature-rich Python project template designed for robustness and ease of use.
5
5
  Author: pamagister
6
6
  Requires-Python: <3.12,>=3.10
7
7
  Description-Content-Type: text/markdown
8
8
  License-File: LICENSE
9
+ Requires-Dist: fastkml>=1.1.0
9
10
  Requires-Dist: gpxpy>=1.6.2
10
11
  Requires-Dist: pydantic>=2.11.7
11
12
  Requires-Dist: pyyaml>=6.0.2
13
+ Requires-Dist: shapely>=2.1.1
12
14
  Requires-Dist: srtm-py>=0.3.7
13
15
  Provides-Extra: dev
14
16
  Requires-Dist: pytest>=8.4.0; extra == "dev"
@@ -3,21 +3,21 @@ firewall_handler.py,sha256=qT0dInkeKHulpCQD2x3HH7_lMxz4lwdkIPxEvBSl3No,7336
3
3
  main.py,sha256=d6ZpnMxcPBf860gQmuYwPVJg1di6r2CR6gSvFLkAZkk,4448
4
4
  python_template_project/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
5
  python_template_project/__main__.py,sha256=JS28LiHoU9Xo4c_RvyLBTDKHqdZRRytJLj6qyrg26mQ,195
6
- python_template_project/_version.py,sha256=HrX7HW5_qgG2kKtggMCOAOnIAM_IUpfjsr4EaS77COI,513
6
+ python_template_project/_version.py,sha256=b9rzYHmy3i_G_wlsa53qvXrAz8O-mxRK23uXogBfZws,513
7
7
  python_template_project/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
8
  python_template_project/cli/__main__.py,sha256=qnGkoNNbhnvLTA24X8YTEDxRqDbNEAzgjp9Ki_JYIhs,184
9
9
  python_template_project/cli/cli.py,sha256=Eumz1XfcBnpI4Yt1tn-TNG_VHLCvrDFREdx-6nI-sNY,6974
10
10
  python_template_project/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
11
  python_template_project/config/config.py,sha256=naSOpbmMXZQfAoOrZuEPYhv1_9TKJtpxjRKBVzCKUlE,17924
12
12
  python_template_project/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
- python_template_project/core/base.py,sha256=qZiQzA9ib-bxH-f3p9Rxzj8DD6nQQnhykd_Gqg4B3a4,22886
13
+ python_template_project/core/base.py,sha256=Cl4fgT7ME_HZ8T_MVv5Rki9_xyG7kTsC-aJP8HJ3OMc,28017
14
14
  python_template_project/core/logging.py,sha256=D99ZAXY9KZvmV7oVlAy3S3fuDf9G7vHvIk277xB7068,6944
15
15
  python_template_project/gui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
16
  python_template_project/gui/__main__.py,sha256=5AaiBxNCAKWcuSQFcQ_lsEQfEsXm6BA5-i5MScdf30c,184
17
- python_template_project/gui/gui.py,sha256=9xX36BK9iqBT67uiJmBAuqU1bupB5r_ceInljZgGBtI,21725
18
- python_template_project-0.1.20.dist-info/licenses/LICENSE,sha256=awOCsWJ58m_2kBQwBUGWejVqZm6wuRtCL2hi9rfa0X4,1211
19
- python_template_project-0.1.20.dist-info/METADATA,sha256=Mayn93-AieIAY7qPk36OnMRnVWxjYoVR5ENTQ-ZCOmU,5059
20
- python_template_project-0.1.20.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
21
- python_template_project-0.1.20.dist-info/entry_points.txt,sha256=LmgMiqxIVRdD7gkktVBBR0CR4AhkugqlgifuglkPhtI,148
22
- python_template_project-0.1.20.dist-info/top_level.txt,sha256=O-Yg0tzEwgH58MhdcYWpJsgZSUPg6SBbOUwlmVHZo3A,55
23
- python_template_project-0.1.20.dist-info/RECORD,,
17
+ python_template_project/gui/gui.py,sha256=0dNsWHvLu0l8lP-PNIHGUDjM0UM-aW_iN6uCJfo9fNw,30404
18
+ python_template_project-0.1.21.dist-info/licenses/LICENSE,sha256=awOCsWJ58m_2kBQwBUGWejVqZm6wuRtCL2hi9rfa0X4,1211
19
+ python_template_project-0.1.21.dist-info/METADATA,sha256=CtgqqcU_vyydu5fI-hWmbZyE6kLJXKM19CQNwhL5jRM,5119
20
+ python_template_project-0.1.21.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
21
+ python_template_project-0.1.21.dist-info/entry_points.txt,sha256=LmgMiqxIVRdD7gkktVBBR0CR4AhkugqlgifuglkPhtI,148
22
+ python_template_project-0.1.21.dist-info/top_level.txt,sha256=O-Yg0tzEwgH58MhdcYWpJsgZSUPg6SBbOUwlmVHZo3A,55
23
+ python_template_project-0.1.21.dist-info/RECORD,,