pygpt-net 2.6.67__py3-none-any.whl → 2.7.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.
Files changed (69) hide show
  1. pygpt_net/CHANGELOG.txt +12 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/controller/assistant/assistant.py +13 -8
  4. pygpt_net/controller/assistant/batch.py +29 -15
  5. pygpt_net/controller/assistant/files.py +19 -14
  6. pygpt_net/controller/assistant/store.py +63 -41
  7. pygpt_net/controller/attachment/attachment.py +45 -35
  8. pygpt_net/controller/chat/attachment.py +50 -39
  9. pygpt_net/controller/config/field/dictionary.py +26 -14
  10. pygpt_net/controller/ctx/common.py +27 -17
  11. pygpt_net/controller/ctx/ctx.py +182 -101
  12. pygpt_net/controller/files/files.py +101 -41
  13. pygpt_net/controller/idx/indexer.py +87 -31
  14. pygpt_net/controller/kernel/kernel.py +13 -2
  15. pygpt_net/controller/mode/mode.py +3 -3
  16. pygpt_net/controller/model/editor.py +70 -15
  17. pygpt_net/controller/model/importer.py +153 -54
  18. pygpt_net/controller/painter/painter.py +2 -2
  19. pygpt_net/controller/presets/experts.py +68 -15
  20. pygpt_net/controller/presets/presets.py +72 -36
  21. pygpt_net/controller/settings/profile.py +76 -35
  22. pygpt_net/controller/settings/workdir.py +70 -39
  23. pygpt_net/core/assistants/files.py +20 -18
  24. pygpt_net/core/filesystem/actions.py +111 -10
  25. pygpt_net/core/filesystem/filesystem.py +2 -1
  26. pygpt_net/core/idx/idx.py +12 -11
  27. pygpt_net/core/idx/worker.py +13 -1
  28. pygpt_net/core/models/models.py +4 -4
  29. pygpt_net/core/profile/profile.py +13 -3
  30. pygpt_net/data/config/config.json +3 -3
  31. pygpt_net/data/config/models.json +3 -3
  32. pygpt_net/data/css/style.dark.css +39 -1
  33. pygpt_net/data/css/style.light.css +39 -1
  34. pygpt_net/data/locale/locale.de.ini +3 -1
  35. pygpt_net/data/locale/locale.en.ini +3 -1
  36. pygpt_net/data/locale/locale.es.ini +3 -1
  37. pygpt_net/data/locale/locale.fr.ini +3 -1
  38. pygpt_net/data/locale/locale.it.ini +3 -1
  39. pygpt_net/data/locale/locale.pl.ini +4 -2
  40. pygpt_net/data/locale/locale.uk.ini +3 -1
  41. pygpt_net/data/locale/locale.zh.ini +3 -1
  42. pygpt_net/provider/api/openai/__init__.py +4 -2
  43. pygpt_net/provider/core/config/patch.py +9 -1
  44. pygpt_net/tools/image_viewer/tool.py +17 -0
  45. pygpt_net/tools/text_editor/tool.py +9 -0
  46. pygpt_net/ui/__init__.py +2 -2
  47. pygpt_net/ui/layout/ctx/ctx_list.py +16 -6
  48. pygpt_net/ui/main.py +3 -1
  49. pygpt_net/ui/widget/calendar/select.py +3 -3
  50. pygpt_net/ui/widget/filesystem/explorer.py +1082 -142
  51. pygpt_net/ui/widget/lists/assistant.py +185 -24
  52. pygpt_net/ui/widget/lists/assistant_store.py +245 -42
  53. pygpt_net/ui/widget/lists/attachment.py +230 -47
  54. pygpt_net/ui/widget/lists/attachment_ctx.py +189 -33
  55. pygpt_net/ui/widget/lists/base_list_combo.py +2 -2
  56. pygpt_net/ui/widget/lists/context.py +1253 -70
  57. pygpt_net/ui/widget/lists/experts.py +110 -8
  58. pygpt_net/ui/widget/lists/model_editor.py +217 -14
  59. pygpt_net/ui/widget/lists/model_importer.py +125 -6
  60. pygpt_net/ui/widget/lists/preset.py +460 -71
  61. pygpt_net/ui/widget/lists/profile.py +149 -27
  62. pygpt_net/ui/widget/lists/uploaded.py +230 -38
  63. pygpt_net/ui/widget/option/combo.py +1046 -32
  64. pygpt_net/ui/widget/option/dictionary.py +35 -7
  65. {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.0.dist-info}/METADATA +14 -57
  66. {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.0.dist-info}/RECORD +69 -69
  67. {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.0.dist-info}/LICENSE +0 -0
  68. {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.0.dist-info}/WHEEL +0 -0
  69. {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.0.dist-info}/entry_points.txt +0 -0
