x-ipe 1.0.22__py3-none-any.whl → 1.0.24__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.
- x_ipe/app.py +9 -0
- x_ipe/routes/ideas_routes.py +272 -0
- x_ipe/routes/proxy_routes.py +8 -2
- x_ipe/routes/uiux_feedback_routes.py +20 -0
- x_ipe/services/ideas_service.py +516 -2
- x_ipe/services/proxy_service.py +33 -7
- x_ipe/services/uiux_feedback_service.py +116 -1
- x_ipe/static/css/terminal.css +22 -0
- x_ipe/static/css/uiux-feedback.css +7 -1
- x_ipe/static/css/workplace.css +616 -0
- x_ipe/static/js/features/confirm-dialog.js +169 -0
- x_ipe/static/js/features/folder-view.js +742 -0
- x_ipe/static/js/features/tree-drag.js +227 -0
- x_ipe/static/js/features/tree-search.js +224 -0
- x_ipe/static/js/features/workplace.js +473 -28
- x_ipe/static/js/terminal-v2.js +45 -14
- x_ipe/static/js/terminal.js +50 -49
- x_ipe/static/js/uiux-feedback.js +57 -14
- x_ipe/templates/base.html +5 -0
- x_ipe/templates/index.html +3 -0
- {x_ipe-1.0.22.dist-info → x_ipe-1.0.24.dist-info}/METADATA +1 -1
- {x_ipe-1.0.22.dist-info → x_ipe-1.0.24.dist-info}/RECORD +25 -22
- x_ipe/app.py.bak +0 -1333
- {x_ipe-1.0.22.dist-info → x_ipe-1.0.24.dist-info}/WHEEL +0 -0
- {x_ipe-1.0.22.dist-info → x_ipe-1.0.24.dist-info}/entry_points.txt +0 -0
- {x_ipe-1.0.22.dist-info → x_ipe-1.0.24.dist-info}/licenses/LICENSE +0 -0
x_ipe/services/ideas_service.py
CHANGED
|
@@ -155,6 +155,71 @@ class IdeasService:
|
|
|
155
155
|
'files_uploaded': uploaded_files
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
def create_folder(self, folder_name: str, parent_folder: str = None) -> Dict[str, Any]:
|
|
159
|
+
"""
|
|
160
|
+
Create an empty folder in ideas directory.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
folder_name: Name for the new folder
|
|
164
|
+
parent_folder: Optional parent folder path (relative to ideas root)
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Dict with success, folder_name, folder_path or error
|
|
168
|
+
"""
|
|
169
|
+
# Validate folder name
|
|
170
|
+
folder_name = folder_name.strip()
|
|
171
|
+
is_valid, error = self._validate_folder_name(folder_name)
|
|
172
|
+
if not is_valid:
|
|
173
|
+
return {
|
|
174
|
+
'success': False,
|
|
175
|
+
'error': error
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
# Determine base path
|
|
179
|
+
if parent_folder:
|
|
180
|
+
# Strip 'x-ipe-docs/ideas/' prefix if present
|
|
181
|
+
if parent_folder.startswith(self.IDEAS_PATH + '/'):
|
|
182
|
+
parent_folder = parent_folder[len(self.IDEAS_PATH) + 1:]
|
|
183
|
+
elif parent_folder.startswith(self.IDEAS_PATH):
|
|
184
|
+
parent_folder = parent_folder[len(self.IDEAS_PATH):]
|
|
185
|
+
|
|
186
|
+
base_path = self.ideas_root / parent_folder
|
|
187
|
+
if not base_path.exists():
|
|
188
|
+
return {
|
|
189
|
+
'success': False,
|
|
190
|
+
'error': f"Parent folder '{parent_folder}' does not exist"
|
|
191
|
+
}
|
|
192
|
+
else:
|
|
193
|
+
base_path = self.ideas_root
|
|
194
|
+
|
|
195
|
+
# Ensure ideas root exists
|
|
196
|
+
self.ideas_root.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
|
|
198
|
+
# Generate unique name if folder exists
|
|
199
|
+
final_name = self._generate_unique_name(folder_name, base_path)
|
|
200
|
+
|
|
201
|
+
# Create the folder
|
|
202
|
+
folder_path = base_path / final_name
|
|
203
|
+
try:
|
|
204
|
+
folder_path.mkdir(parents=True, exist_ok=True)
|
|
205
|
+
except OSError as e:
|
|
206
|
+
return {
|
|
207
|
+
'success': False,
|
|
208
|
+
'error': f'Failed to create folder: {str(e)}'
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
# Build relative path for response
|
|
212
|
+
if parent_folder:
|
|
213
|
+
relative_path = f'{self.IDEAS_PATH}/{parent_folder}/{final_name}'
|
|
214
|
+
else:
|
|
215
|
+
relative_path = f'{self.IDEAS_PATH}/{final_name}'
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
'success': True,
|
|
219
|
+
'folder_name': final_name,
|
|
220
|
+
'folder_path': relative_path
|
|
221
|
+
}
|
|
222
|
+
|
|
158
223
|
def rename_folder(self, old_name: str, new_name: str) -> Dict[str, Any]:
|
|
159
224
|
"""
|
|
160
225
|
Rename an idea folder.
|
|
@@ -312,15 +377,22 @@ class IdeasService:
|
|
|
312
377
|
|
|
313
378
|
return (True, None)
|
|
314
379
|
|
|
315
|
-
def _generate_unique_name(self, base_name: str) -> str:
|
|
380
|
+
def _generate_unique_name(self, base_name: str, base_path: Path = None) -> str:
|
|
316
381
|
"""
|
|
317
382
|
Generate unique folder name if base_name exists.
|
|
318
383
|
Appends (2), (3), etc. until unique.
|
|
384
|
+
|
|
385
|
+
Args:
|
|
386
|
+
base_name: Base name for the folder
|
|
387
|
+
base_path: Optional base path to check existence (defaults to ideas_root)
|
|
319
388
|
"""
|
|
389
|
+
if base_path is None:
|
|
390
|
+
base_path = self.ideas_root
|
|
391
|
+
|
|
320
392
|
name = base_name
|
|
321
393
|
counter = 2
|
|
322
394
|
|
|
323
|
-
while (
|
|
395
|
+
while (base_path / name).exists():
|
|
324
396
|
name = f'{base_name} ({counter})'
|
|
325
397
|
counter += 1
|
|
326
398
|
|
|
@@ -510,3 +582,445 @@ class IdeasService:
|
|
|
510
582
|
return {'success': True}
|
|
511
583
|
except IOError as e:
|
|
512
584
|
return {'success': False, 'error': str(e)}
|
|
585
|
+
|
|
586
|
+
# =========================================================================
|
|
587
|
+
# CR-006: Folder Tree UX Enhancement
|
|
588
|
+
# =========================================================================
|
|
589
|
+
|
|
590
|
+
def move_item(self, source_path: str, target_folder: str) -> Dict[str, Any]:
|
|
591
|
+
"""
|
|
592
|
+
Move file or folder to target folder.
|
|
593
|
+
|
|
594
|
+
Args:
|
|
595
|
+
source_path: Path (relative to project root or ideas root)
|
|
596
|
+
target_folder: Target folder path (relative to project root or ideas root)
|
|
597
|
+
|
|
598
|
+
Returns:
|
|
599
|
+
{success: bool, new_path: str, error?: str}
|
|
600
|
+
"""
|
|
601
|
+
if not source_path:
|
|
602
|
+
return {'success': False, 'error': 'Source path is required'}
|
|
603
|
+
if target_folder is None:
|
|
604
|
+
return {'success': False, 'error': 'Target folder is required'}
|
|
605
|
+
|
|
606
|
+
# Normalize source path - handle both x-ipe-docs/ideas/... and relative paths
|
|
607
|
+
if source_path.startswith(self.IDEAS_PATH + '/'):
|
|
608
|
+
source_rel = source_path[len(self.IDEAS_PATH) + 1:]
|
|
609
|
+
elif source_path.startswith(self.IDEAS_PATH):
|
|
610
|
+
source_rel = source_path[len(self.IDEAS_PATH):]
|
|
611
|
+
else:
|
|
612
|
+
source_rel = source_path
|
|
613
|
+
source_full = self.ideas_root / source_rel
|
|
614
|
+
|
|
615
|
+
# Normalize target path
|
|
616
|
+
if target_folder == '' or target_folder == self.IDEAS_PATH:
|
|
617
|
+
target_full = self.ideas_root
|
|
618
|
+
target_rel = ''
|
|
619
|
+
elif target_folder.startswith(self.IDEAS_PATH + '/'):
|
|
620
|
+
target_rel = target_folder[len(self.IDEAS_PATH) + 1:]
|
|
621
|
+
target_full = self.ideas_root / target_rel
|
|
622
|
+
elif target_folder.startswith(self.IDEAS_PATH):
|
|
623
|
+
target_rel = target_folder[len(self.IDEAS_PATH):]
|
|
624
|
+
target_full = self.ideas_root / target_rel
|
|
625
|
+
else:
|
|
626
|
+
target_rel = target_folder
|
|
627
|
+
target_full = self.ideas_root / target_folder
|
|
628
|
+
|
|
629
|
+
# Validate source exists
|
|
630
|
+
if not source_full.exists():
|
|
631
|
+
return {'success': False, 'error': f'Source not found: {source_path}'}
|
|
632
|
+
|
|
633
|
+
# Validate target exists
|
|
634
|
+
if not target_full.exists():
|
|
635
|
+
return {'success': False, 'error': f'Target folder not found: {target_folder}'}
|
|
636
|
+
|
|
637
|
+
# Validate target is a folder
|
|
638
|
+
if not target_full.is_dir():
|
|
639
|
+
return {'success': False, 'error': 'Target is not a folder'}
|
|
640
|
+
|
|
641
|
+
# Validate not moving into self or child (use normalized paths)
|
|
642
|
+
if not self.is_valid_drop_target(source_rel, target_rel):
|
|
643
|
+
return {'success': False, 'error': 'Cannot move folder into itself or its children'}
|
|
644
|
+
|
|
645
|
+
# Determine destination path
|
|
646
|
+
dest_path = target_full / source_full.name
|
|
647
|
+
|
|
648
|
+
# Handle name collision
|
|
649
|
+
if dest_path.exists():
|
|
650
|
+
dest_path = self._get_unique_path(dest_path)
|
|
651
|
+
|
|
652
|
+
try:
|
|
653
|
+
shutil.move(str(source_full), str(dest_path))
|
|
654
|
+
# Return path relative to ideas root
|
|
655
|
+
new_path = str(dest_path.relative_to(self.ideas_root))
|
|
656
|
+
return {'success': True, 'new_path': new_path}
|
|
657
|
+
except OSError as e:
|
|
658
|
+
return {'success': False, 'error': f'Failed to move: {str(e)}'}
|
|
659
|
+
|
|
660
|
+
def duplicate_item(self, path: str) -> Dict[str, Any]:
|
|
661
|
+
"""
|
|
662
|
+
Duplicate file or folder with -copy suffix.
|
|
663
|
+
|
|
664
|
+
Creates: filename-copy.ext or foldername-copy/
|
|
665
|
+
If exists: filename-copy-2.ext, etc.
|
|
666
|
+
"""
|
|
667
|
+
if not path:
|
|
668
|
+
return {'success': False, 'error': 'Path is required'}
|
|
669
|
+
|
|
670
|
+
# Normalize path
|
|
671
|
+
if path.startswith(self.IDEAS_PATH + '/'):
|
|
672
|
+
path_rel = path[len(self.IDEAS_PATH) + 1:]
|
|
673
|
+
elif path.startswith(self.IDEAS_PATH):
|
|
674
|
+
path_rel = path[len(self.IDEAS_PATH):]
|
|
675
|
+
else:
|
|
676
|
+
path_rel = path
|
|
677
|
+
|
|
678
|
+
full_path = self.ideas_root / path_rel
|
|
679
|
+
|
|
680
|
+
if not full_path.exists():
|
|
681
|
+
return {'success': False, 'error': f'Path not found: {path}'}
|
|
682
|
+
|
|
683
|
+
# Validate path is within ideas directory
|
|
684
|
+
try:
|
|
685
|
+
resolved_path = full_path.resolve()
|
|
686
|
+
ideas_resolved = self.ideas_root.resolve()
|
|
687
|
+
|
|
688
|
+
if not str(resolved_path).startswith(str(ideas_resolved)):
|
|
689
|
+
return {'success': False, 'error': 'Path must be within x-ipe-docs/ideas/'}
|
|
690
|
+
except Exception:
|
|
691
|
+
return {'success': False, 'error': 'Invalid path'}
|
|
692
|
+
|
|
693
|
+
# Generate copy name
|
|
694
|
+
if full_path.is_file():
|
|
695
|
+
stem = full_path.stem
|
|
696
|
+
suffix = full_path.suffix
|
|
697
|
+
copy_name = f"{stem}-copy{suffix}"
|
|
698
|
+
copy_path = full_path.parent / copy_name
|
|
699
|
+
|
|
700
|
+
# Handle collisions
|
|
701
|
+
counter = 2
|
|
702
|
+
while copy_path.exists():
|
|
703
|
+
copy_name = f"{stem}-copy-{counter}{suffix}"
|
|
704
|
+
copy_path = full_path.parent / copy_name
|
|
705
|
+
counter += 1
|
|
706
|
+
|
|
707
|
+
try:
|
|
708
|
+
shutil.copy2(str(full_path), str(copy_path))
|
|
709
|
+
except OSError as e:
|
|
710
|
+
return {'success': False, 'error': f'Failed to duplicate: {str(e)}'}
|
|
711
|
+
else:
|
|
712
|
+
# Folder
|
|
713
|
+
copy_name = f"{full_path.name}-copy"
|
|
714
|
+
copy_path = full_path.parent / copy_name
|
|
715
|
+
|
|
716
|
+
# Handle collisions
|
|
717
|
+
counter = 2
|
|
718
|
+
while copy_path.exists():
|
|
719
|
+
copy_name = f"{full_path.name}-copy-{counter}"
|
|
720
|
+
copy_path = full_path.parent / copy_name
|
|
721
|
+
counter += 1
|
|
722
|
+
|
|
723
|
+
try:
|
|
724
|
+
shutil.copytree(str(full_path), str(copy_path))
|
|
725
|
+
except OSError as e:
|
|
726
|
+
return {'success': False, 'error': f'Failed to duplicate: {str(e)}'}
|
|
727
|
+
|
|
728
|
+
# Return path relative to ideas root
|
|
729
|
+
new_path = str(copy_path.relative_to(self.ideas_root))
|
|
730
|
+
return {'success': True, 'new_path': new_path}
|
|
731
|
+
|
|
732
|
+
def get_folder_contents(self, folder_path: str) -> Dict[str, Any]:
|
|
733
|
+
"""
|
|
734
|
+
Get contents of a specific folder for folder view panel.
|
|
735
|
+
|
|
736
|
+
Args:
|
|
737
|
+
folder_path: Path (relative to project root or ideas root)
|
|
738
|
+
|
|
739
|
+
Returns:
|
|
740
|
+
{success: bool, items: [...], error?: str}
|
|
741
|
+
"""
|
|
742
|
+
# Normalize path
|
|
743
|
+
if not folder_path:
|
|
744
|
+
folder_path_rel = ''
|
|
745
|
+
elif folder_path.startswith(self.IDEAS_PATH + '/'):
|
|
746
|
+
folder_path_rel = folder_path[len(self.IDEAS_PATH) + 1:]
|
|
747
|
+
elif folder_path.startswith(self.IDEAS_PATH):
|
|
748
|
+
folder_path_rel = folder_path[len(self.IDEAS_PATH):]
|
|
749
|
+
else:
|
|
750
|
+
folder_path_rel = folder_path
|
|
751
|
+
|
|
752
|
+
if folder_path_rel:
|
|
753
|
+
full_path = self.ideas_root / folder_path_rel
|
|
754
|
+
else:
|
|
755
|
+
full_path = self.ideas_root
|
|
756
|
+
|
|
757
|
+
if not full_path.exists():
|
|
758
|
+
return {'success': False, 'error': f'Folder not found: {folder_path}'}
|
|
759
|
+
|
|
760
|
+
if not full_path.is_dir():
|
|
761
|
+
return {'success': False, 'error': 'Path is not a folder'}
|
|
762
|
+
|
|
763
|
+
# Validate path is within ideas directory
|
|
764
|
+
try:
|
|
765
|
+
resolved_path = full_path.resolve()
|
|
766
|
+
ideas_resolved = self.ideas_root.resolve()
|
|
767
|
+
|
|
768
|
+
if not str(resolved_path).startswith(str(ideas_resolved)):
|
|
769
|
+
return {'success': False, 'error': 'Path must be within x-ipe-docs/ideas/'}
|
|
770
|
+
except Exception:
|
|
771
|
+
return {'success': False, 'error': 'Invalid path'}
|
|
772
|
+
|
|
773
|
+
items = []
|
|
774
|
+
try:
|
|
775
|
+
for entry in sorted(full_path.iterdir()):
|
|
776
|
+
if entry.name.startswith('.'):
|
|
777
|
+
continue
|
|
778
|
+
|
|
779
|
+
# Return path relative to project root (consistent with get_tree)
|
|
780
|
+
relative_path = str(entry.relative_to(self.project_root))
|
|
781
|
+
item = {
|
|
782
|
+
'name': entry.name,
|
|
783
|
+
'type': 'folder' if entry.is_dir() else 'file',
|
|
784
|
+
'path': relative_path
|
|
785
|
+
}
|
|
786
|
+
if entry.is_dir():
|
|
787
|
+
item['children'] = [] # Lazy load
|
|
788
|
+
items.append(item)
|
|
789
|
+
except PermissionError:
|
|
790
|
+
return {'success': False, 'error': 'Permission denied'}
|
|
791
|
+
|
|
792
|
+
# Return folder_path relative to project root
|
|
793
|
+
folder_path_result = str(full_path.relative_to(self.project_root)) if full_path != self.ideas_root else self.IDEAS_PATH
|
|
794
|
+
return {'success': True, 'items': items, 'folder_path': folder_path_result}
|
|
795
|
+
|
|
796
|
+
def is_valid_drop_target(self, source_path: str, target_folder: str) -> bool:
|
|
797
|
+
"""
|
|
798
|
+
Validate that target is not source or child of source.
|
|
799
|
+
|
|
800
|
+
Args:
|
|
801
|
+
source_path: Path of item being dragged (relative to ideas root)
|
|
802
|
+
target_folder: Path of drop target folder (relative to ideas root)
|
|
803
|
+
"""
|
|
804
|
+
if not source_path:
|
|
805
|
+
return False
|
|
806
|
+
|
|
807
|
+
# Normalize paths (remove x-ipe-docs/ideas/ prefix if present)
|
|
808
|
+
if source_path.startswith(self.IDEAS_PATH + '/'):
|
|
809
|
+
source_norm = source_path[len(self.IDEAS_PATH) + 1:].rstrip('/')
|
|
810
|
+
elif source_path.startswith(self.IDEAS_PATH):
|
|
811
|
+
source_norm = source_path[len(self.IDEAS_PATH):].lstrip('/').rstrip('/')
|
|
812
|
+
else:
|
|
813
|
+
source_norm = source_path.rstrip('/')
|
|
814
|
+
|
|
815
|
+
if not target_folder:
|
|
816
|
+
return True # Root is always valid
|
|
817
|
+
|
|
818
|
+
if target_folder.startswith(self.IDEAS_PATH + '/'):
|
|
819
|
+
target_norm = target_folder[len(self.IDEAS_PATH) + 1:].rstrip('/')
|
|
820
|
+
elif target_folder.startswith(self.IDEAS_PATH):
|
|
821
|
+
target_norm = target_folder[len(self.IDEAS_PATH):].lstrip('/').rstrip('/')
|
|
822
|
+
else:
|
|
823
|
+
target_norm = target_folder.rstrip('/')
|
|
824
|
+
|
|
825
|
+
# Cannot drop onto self
|
|
826
|
+
if source_norm == target_norm:
|
|
827
|
+
return False
|
|
828
|
+
|
|
829
|
+
# Check if source is a folder
|
|
830
|
+
source_full = self.ideas_root / source_norm
|
|
831
|
+
if not source_full.exists() or not source_full.is_dir():
|
|
832
|
+
return True # Files can be dropped anywhere, non-existent is handled elsewhere
|
|
833
|
+
|
|
834
|
+
# Cannot drop folder into its own children
|
|
835
|
+
if target_norm.startswith(source_norm + '/'):
|
|
836
|
+
return False
|
|
837
|
+
|
|
838
|
+
return True
|
|
839
|
+
|
|
840
|
+
def filter_tree(self, query: str) -> List[Dict]:
|
|
841
|
+
"""
|
|
842
|
+
Filter tree by search query, returning matching items with parent context.
|
|
843
|
+
|
|
844
|
+
Args:
|
|
845
|
+
query: Search string to match against item names
|
|
846
|
+
|
|
847
|
+
Returns:
|
|
848
|
+
Flat list of matching items and their parent folders
|
|
849
|
+
"""
|
|
850
|
+
if not query or not query.strip():
|
|
851
|
+
return self.get_tree()
|
|
852
|
+
|
|
853
|
+
query_lower = query.lower().strip()
|
|
854
|
+
tree = self.get_tree()
|
|
855
|
+
|
|
856
|
+
# Collect all matching items plus their parents
|
|
857
|
+
results = []
|
|
858
|
+
self._collect_matches(tree, query_lower, results)
|
|
859
|
+
return results
|
|
860
|
+
|
|
861
|
+
def _collect_matches(self, items: List[Dict], query: str, results: List[Dict], include_all: bool = False) -> bool:
|
|
862
|
+
"""Recursively collect matching items and parents into flat results list.
|
|
863
|
+
|
|
864
|
+
Returns True if any child matches (to include parent in results).
|
|
865
|
+
"""
|
|
866
|
+
any_match = False
|
|
867
|
+
|
|
868
|
+
for item in items:
|
|
869
|
+
item_copy = copy.copy(item)
|
|
870
|
+
name_matches = query in item['name'].lower()
|
|
871
|
+
|
|
872
|
+
if item['type'] == 'folder' and 'children' in item:
|
|
873
|
+
# Check if any children match
|
|
874
|
+
child_results = []
|
|
875
|
+
has_child_match = self._collect_matches(item['children'], query, child_results, include_all=name_matches)
|
|
876
|
+
|
|
877
|
+
if name_matches or has_child_match:
|
|
878
|
+
item_copy['_matches'] = name_matches
|
|
879
|
+
item_copy['children'] = [] # Don't include nested children in flat result
|
|
880
|
+
results.append(item_copy)
|
|
881
|
+
results.extend(child_results)
|
|
882
|
+
any_match = True
|
|
883
|
+
else:
|
|
884
|
+
# File - include if name matches or parent matched
|
|
885
|
+
if name_matches or include_all:
|
|
886
|
+
item_copy['_matches'] = name_matches
|
|
887
|
+
results.append(item_copy)
|
|
888
|
+
if name_matches:
|
|
889
|
+
any_match = True
|
|
890
|
+
|
|
891
|
+
return any_match
|
|
892
|
+
|
|
893
|
+
def get_download_info(self, path: str) -> Dict[str, Any]:
|
|
894
|
+
"""
|
|
895
|
+
Get file content and mime type for download.
|
|
896
|
+
|
|
897
|
+
Args:
|
|
898
|
+
path: Path (relative to project root or ideas root)
|
|
899
|
+
|
|
900
|
+
Returns:
|
|
901
|
+
{success: bool, content: bytes, filename: str, mime_type: str}
|
|
902
|
+
"""
|
|
903
|
+
if not path:
|
|
904
|
+
return {'success': False, 'error': 'Path is required'}
|
|
905
|
+
|
|
906
|
+
# Normalize path
|
|
907
|
+
if path.startswith(self.IDEAS_PATH + '/'):
|
|
908
|
+
path_rel = path[len(self.IDEAS_PATH) + 1:]
|
|
909
|
+
elif path.startswith(self.IDEAS_PATH):
|
|
910
|
+
path_rel = path[len(self.IDEAS_PATH):]
|
|
911
|
+
else:
|
|
912
|
+
path_rel = path
|
|
913
|
+
|
|
914
|
+
full_path = self.ideas_root / path_rel
|
|
915
|
+
|
|
916
|
+
if not full_path.exists():
|
|
917
|
+
return {'success': False, 'error': f'File not found: {path}'}
|
|
918
|
+
|
|
919
|
+
if not full_path.is_file():
|
|
920
|
+
return {'success': False, 'error': 'Cannot download a folder'}
|
|
921
|
+
|
|
922
|
+
# Validate path is within ideas directory
|
|
923
|
+
try:
|
|
924
|
+
resolved_path = full_path.resolve()
|
|
925
|
+
ideas_resolved = self.ideas_root.resolve()
|
|
926
|
+
|
|
927
|
+
if not str(resolved_path).startswith(str(ideas_resolved)):
|
|
928
|
+
return {'success': False, 'error': 'Path must be within x-ipe-docs/ideas/'}
|
|
929
|
+
except Exception:
|
|
930
|
+
return {'success': False, 'error': 'Invalid path'}
|
|
931
|
+
|
|
932
|
+
# Determine mime type
|
|
933
|
+
suffix = full_path.suffix.lower()
|
|
934
|
+
mime_types = {
|
|
935
|
+
'.md': 'text/markdown',
|
|
936
|
+
'.txt': 'text/plain',
|
|
937
|
+
'.json': 'application/json',
|
|
938
|
+
'.html': 'text/html',
|
|
939
|
+
'.pdf': 'application/pdf',
|
|
940
|
+
'.png': 'image/png',
|
|
941
|
+
'.jpg': 'image/jpeg',
|
|
942
|
+
'.jpeg': 'image/jpeg',
|
|
943
|
+
'.gif': 'image/gif',
|
|
944
|
+
}
|
|
945
|
+
mime_type = mime_types.get(suffix, 'application/octet-stream')
|
|
946
|
+
|
|
947
|
+
try:
|
|
948
|
+
content = full_path.read_bytes()
|
|
949
|
+
# For text files, decode to string for easier testing
|
|
950
|
+
text_types = ['.md', '.txt', '.json', '.html', '.css', '.js']
|
|
951
|
+
if suffix in text_types:
|
|
952
|
+
try:
|
|
953
|
+
content = content.decode('utf-8')
|
|
954
|
+
except UnicodeDecodeError:
|
|
955
|
+
pass # Keep as bytes if decode fails
|
|
956
|
+
return {
|
|
957
|
+
'success': True,
|
|
958
|
+
'content': content,
|
|
959
|
+
'filename': full_path.name,
|
|
960
|
+
'mime_type': mime_type
|
|
961
|
+
}
|
|
962
|
+
except OSError as e:
|
|
963
|
+
return {'success': False, 'error': f'Failed to read file: {str(e)}'}
|
|
964
|
+
|
|
965
|
+
def get_delete_info(self, path: str) -> Dict[str, Any]:
|
|
966
|
+
"""
|
|
967
|
+
Get item info for delete confirmation dialog.
|
|
968
|
+
|
|
969
|
+
Returns item type and count of children for folders.
|
|
970
|
+
"""
|
|
971
|
+
if not path:
|
|
972
|
+
return {'success': False, 'error': 'Path is required'}
|
|
973
|
+
|
|
974
|
+
# Normalize path
|
|
975
|
+
if path.startswith(self.IDEAS_PATH + '/'):
|
|
976
|
+
path_rel = path[len(self.IDEAS_PATH) + 1:]
|
|
977
|
+
elif path.startswith(self.IDEAS_PATH):
|
|
978
|
+
path_rel = path[len(self.IDEAS_PATH):]
|
|
979
|
+
else:
|
|
980
|
+
path_rel = path
|
|
981
|
+
|
|
982
|
+
full_path = self.ideas_root / path_rel
|
|
983
|
+
|
|
984
|
+
if not full_path.exists():
|
|
985
|
+
return {'success': False, 'error': f'Path not found: {path}'}
|
|
986
|
+
|
|
987
|
+
item_type = 'folder' if full_path.is_dir() else 'file'
|
|
988
|
+
item_count = 1
|
|
989
|
+
|
|
990
|
+
if full_path.is_dir():
|
|
991
|
+
# Count all items recursively
|
|
992
|
+
item_count = sum(1 for _ in full_path.rglob('*') if not _.name.startswith('.'))
|
|
993
|
+
|
|
994
|
+
return {
|
|
995
|
+
'success': True,
|
|
996
|
+
'path': path,
|
|
997
|
+
'name': full_path.name,
|
|
998
|
+
'type': item_type,
|
|
999
|
+
'item_count': item_count
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
def _get_unique_path(self, path: Path) -> Path:
|
|
1003
|
+
"""Generate unique path if target exists."""
|
|
1004
|
+
if not path.exists():
|
|
1005
|
+
return path
|
|
1006
|
+
|
|
1007
|
+
parent = path.parent
|
|
1008
|
+
if path.is_file() or not path.exists():
|
|
1009
|
+
stem = path.stem
|
|
1010
|
+
suffix = path.suffix
|
|
1011
|
+
counter = 2
|
|
1012
|
+
while True:
|
|
1013
|
+
new_name = f"{stem}-{counter}{suffix}"
|
|
1014
|
+
new_path = parent / new_name
|
|
1015
|
+
if not new_path.exists():
|
|
1016
|
+
return new_path
|
|
1017
|
+
counter += 1
|
|
1018
|
+
else:
|
|
1019
|
+
name = path.name
|
|
1020
|
+
counter = 2
|
|
1021
|
+
while True:
|
|
1022
|
+
new_name = f"{name}-{counter}"
|
|
1023
|
+
new_path = parent / new_name
|
|
1024
|
+
if not new_path.exists():
|
|
1025
|
+
return new_path
|
|
1026
|
+
counter += 1
|
x_ipe/services/proxy_service.py
CHANGED
|
@@ -118,6 +118,25 @@ class ProxyResult:
|
|
|
118
118
|
content_type: str = "text/html"
|
|
119
119
|
error: str = ""
|
|
120
120
|
status_code: int = 200
|
|
121
|
+
binary_content: bytes = None # TASK-235: For binary files like fonts
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# TASK-235: Binary content types that should not be decoded as text
|
|
125
|
+
BINARY_CONTENT_TYPES = {
|
|
126
|
+
'font/', 'image/', 'audio/', 'video/',
|
|
127
|
+
'application/octet-stream', 'application/font', 'application/x-font',
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _is_binary_content_type(content_type: str) -> bool:
|
|
132
|
+
"""Check if content type represents binary data."""
|
|
133
|
+
if not content_type:
|
|
134
|
+
return False
|
|
135
|
+
content_type = content_type.lower()
|
|
136
|
+
for binary_type in BINARY_CONTENT_TYPES:
|
|
137
|
+
if binary_type in content_type:
|
|
138
|
+
return True
|
|
139
|
+
return False
|
|
121
140
|
|
|
122
141
|
|
|
123
142
|
class ProxyService:
|
|
@@ -209,20 +228,27 @@ class ProxyService:
|
|
|
209
228
|
|
|
210
229
|
content_type = response.headers.get('Content-Type', 'text/html')
|
|
211
230
|
|
|
231
|
+
# TASK-235: Handle binary content (fonts, images, etc.) without decoding
|
|
232
|
+
if _is_binary_content_type(content_type):
|
|
233
|
+
return ProxyResult(
|
|
234
|
+
success=True,
|
|
235
|
+
binary_content=response.content,
|
|
236
|
+
content_type=content_type
|
|
237
|
+
)
|
|
238
|
+
|
|
212
239
|
# Only rewrite HTML
|
|
213
240
|
if 'text/html' in content_type:
|
|
214
241
|
html = self._rewrite_html(response.text, url)
|
|
215
242
|
return ProxyResult(success=True, html=html, content_type=content_type)
|
|
243
|
+
# TASK-235: Rewrite CSS url() references for font files
|
|
244
|
+
elif 'text/css' in content_type:
|
|
245
|
+
css = self._rewrite_css_urls(response.text, url)
|
|
246
|
+
return ProxyResult(success=True, html=css, content_type=content_type)
|
|
216
247
|
else:
|
|
217
|
-
# Return
|
|
218
|
-
# For text content, decode; for binary, return bytes as string (will be raw in Response)
|
|
219
|
-
try:
|
|
220
|
-
content = response.text
|
|
221
|
-
except Exception:
|
|
222
|
-
content = response.content.decode('utf-8', errors='replace')
|
|
248
|
+
# Return text content as-is
|
|
223
249
|
return ProxyResult(
|
|
224
250
|
success=True,
|
|
225
|
-
html=
|
|
251
|
+
html=response.text,
|
|
226
252
|
content_type=content_type
|
|
227
253
|
)
|
|
228
254
|
|