- Add API endpoint and handler to delete collections - Introduce LoadingSpinner component for async operations - Show loading spinners during file loading and preview rendering - Enhance modal accessibility by removing aria-hidden attribute - Refactor delete functionality to distinguish between collections and files/folders - Remove unused collection definitions from config
		
			
				
	
	
		
			369 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Python
		
	
	
		
			Executable File
		
	
	
	
	
			
		
		
	
	
			369 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Python
		
	
	
		
			Executable File
		
	
	
	
	
| #!/usr/bin/env python3
 | |
| """
 | |
| WebDAV-based Markdown Editor Server
 | |
| Uses WsgiDAV for standards-compliant file operations
 | |
| """
 | |
| 
 | |
| import os
 | |
| import sys
 | |
| import yaml
 | |
| import json
 | |
| from pathlib import Path
 | |
| from wsgidav.wsgidav_app import WsgiDAVApp
 | |
| from wsgidav.fs_dav_provider import FilesystemProvider
 | |
| from cheroot import wsgi
 | |
| from cheroot.ssl.builtin import BuiltinSSLAdapter
 | |
| 
 | |
| 
 | |
| class MarkdownEditorApp:
 | |
|     """Main application that wraps WsgiDAV and adds custom endpoints"""
 | |
|     
 | |
|     def __init__(self, config_path="config.yaml"):
 | |
|         self.root_path = Path(__file__).parent.resolve()
 | |
|         os.chdir(self.root_path)
 | |
|         self.config = self.load_config(config_path)
 | |
|         self.collections = self.config.get('collections', {})
 | |
|         self.setup_collections()
 | |
|         self.webdav_app = self.create_webdav_app()
 | |
|     
 | |
|     def load_config(self, config_path):
 | |
|         """Load configuration from YAML file"""
 | |
|         self.config_path = config_path
 | |
|         with open(config_path, 'r') as f:
 | |
|             return yaml.safe_load(f)
 | |
| 
 | |
|     def save_config(self):
 | |
|         """Save configuration to YAML file"""
 | |
|         # Update config with current collections
 | |
|         self.config['collections'] = self.collections
 | |
|         with open(self.config_path, 'w') as f:
 | |
|             yaml.dump(self.config, f, default_flow_style=False, sort_keys=False)
 | |
|     
 | |
|     def setup_collections(self):
 | |
|         """Create collection directories if they don't exist"""
 | |
|         for name, config in self.collections.items():
 | |
|             path = Path(config['path'])
 | |
|             path.mkdir(parents=True, exist_ok=True)
 | |
|             
 | |
|             # Create images subdirectory
 | |
|             images_path = path / 'images'
 | |
|             images_path.mkdir(exist_ok=True)
 | |
|             
 | |
|             print(f"Collection '{name}' -> {path.absolute()}")
 | |
|     
 | |
|     def create_webdav_app(self):
 | |
|         """Create WsgiDAV application with configured collections"""
 | |
|         provider_mapping = {}
 | |
|         
 | |
|         for name, config in self.collections.items():
 | |
|             path = os.path.abspath(config['path'])
 | |
|             provider_mapping[f'/fs/{name}'] = FilesystemProvider(path)
 | |
|         
 | |