@@ -6,12 +6,12 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.09.26 13:00:00 #
9
+ # Updated Date: 2025.12.28 04:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import os
13
13
  from pathlib import Path
14
- from typing import Optional, Dict, Any
14
+ from typing import Optional, Dict, Any, Union
15
15
 
16
16
  from PySide6.QtCore import Slot, QTimer
17
17
  from PySide6.QtGui import QAction
@@ -173,6 +173,8 @@ class Profile:
173
173
  self.setup()
174
174
  self.initialized = True
175
175
  if not self.dialog or force:
176
+ self.update_menu()
177
+ self.update_list()
176
178
  self.window.ui.dialogs.open(
177
179
  'profile.editor',
178
180
  width=self.width,
@@ -336,18 +338,26 @@ class Profile:
336
338
  uuid = self.get_id_by_idx(idx)
337
339
  self.switch(uuid)
338
340
 
339
- def delete_by_idx(self, idx: int, force: bool = False):
341
+ def delete_by_idx(self, idx: Union[int, list], force: bool = False):
340
342
  """
341
343
  Delete profile by index
342
344
 
343
- :param idx: profile index
345
+ :param idx: profile index or list of indexes
344
346
  :param force: force delete
345
347
  """
346
- uuid = self.get_id_by_idx(idx)
348
+ uuids = []
349
+ ids = idx if isinstance(idx, list) else [idx]
350
+ for i in ids:
351
+ uuid = self.get_id_by_idx(i)
352
+ uuids.append(uuid)
347
353
  current = self.window.core.config.profile.get_current()
348
- if uuid == current:
349
- self.window.ui.dialogs.alert(trans("dialog.profile.alert.delete.current"))
350
- return
354
+ for uuid in list(uuids):
355
+ if uuid == current:
356
+ if len(uuids) == 1:
357
+ self.window.ui.dialogs.alert(trans("dialog.profile.alert.delete.current"))
358
+ return
359
+ else:
360
+ uuids.remove(uuid) # skip current
351
361
  if not force:
352
362
  self.window.ui.dialogs.confirm(
353
363
  type='profile.delete',
@@ -355,35 +365,48 @@ class Profile:
355
365
  msg=trans('confirm.profile.delete'),
356
366
  )
357
367
  return
358
- self.delete(uuid)
368
+ self.delete(uuids)
359
369
 
360
- def delete(self, uuid: str):
370
+ def delete(self, uuid: Union[str, list]):
361
371
  """
362
372
  Delete profile (remove only)
363
373
 
364
- :param uuid: profile ID
374
+ :param uuid: profile ID or list of IDs
365
375
  """
366
376
  profiles = self.get_profiles()
367
- if uuid in profiles:
368
- profile = profiles[uuid]
369
- name = profile['name']
370
- if self.window.core.config.profile.remove(uuid):
371
- self.window.update_status(trans("dialog.profile.status.removed") + ": " + name)
372
- self.update_list()
373
- self.update_menu()
377
+ updated = False
378
+ ids = uuid if isinstance(uuid, list) else [uuid]
379
+ for uuid in ids:
380
+ if uuid in profiles:
381
+ profile = profiles[uuid]
382
+ name = profile['name']
383
+ if self.window.core.config.profile.remove(uuid):
384
+ updated = True
385
+ self.window.update_status(trans("dialog.profile.status.removed") + ": " + name)
386
+ if updated:
387
+ self.update_list()
388
+ self.update_menu()
374
389
 
375
- def delete_all_by_idx(self, idx: int, force: bool = False):
390
+ def delete_all_by_idx(self, idx: Union[int, list], force: bool = False):
376
391
  """
377
392
  Delete profile with files by index
378
393
 
379
- :param idx: profile index
394
+ :param idx: profile index or list of indexes
380
395
  :param force: force delete
381
396
  """
382
- uuid = self.get_id_by_idx(idx)
397
+ uuids = []
398
+ ids = idx if isinstance(idx, list) else [idx]
399
+ for i in ids:
400
+ uuid = self.get_id_by_idx(i)
401
+ uuids.append(uuid)
383
402
  current = self.window.core.config.profile.get_current()
384
- if uuid == current:
385
- self.window.ui.dialogs.alert(trans("dialog.profile.alert.delete.current"))
386
- return
403
+ for uuid in list(uuids):
404
+ if uuid == current:
405
+ if len(uuids) == 1:
406
+ self.window.ui.dialogs.alert(trans("dialog.profile.alert.delete.current"))
407
+ return
408
+ else:
409
+ uuids.remove(uuid) # skip current
387
410
  if not force:
388
411
  self.window.ui.dialogs.confirm(
389
412
  type='profile.delete.all',
@@ -391,7 +414,7 @@ class Profile:
391
414
  msg=trans('confirm.profile.delete_all'),
392
415
  )
393
416
  return
394
- self.delete_all(uuid)
417
+ self.delete_all(uuids)
395
418
 
396
419
  def duplicate_by_idx(self, idx: int):
397
420
  """
@@ -416,13 +439,20 @@ class Profile:
416
439
  dialog.prepare()
417
440
  dialog.show()
418
441
 
419
- def delete_all(self, uuid: str):
442
+ def delete_all(self, uuid: Union[str, list]):
420
443
  """
421
444
  Delete profile with files
422
445
 
423
- :param uuid: profile ID
446
+ :param uuid: profile ID or list of IDs
424
447
  """
425
- self.window.controller.settings.workdir.delete_files(uuid)
448
+ ids = uuid if isinstance(uuid, list) else [uuid]
449
+ batch = False
450
+ if len(ids) > 1:
451
+ batch = True
452
+ if not batch:
453
+ self.window.controller.settings.workdir.delete_files(uuid)
454
+ else:
455
+ self.window.controller.settings.workdir.delete_files(ids, batch=batch)
426
456
 
427
457
  @Slot(str)
428
458
  def after_delete(self, name: str):
@@ -468,22 +498,33 @@ class Profile:
468
498
  if self.window.ui.nodes['dialog.profile.checkbox.switch'].isChecked():
469
499
  self.switch(uuid, force=True)
470
500
 
471
- def reset(self, uuid: str):
501
+ def reset(self, uuid: Union[str, list]):
472
502
  """
473
503
  Reset profile
474
504
 
475
- :param uuid: profile ID
505
+ :param uuid: profile ID or list of IDs
476
506
  """
477
- self.window.controller.settings.workdir.reset(uuid)
507
+ ids = uuid if isinstance(uuid, list) else [uuid]
508
+ batch = False
509
+ if len(ids) > 1:
510
+ batch = True
511
+ if not batch:
512
+ self.window.controller.settings.workdir.reset(uuid)
513
+ else:
514
+ self.window.controller.settings.workdir.reset(ids, batch=batch)
478
515
 
479
- def reset_by_idx(self, idx: int, force: bool = False):
516
+ def reset_by_idx(self, idx: Union[int, list], force: bool = False):
480
517
  """
481
518
  Reset profile by index
482
519
 
483
- :param idx: profile index
520
+ :param idx: profile index or list of indexes
484
521
  :param force: force reset
485
522
  """
486
- uuid = self.get_id_by_idx(idx)
523
+ uuids = []
524
+ ids = idx if isinstance(idx, list) else [idx]
525
+ for i in ids:
526
+ uuid = self.get_id_by_idx(i)
527
+ uuids.append(uuid)
487
528
  if not force:
488
529
  self.window.ui.dialogs.confirm(
489
530
  type='profile.reset',
@@ -491,7 +532,7 @@ class Profile:
491
532
  msg=trans('confirm.profile.reset'),
492
533
  )
493
534
  return
494
- self.reset(uuid)
535
+ self.reset(uuids)
495
536
 
496
537
  def is_include_db(self):
497
538
  """Get include db"""
@@ -6,13 +6,13 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.09.26 13:00:00 #
9
+ # Updated Date: 2025.12.28 04:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import copy
13
13
  import os
14
14
  from pathlib import Path
15
- from typing import Optional
15
+ from typing import Optional, Union
16
16
  from uuid import uuid4
17
17
 
18
18
  from PySide6.QtCore import QObject, QRunnable, Signal, Slot
@@ -50,9 +50,10 @@ class WorkdirWorker(QRunnable):
50
50
  force: bool = False,
51
51
  current: Optional[str] = None,
52
52
  profile_name: Optional[str] = None,
53
- profile_uuid: Optional[str] = None,
53
+ profile_uuid: Optional[Union[str, list]] = None,
54
54
  profile_new_name: Optional[str] = None,
55
55
  profile_new_path: Optional[str] = None,
56
+ batch: bool = False,
56
57
  ):
57
58
  super().__init__()
58
59
  self.window = window
@@ -65,6 +66,7 @@ class WorkdirWorker(QRunnable):
65
66
  self.profile_new_name = profile_new_name
66
67
  self.profile_new_path = profile_new_path
67
68
  self.signals = WorkerSignals()
69
+ self.batch = batch
68
70
 
69
71
  @Slot()
70
72
  def run(self):
@@ -104,22 +106,34 @@ class WorkdirWorker(QRunnable):
104
106
  profiles = self.window.controller.settings.profile.get_profiles()
105
107
  remove_datadir = True
106
108
  remove_db = True
107
- if uuid in profiles:
108
- profile = profiles[uuid]
109
- name = profile['name']
110
- path = profile['workdir'].replace("%HOME%", str(Path.home()))
111
- # remove profile
112
- if self.window.core.config.profile.remove(uuid):
113
- if not os.path.exists(path) or not os.path.isdir(path):
114
- self.signals.alert.emit(trans("dialog.profile.alert.path.not_exists"))
115
- return
116
- print(f"Clearing workdir: {path}")
117
- self.window.core.filesystem.clear_workdir(
118
- path,
119
- remove_db=remove_db,
120
- remove_datadir=remove_datadir,
121
- )
122
- self.signals.deleted.emit(name)
109
+ uuids = uuid if isinstance(uuid, list) else [uuid]
110
+ for uuid in uuids:
111
+ if uuid in profiles:
112
+ profile = profiles[uuid]
113
+ name = profile['name']
114
+ path = profile['workdir'].replace("%HOME%", str(Path.home()))
115
+ # remove profile
116
+ if self.window.core.config.profile.remove(uuid):
117
+ if not os.path.exists(path) or not os.path.isdir(path):
118
+ if not self.batch:
119
+ self.signals.deleted.emit(name)
120
+ self.signals.alert.emit(trans("dialog.profile.alert.path.not_exists"))
121
+ return
122
+ else:
123
+ print("Directory not exists, ignoring: ", path)
124
+ self.signals.updateGlobalStatus.emit(f"Directory not exists: {path}")
125
+ self.signals.deleted.emit(name)
126
+ return # nothing to delete
127
+ print(f"Clearing workdir: {path}")
128
+ try:
129
+ self.window.core.filesystem.clear_workdir(
130
+ path,
131
+ remove_db=remove_db,
132
+ remove_datadir=remove_datadir,
133
+ )
134
+ except Exception as e:
135
+ print("Error deleting profile files: ", e)
136
+ self.signals.deleted.emit(name)
123
137
 
124
138
  def worker_duplicate(self):
125
139
  """Duplicate profile"""
@@ -168,22 +182,30 @@ class WorkdirWorker(QRunnable):
168
182
  current = self.window.core.config.profile.get_current()
169
183
  remove_datadir = False
170
184
  remove_db = True
171
- if uuid in profiles:
172
- profile = profiles[uuid]
173
- path = profile['workdir'].replace("%HOME%", str(Path.home()))
174
- if not os.path.exists(path) or not os.path.isdir(path):
175
- self.signals.alert.emit(f"Directory not exists: {path}")
176
- return
177
- print("Clearing workdir: ", path)
178
- self.window.core.db.close()
179
- self.window.core.filesystem.clear_workdir(
180
- path,
181
- remove_db=remove_db,
182
- remove_datadir=remove_datadir,
183
- )
184
- if uuid == current:
185
- self.signals.switch.emit(uuid) # switch to profile
186
- self.signals.updateGlobalStatus.emit(f"Profile cleared: {profile['name']}")
185
+ uuids = uuid if isinstance(uuid, list) else [uuid]
186
+ for uuid in uuids:
187
+ if uuid in profiles:
188
+ profile = profiles[uuid]
189
+ path = profile['workdir'].replace("%HOME%", str(Path.home()))
190
+ if not os.path.exists(path) or not os.path.isdir(path):
191
+ if not self.batch:
192
+ self.signals.alert.emit(f"Directory not exists: {path}")
193
+ return
194
+ else:
195
+ print("Directory not exists, ignoring: ", path)
196
+ self.signals.updateGlobalStatus.emit(f"Directory not exists: {path}")
197
+ return
198
+ print("Clearing workdir: ", path)
199
+ if uuid == current:
200
+ self.window.core.db.close()
201
+ self.window.core.filesystem.clear_workdir(
202
+ path,
203
+ remove_db=remove_db,
204
+ remove_datadir=remove_datadir,
205
+ )
206
+ if uuid == current and not self.batch:
207
+ self.signals.switch.emit(uuid) # switch to profile
208
+ self.signals.updateGlobalStatus.emit(f"Profile cleared: {profile['name']}")
187
209
 
188
210
  def worker_migrate(self):
189
211
  """Migrate working directory"""
@@ -471,9 +493,10 @@ class Workdir:
471
493
  force: bool = False,
472
494
  current: str = None,
473
495
  profile_name: Optional[str] = None,
474
- profile_uuid: Optional[str] = None,
496
+ profile_uuid: Optional[Union[str, list]] = None,
475
497
  profile_new_name: Optional[str] = None,
476
498
  profile_new_path: Optional[str] = None,
499
+ batch: bool = False,
477
500
  ):
478
501
  """
479
502
  Run action in thread
@@ -483,9 +506,10 @@ class Workdir:
483
506
  :param force: force action (confirm)
484
507
  :param current: current working directory (for restore action)
485
508
  :param profile_name: profile name (optional, for after update callback)
486
- :param profile_uuid: profile UUID (optional, for delete files action)
509
+ :param profile_uuid: profile UUID (optional, for delete files action) or list of UUIDs
487
510
  :param profile_new_name: new profile name (optional, for duplicate action)
488
511
  :param profile_new_path: new profile path (optional, for duplicate action)
512
+ :param batch: if True, run in batch mode (no UI updates)
489
513
  """
490
514
  worker = WorkdirWorker(
491
515
  window=self.window,
@@ -497,6 +521,7 @@ class Workdir:
497
521
  profile_uuid=profile_uuid,
498
522
  profile_new_name=profile_new_name,
499
523
  profile_new_path=profile_new_path,
524
+ batch=batch,
500
525
  )
501
526
 
502
527
  # connect signals
@@ -569,16 +594,19 @@ class Workdir:
569
594
 
570
595
  def delete_files(
571
596
  self,
572
- profile_uuid: str
597
+ profile_uuid: Union[str, list],
598
+ batch: bool = False
573
599
  ):
574
600
  """
575
601
  Delete files and directories associated with the profile
576
602
 
577
603
  :param profile_uuid: profile UUID
604
+ :param batch: if True, run in batch mode (no UI updates)
578
605
  """
579
606
  self.run_action(
580
607
  action="delete",
581
608
  profile_uuid=profile_uuid,
609
+ batch=batch,
582
610
  )
583
611
 
584
612
  def duplicate(
@@ -603,14 +631,17 @@ class Workdir:
603
631
 
604
632
  def reset(
605
633
  self,
606
- profile_uuid: str
634
+ profile_uuid: Union[str, list],
635
+ batch: bool = False
607
636
  ):
608
637
  """
609
638
  Reset profile
610
639
 
611
640
  :param profile_uuid: profile UUID to reset
641
+ :param batch: if True, run in batch mode (no UI updates)
612
642
  """
613
643
  self.run_action(
614
644
  action="reset",
615
645
  profile_uuid=profile_uuid,
646
+ batch=batch,
616
647
  )
@@ -6,10 +6,10 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2024.12.14 08:00:00 #
9
+ # Updated Date: 2025.12.27 00:00:00 #
10
10
  # ================================================== #
11
11
 
12
- from typing import Optional, List, Dict
12
+ from typing import Optional, List, Dict, Union
13
13
 
14
14
  from packaging.version import Version
15
15
 
@@ -231,30 +231,32 @@ class Files:
231
231
  """
232
232
  return self.provider.get_all_by_file_id(file_id)
233
233
 
234
- def delete(self, file: AssistantFileItem) -> bool:
234
+ def delete(self, file: Union[AssistantFileItem, list]) -> bool:
235
235
  """
236
236
  Delete file and remove from vector stores if exists
237
237
 
238
238
  :param file: file item
239
239
  :return: True if file was deleted
240
240
  """
241
- file_id = file.file_id
242
- items = self.get_all_by_file_id(file_id) # get store_ids
243
- for id in items:
244
- store_id = items[id].store_id
245
- if store_id is None or store_id == "":
246
- continue # skip if no store_id
241
+ files = file if isinstance(file, list) else [file]
242
+ for file in files:
243
+ file_id = file.file_id
244
+ items = self.get_all_by_file_id(file_id) # get store_ids
245
+ for id in items:
246
+ store_id = items[id].store_id
247
+ if store_id is None or store_id == "":
248
+ continue # skip if no store_id
249
+ try:
250
+ self.window.core.api.openai.store.delete_store_file(store_id, file_id) # remove from vector store
251
+ except Exception as e:
252
+ self.window.core.debug.log("Failed to delete file from vector store: " + str(e))
253
+ self.provider.delete_by_id(file.record_id) # delete file in DB
247
254
  try:
248
- self.window.core.api.openai.store.delete_store_file(store_id, file_id) # remove from vector store
255
+ self.window.core.api.openai.store.delete_file(file.file_id) # delete file in API
249
256
  except Exception as e:
250
- self.window.core.debug.log("Failed to delete file from vector store: " + str(e))
251
- self.provider.delete_by_id(file.record_id) # delete file in DB
252
- try:
253
- self.window.core.api.openai.store.delete_file(file.file_id) # delete file in API
254
- except Exception as e:
255
- self.window.core.debug.log("Failed to delete remote file: " + str(e))
256
- if file.record_id in self.items:
257
- del self.items[file.record_id]
257
+ self.window.core.debug.log("Failed to delete remote file: " + str(e))
258
+ if file.record_id in self.items:
259
+ del self.items[file.record_id]
258
260
  return True
259
261
 
260
262
  def delete_by_file_id(self, file_id: str) -> bool:
@@ -6,11 +6,11 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.08.24 23:00:00 #
9
+ # Updated Date: 2025.12.27 17:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import os
13
- from typing import List
13
+ from typing import List, Union
14
14
 
15
15
  from PySide6.QtGui import QAction, QIcon
16
16
  from PySide6.QtWidgets import QWidget
@@ -27,16 +27,22 @@ class Actions:
27
27
  """
28
28
  self.window = window
29
29
 
30
- def has_preview(self, path: str) -> bool:
30
+ def has_preview(self, path: Union[str, list]) -> bool:
31
31
  """
32
32
  Check if file has preview action
33
33
 
34
- :param path: path to file
34
+ :param path: path to file or list of paths
35
35
  :return: True if file has preview
36
36
  """
37
- if os.path.isdir(path):
37
+ if isinstance(path, list):
38
+ for p in path:
39
+ if not os.path.isdir(p):
40
+ return True # allow preview for any file in the list
38
41
  return False
39
- return True
42
+ else:
43
+ if os.path.isdir(path):
44
+ return False
45
+ return True
40
46
 
41
47
  def has_use(self, path: str) -> bool:
42
48
  """
@@ -48,14 +54,93 @@ class Actions:
48
54
  return (self.window.core.filesystem.types.is_image(path)
49
55
  or self.window.core.filesystem.types.is_video(path))
50
56
 
51
- def get_preview(self, parent: QWidget, path: str) -> List[QAction]:
57
+ def get_preview_batch(self, parent: QWidget, path: list) -> List[QAction]:
58
+ """
59
+ Get preview actions for multiple files
60
+
61
+ :param parent: explorer widget
62
+ :param path: list of paths
63
+ :return: list of context menu actions
64
+ """
65
+ actions = []
66
+ paths_video = []
67
+ paths_image = []
68
+ paths_edit = []
69
+ for p in path:
70
+ if os.path.isdir(p):
71
+ continue
72
+ actions = []
73
+ if (self.window.core.filesystem.types.is_video(p)
74
+ or self.window.core.filesystem.types.is_audio(p)):
75
+ paths_video.append(p)
76
+ elif self.window.core.filesystem.types.is_image(p):
77
+ paths_image.append(p)
78
+ else:
79
+ extra_excluded = ["pdf", "docx", "doc", "xlsx", "xls", "pptx", "ppt"]
80
+ ext = os.path.splitext(p)[1][1:].lower()
81
+ if ext not in self.window.core.filesystem.types.get_excluded_extensions() + extra_excluded:
82
+ paths_edit.append(p)
83
+
84
+ # video/audio - single action for first file
85
+ if paths_video:
86
+ p = paths_video[0] # single action for first video
87
+ action = QAction(
88
+ QIcon(":/icons/video.svg"),
89
+ trans('action.video.play'),
90
+ parent,
91
+ )
92
+ action.triggered.connect(
93
+ lambda: self.window.tools.get("player").play(p),
94
+ )
95
+ actions.append(action)
96
+ action = QAction(
97
+ QIcon(":/icons/hearing.svg"),
98
+ trans('action.video.transcribe'),
99
+ parent,
100
+ )
101
+ action.triggered.connect(
102
+ lambda: self.window.tools.get("transcriber").from_file(p),
103
+ )
104
+ actions.append(action)
105
+
106
+ # image - batch preview
107
+ if paths_image:
108
+ action = QAction(
109
+ QIcon(":/icons/image.svg"),
110
+ trans('action.preview'),
111
+ parent,
112
+ )
113
+ action.triggered.connect(
114
+ lambda: self.window.tools.get("viewer").open_preview_batch(paths_image),
115
+ )
116
+ actions.append(action)
117
+
118
+ # edit - batch edit
119
+ if paths_edit:
120
+ action = QAction(
121
+ QIcon(":/icons/edit.svg"),
122
+ trans('action.edit'),
123
+ parent,
124
+ )
125
+ action.triggered.connect(
126
+ lambda: self.window.tools.get("editor").open_batch(paths_edit),
127
+ )
128
+ actions.append(action)
129
+
130
+ return actions
131
+
132
+
133
+ def get_preview(self, parent: QWidget, path: Union[str, list]) -> List[QAction]:
52
134
  """
53
135
  Get preview actions for context menu
54
136
 
55
137
  :param parent: explorer widget
56
- :param path: path to file
138
+ :param path: path to file or list of paths
57
139
  :return: list of context menu actions
58
140
  """
141
+ if isinstance(path, list):
142
+ return self.get_preview_batch(parent, path)
143
+
59
144
  actions = []
60
145
  if (self.window.core.filesystem.types.is_video(path)
61
146
  or self.window.core.filesystem.types.is_audio(path)):
@@ -102,14 +187,30 @@ class Actions:
102
187
  actions.append(action)
103
188
  return actions
104
189
 
105
- def get_use(self, parent: QWidget, path: str) -> List[QAction]:
190
+ def get_use_batch(self, parent: QWidget, path: list) -> List[QAction]:
191
+ """
192
+ Get use actions for multiple files
193
+
194
+ :param parent: explorer widget
195
+ :param path: list of paths
196
+ :return: list of context menu actions
197
+ """
198
+ actions = []
199
+ for p in path:
200
+ actions.extend(self.get_use(parent, p))
201
+ return actions
202
+
203
+ def get_use(self, parent: QWidget, path: Union[str, list]) -> List[QAction]:
106
204
  """
107
205
  Get use actions for context menu
108
206
 
109
207
  :param parent: explorer widget
110
- :param path: path to file
208
+ :param path: path to file or list of paths
111
209
  :return: list of context menu actions
112
210
  """
211
+ if isinstance(path, list):
212
+ return self.get_use_batch(parent, path)
213
+
113
214
  actions = []
114
215
  if self.window.core.filesystem.types.is_image(path):
115
216
  action = QAction(
@@ -429,7 +429,8 @@ class Filesystem:
429
429
  os.remove(item_path)
430
430
  else:
431
431
  if item not in excluded_dirs:
432
- shutil.rmtree(item_path)
432
+ if os.path.exists(item_path) and os.path.isdir(item_path):
433
+ shutil.rmtree(item_path)
433
434
 
434
435
  return True
435
436