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.
@@ -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 (self.ideas_root / name).exists():
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
@@ -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 non-HTML content as-is (use response.content for binary safety)
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=content,
251
+ html=response.text,
226
252
  content_type=content_type
227
253
  )
228
254