|         config = {
 | |
|             'host': self.config['server']['host'],
 | |
|             'port': int(os.environ.get('PORT', self.config['server']['port'])),
 | |
|             'provider_mapping': provider_mapping,
 | |
|             'verbose': self.config['webdav'].get('verbose', 1),
 | |
|             'logging': {
 | |
|                 'enable_loggers': []
 | |
|             },
 | |
|             'property_manager': True,
 | |
|             'lock_storage': True,
 | |
|             'simple_dc': {
 | |
|                 'user_mapping': {
 | |
|                     '*': True  # Allow anonymous access for development
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
|         
 | |
|         return WsgiDAVApp(config)
 | |
|     
 | |
|     def __call__(self, environ, start_response):
 | |
|         """WSGI application entry point"""
 | |
|         path = environ.get('PATH_INFO', '')
 | |
|         method = environ.get('REQUEST_METHOD', '')
 | |
| 
 | |
|         # Root and index.html
 | |
|         if path == '/' or path == '/index.html':
 | |
|             return self.handle_index(environ, start_response)
 | |
| 
 | |
|         # Static files
 | |
|         if path.startswith('/static/'):
 | |
|             return self.handle_static(environ, start_response)
 | |
| 
 | |
|         # Health check
 | |
|         if path == '/health' and method == 'GET':
 | |
|             start_response('200 OK', [('Content-Type', 'text/plain')])
 | |
|             return [b'OK']
 | |
| 
 | |
|         # API for collections
 | |
|         if path == '/fs/' and method == 'GET':
 | |
|             return self.handle_collections_list(environ, start_response)
 | |
| 
 | |
|         # API to create new collection
 | |
|         if path == '/fs/' and method == 'POST':
 | |
|             return self.handle_create_collection(environ, start_response)
 | |
| 
 | |
|         # API to delete a collection
 | |
|         if path.startswith('/api/collections/') and method == 'DELETE':
 | |
|             return self.handle_delete_collection(environ, start_response)
 | |
| 
 | |
|         # Check if path starts with a collection name (for SPA routing)
 | |
|         # This handles URLs like /notes/ttt or /documents/file.md
 | |
|         # MUST be checked BEFORE WebDAV routing to prevent WebDAV from intercepting SPA routes
 | |
|         path_parts = path.strip('/').split('/')
 | |
|         if path_parts and path_parts[0] in self.collections:
 | |
|             # This is a SPA route for a collection, serve index.html
 | |
|             # The client-side router will handle the path
 | |
|             return self.handle_index(environ, start_response)
 | |
| 
 | |
|         # All other /fs/ requests go to WebDAV
 | |
|         if path.startswith('/fs/'):
 | |
|             return self.webdav_app(environ, start_response)
 | |
| 
 | |
|         # Fallback: Serve index.html for all other routes (SPA routing)
 | |
|         # This allows client-side routing to handle any other paths
 | |
|         return self.handle_index(environ, start_response)
 | |
|     
 | |
|     def handle_collections_list(self, environ, start_response):
 | |
|         """Return list of available collections"""
 | |
|         collections = list(self.collections.keys())
 | |
|         response_body = json.dumps(collections).encode('utf-8')
 | |
| 
 | |
|         start_response('200 OK', [
 | |
|             ('Content-Type', 'application/json'),
 | |
|             ('Content-Length', str(len(response_body))),
 | |
|             ('Access-Control-Allow-Origin', '*')
 | |
|         ])
 | |
| 
 | |
|         return [response_body]
 | |
| 
 | |
|     def handle_create_collection(self, environ, start_response):
 | |
|         """Create a new collection"""
 | |
|         try:
 | |
|             # Read request body
 | |
|             content_length = int(environ.get('CONTENT_LENGTH', 0))
 | |
|             request_body = environ['wsgi.input'].read(content_length)
 | |
|             data = json.loads(request_body.decode('utf-8'))
 | |
| 
 | |
|             collection_name = data.get('name')
 | |
|             if not collection_name:
 | |
|                 start_response('400 Bad Request', [('Content-Type', 'application/json')])
 | |
|                 return [json.dumps({'error': 'Collection name is required'}).encode('utf-8')]
 | |
| 
 | |
|             # Check if collection already exists
 | |
|             if collection_name in self.collections:
 | |
|                 start_response('409 Conflict', [('Content-Type', 'application/json')])
 | |
|                 return [json.dumps({'error': f'Collection "{collection_name}" already exists'}).encode('utf-8')]
 | |
| 
 | |
|             # Create collection directory
 | |
|             collection_path = Path(f'./collections/{collection_name}')
 | |
|             collection_path.mkdir(parents=True, exist_ok=True)
 | |
| 
 | |
|             # Create images subdirectory
 | |
|             images_path = collection_path / 'images'
 | |
|             images_path.mkdir(exist_ok=True)
 | |
| 
 | |
|             # Add to collections dict
 | |
|             self.collections[collection_name] = {
 | |
|                 'path': str(collection_path),
 | |
|                 'description': f'User-created collection: {collection_name}'
 | |
|             }
 | |
| 
 | |
|             # Update config file
 | |
|             self.save_config()
 | |
| 
 | |
|             # Add to WebDAV provider mapping
 | |
|             from wsgidav.fs_dav_provider import FilesystemProvider
 | |
|             provider_path = os.path.abspath(str(collection_path))
 | |
|             provider_key = f'/fs/{collection_name}'
 | |
| 
 | |
|             # Use the add_provider method if available, otherwise add directly to provider_map
 | |
|             provider = FilesystemProvider(provider_path)
 | |
|             if hasattr(self.webdav_app, 'add_provider'):
 | |
|                 self.webdav_app.add_provider(provider_key, provider)
 | |
|                 print(f"Added provider using add_provider(): {provider_key}")
 | |
|             else:
 | |
|                 self.webdav_app.provider_map[provider_key] = provider
 | |
|                 print(f"Added provider to provider_map: {provider_key}")
 | |
| 
 | |
|             # Also update sorted_share_list if it exists
 | |
|             if hasattr(self.webdav_app, 'sorted_share_list'):
 | |
|                 if provider_key not in self.webdav_app.sorted_share_list:
 | |
|                     self.webdav_app.sorted_share_list.append(provider_key)
 | |
|                     self.webdav_app.sorted_share_list.sort(reverse=True)
 | |
|                     print(f"Updated sorted_share_list")
 | |
| 
 | |
|             print(f"Created collection '{collection_name}' at {provider_path}")
 | |
| 
 | |
|             response_body = json.dumps({'success': True, 'name': collection_name}).encode('utf-8')
 | |
|             start_response('201 Created', [
 | |
|                 ('Content-Type', 'application/json'),
 | |
|                 ('Content-Length', str(len(response_body))),
 | |
|                 ('Access-Control-Allow-Origin', '*')
 | |
|             ])
 | |
| 
 | |
|             return [response_body]
 | |
| 
 | |
|         except Exception as e:
 | |
|             print(f"Error creating collection: {e}")
 | |
|             start_response('500 Internal Server Error', [('Content-Type', 'application/json')])
 | |
|             return [json.dumps({'error': str(e)}).encode('utf-8')]
 | |
| 
 | |
|     def handle_delete_collection(self, environ, start_response):
 | |
|         """Delete a collection"""
 | |
|         try:
 | |
|             # Extract collection name from path: /api/collections/{name}
 | |
|             path = environ.get('PATH_INFO', '')
 | |
|             collection_name = path.split('/')[-1]
 | |
| 
 | |
|             if not collection_name:
 | |
|                 start_response('400 Bad Request', [('Content-Type', 'application/json')])
 | |
|                 return [json.dumps({'error': 'Collection name is required'}).encode('utf-8')]
 | |
| 
 | |
|             # Check if collection exists
 | |
|             if collection_name not in self.collections:
 | |
|                 start_response('404 Not Found', [('Content-Type', 'application/json')])
 | |
|                 return [json.dumps({'error': f'Collection "{collection_name}" not found'}).encode('utf-8')]
 | |
| 
 | |
|             # Get collection path
 | |
|             collection_config = self.collections[collection_name]
 | |
|             collection_path = Path(collection_config['path'])
 | |
| 
 | |
|             # Delete the collection directory and all its contents
 | |
|             import shutil
 | |
|             if collection_path.exists():
 | |
|                 shutil.rmtree(collection_path)
 | |
|                 print(f"Deleted collection directory: {collection_path}")
 | |
| 
 | |
|             # Remove from collections dict
 | |
|             del self.collections[collection_name]
 | |
| 
 | |
|             # Update config file
 | |
|             self.save_config()
 | |
| 
 | |
|             # Remove from WebDAV provider mapping
 | |
|             provider_key = f'/fs/{collection_name}'
 | |
|             if hasattr(self.webdav_app, 'provider_map') and provider_key in self.webdav_app.provider_map:
 | |
|                 del self.webdav_app.provider_map[provider_key]
 | |
|                 print(f"Removed provider from provider_map: {provider_key}")
 | |
| 
 | |
|             # Remove from sorted_share_list if it exists
 | |
|             if hasattr(self.webdav_app, 'sorted_share_list') and provider_key in self.webdav_app.sorted_share_list:
 | |
|                 self.webdav_app.sorted_share_list.remove(provider_key)
 | |
|                 print(f"Removed from sorted_share_list: {provider_key}")
 | |
| 
 | |
|             print(f"Deleted collection '{collection_name}'")
 | |
| 
 | |
|             response_body = json.dumps({'success': True, 'name': collection_name}).encode('utf-8')
 | |
|             start_response('200 OK', [
 | |
|                 ('Content-Type', 'application/json'),
 | |
|                 ('Content-Length', str(len(response_body))),
 | |
|                 ('Access-Control-Allow-Origin', '*')
 | |
|             ])
 | |
| 
 | |
|             return [response_body]
 | |
| 
 | |
|         except Exception as e:
 | |
|             print(f"Error deleting collection: {e}")
 | |
|             import traceback
 | |
|             traceback.print_exc()
 | |
|             start_response('500 Internal Server Error', [('Content-Type', 'application/json')])
 | |
|             return [json.dumps({'error': str(e)}).encode('utf-8')]
 | |
| 
 | |
|     def handle_static(self, environ, start_response):
 | |
|         """Serve static files"""
 | |
|         path = environ.get('PATH_INFO', '')[1:]  # Remove leading /
 | |
|         file_path = self.root_path / path
 | |
|         
 | |
|         if not file_path.is_file():
 | |
|             start_response('404 Not Found', [('Content-Type', 'text/plain')])
 | |
|             return [b'File not found']
 | |
|         
 | |
|         # Determine content type
 | |
|         content_types = {
 | |
|             '.html': 'text/html',
 | |
|             '.css': 'text/css',
 | |
|             '.js': 'application/javascript',
 | |
|             '.json': 'application/json',
 | |
|             '.png': 'image/png',
 | |
|             '.jpg': 'image/jpeg',
 | |
|             '.jpeg': 'image/jpeg',
 | |
|             '.gif': 'image/gif',
 | |
|             '.svg': 'image/svg+xml',
 | |
|             '.ico': 'image/x-icon'
 | |
|         }
 | |
|         
 | |
|         ext = file_path.suffix.lower()
 | |
|         content_type = content_types.get(ext, 'application/octet-stream')
 | |
|         
 | |
|         with open(file_path, 'rb') as f:
 | |
|             content = f.read()
 | |
|         
 | |
|         start_response('200 OK', [
 | |
|             ('Content-Type', content_type),
 | |
|             ('Content-Length', str(len(content)))
 | |
|         ])
 | |
|         
 | |
|         return [content]
 | |
|     
 | |
|     def handle_index(self, environ, start_response):
 | |
|         """Serve index.html"""
 | |
|         index_path = self.root_path / 'templates' / 'index.html'
 | |
|         
 | |
|         if not index_path.is_file():
 | |
|             start_response('404 Not Found', [('Content-Type', 'text/plain')])
 | |
|             return [b'index.html not found']
 | |
|         
 | |
|         with open(index_path, 'r', encoding='utf-8') as f:
 | |
|             content = f.read().encode('utf-8')
 | |
|         
 | |
|         start_response('200 OK', [
 | |
|             ('Content-Type', 'text/html; charset=utf-8'),
 | |
|             ('Content-Length', str(len(content)))
 | |
|         ])
 | |
|         
 | |
|         return [content]
 | |
| 
 | |
| 
 | |
| def main():
 | |
|     """Start the server"""
 | |
|     print("=" * 60)
 | |
|     print("Markdown Editor with WebDAV Backend")
 | |
|     print("=" * 60)
 | |
|     
 | |
|     # Create application
 | |
|     app = MarkdownEditorApp()
 | |
|     
 | |
|     # Get server config
 | |
|     host = app.config['server']['host']
 | |
|     port = int(os.environ.get('PORT', app.config['server']['port']))
 | |
|     
 | |
|     print(f"\nServer starting on http://{host}:{port}")
 | |
|     print(f"\nAvailable collections:")
 | |
|     for name, config in app.collections.items():
 | |
|         print(f"  - {name}: {config['description']}")
 | |
|         print(f"    WebDAV: http://{host}:{port}/fs/{name}/")
 | |
|     
 | |
|     print(f"\nWeb UI: http://{host}:{port}/")
 | |
|     print("\nPress Ctrl+C to stop the server")
 | |
|     print("=" * 60)
 | |
|     
 | |
|     # Create and start server
 | |
|     server = wsgi.Server(
 | |
|         bind_addr=(host, port),
 | |
|         wsgi_app=app
 | |
|     )
 | |
|     
 | |
|     try:
 | |
|         server.start()
 | |
|         server.wait()
 | |
|     except KeyboardInterrupt:
 | |
|         print("\n\nShutting down...")
 | |
|         server.stop()
 | |
| 
 | |
| 
 | |
| if __name__ == '__main__':
 | |
|     main()
 | |
| 
 |