From f319f29d4c84dc24408cdaf57bd5dfee82d160ff Mon Sep 17 00:00:00 2001 From: Mahmoud-Emad Date: Sun, 26 Oct 2025 17:29:45 +0300 Subject: [PATCH] feat: Enhance WebDAV file management and UI - Add functionality to create new collections via API - Implement copy and move operations between collections - Improve image rendering in markdown preview with relative path resolution - Add support for previewing binary files (images, PDFs) - Refactor modal styling to use flat buttons and improve accessibility --- .gitignore | 1 - collections/7madah/tests/sub_tests/file1.md | 22 + collections/7madah/tests/test.md | 9 + collections/7madah/tests/test2.md | 12 + collections/7madah/tests/test3.md | 426 ++++++++++++++++++++ collections/notes/introduction.md | 18 + collections/notes/presentation.md | 40 ++ collections/notes/why.md | 78 ++++ config.yaml | 38 +- server_debug.log | 8 + server_webdav.py | 88 +++- static/css/components.css | 88 ++++ static/js/app.js | 183 ++++++++- static/js/collection-selector.js | 70 +++- static/js/confirmation.js | 21 +- static/js/editor.js | 89 ++++ static/js/file-tree-actions.js | 314 +++++++++++++++ static/js/utils.js | 49 +++ static/js/webdav-client.js | 188 ++++++--- templates/index.html | 50 ++- 20 files changed, 1679 insertions(+), 113 deletions(-) create mode 100644 collections/7madah/tests/sub_tests/file1.md create mode 100644 collections/7madah/tests/test.md create mode 100644 collections/7madah/tests/test2.md create mode 100644 collections/7madah/tests/test3.md create mode 100644 collections/notes/introduction.md create mode 100644 collections/notes/presentation.md create mode 100644 collections/notes/why.md create mode 100644 server_debug.log diff --git a/.gitignore b/.gitignore index 443f468..1d17dae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ .venv -server.log diff --git a/collections/7madah/tests/sub_tests/file1.md b/collections/7madah/tests/sub_tests/file1.md new file mode 100644 index 0000000..c83a7dc --- /dev/null +++ b/collections/7madah/tests/sub_tests/file1.md @@ -0,0 +1,22 @@ +# Start to end file + +### Graph + +--- + +This is just for testing + +--- + +**See what i did?** + +--- + +```mermaid +graph TD + A[Start] --> B{Process}; + B --> C{Decision}; + C -- Yes --> D[End Yes]; + C -- No --> E[End No]; +``` + diff --git a/collections/7madah/tests/test.md b/collections/7madah/tests/test.md new file mode 100644 index 0000000..dc493b0 --- /dev/null +++ b/collections/7madah/tests/test.md @@ -0,0 +1,9 @@ + +# test + +- 1 +- 2 + + + +!!include path:test2.md diff --git a/collections/7madah/tests/test2.md b/collections/7madah/tests/test2.md new file mode 100644 index 0000000..cd8e39b --- /dev/null +++ b/collections/7madah/tests/test2.md @@ -0,0 +1,12 @@ + +## test2 + +- something +- another thing + + + + + + + diff --git a/collections/7madah/tests/test3.md b/collections/7madah/tests/test3.md new file mode 100644 index 0000000..06bcd72 --- /dev/null +++ b/collections/7madah/tests/test3.md @@ -0,0 +1,426 @@ +# UI Code Refactoring Plan + +**Project:** Markdown Editor +**Date:** 2025-10-26 +**Status:** In Progress + +--- + +## Executive Summary + +This document outlines a comprehensive refactoring plan for the UI codebase to improve maintainability, remove dead code, extract utilities, and standardize patterns. The refactoring is organized into 6 phases with 14 tasks, prioritized by risk and impact. + +**Key Metrics:** + +- Total Lines of Code: ~3,587 +- Dead Code to Remove: 213 lines (6%) +- Estimated Effort: 5-8 days +- Risk Level: Mostly LOW to MEDIUM + +--- + +## Phase 1: Analysis Summary + +### Files Reviewed + +**JavaScript Files (10):** + +- `/static/js/app.js` (484 lines) +- `/static/js/column-resizer.js` (100 lines) +- `/static/js/confirmation.js` (170 lines) +- `/static/js/editor.js` (420 lines) +- `/static/js/file-tree-actions.js` (482 lines) +- `/static/js/file-tree.js` (865 lines) +- `/static/js/macro-parser.js` (103 lines) +- `/static/js/macro-processor.js` (157 lines) +- `/static/js/ui-utils.js` (305 lines) +- `/static/js/webdav-client.js` (266 lines) + +**CSS Files (6):** + +- `/static/css/variables.css` (32 lines) +- `/static/css/layout.css` +- `/static/css/file-tree.css` +- `/static/css/editor.css` +- `/static/css/components.css` +- `/static/css/modal.css` + +**HTML Templates (1):** + +- `/templates/index.html` (203 lines) + +--- + +## Issues Found + +### 🔴 HIGH PRIORITY + +1. **Deprecated Modal Code (Dead Code)** + - Location: `/static/js/file-tree-actions.js` lines 262-474 + - Impact: 213 lines of unused code (44% of file) + - Risk: LOW to remove + +2. **Duplicated Event Bus Implementation** + - Location: `/static/js/app.js` lines 16-30 + - Should be extracted to reusable module + +3. **Duplicated Debounce Function** + - Location: `/static/js/editor.js` lines 404-414 + - Should be shared utility + +4. **Inconsistent Notification Usage** + - Mixed usage of `window.showNotification` vs `showNotification` + +5. **Duplicated File Download Logic** + - Location: `/static/js/file-tree.js` lines 829-839 + - Should be shared utility + +6. **Hard-coded Values** + - Long-press threshold: 400ms + - Debounce delay: 300ms + - Drag preview width: 200px + - Toast delay: 3000ms + +### 🟡 MEDIUM PRIORITY + +7. **Global State Management** + - Location: `/static/js/app.js` lines 6-13 + - Makes testing difficult + +8. **Duplicated Path Manipulation** + - `path.split('/').pop()` appears 10+ times + - `path.substring(0, path.lastIndexOf('/'))` appears 5+ times + +9. **Mixed Responsibility in ui-utils.js** + - Contains 6 different classes/utilities + - Should be split into separate modules + +10. **Deprecated Event Handler** + - Location: `/static/js/file-tree-actions.js` line 329 + - Uses deprecated `onkeypress` + +### 🟢 LOW PRIORITY + +11. **Unused Function Parameters** +12. **Magic Numbers in Styling** +13. **Inconsistent Comment Styles** +14. **Console.log Statements** + +--- + +## Phase 2: Proposed Reusable Components + +### 1. Config Module (`/static/js/config.js`) + +Centralize all configuration values: + +```javascript +export const Config = { + // Timing + LONG_PRESS_THRESHOLD: 400, + DEBOUNCE_DELAY: 300, + TOAST_DURATION: 3000, + + // UI + DRAG_PREVIEW_WIDTH: 200, + TREE_INDENT_PX: 12, + MOUSE_MOVE_THRESHOLD: 5, + + // Validation + FILENAME_PATTERN: /^[a-z0-9_]+(\.[a-z0-9_]+)*$/, + + // Storage Keys + STORAGE_KEYS: { + DARK_MODE: 'darkMode', + SELECTED_COLLECTION: 'selectedCollection', + LAST_VIEWED_PAGE: 'lastViewedPage', + COLUMN_DIMENSIONS: 'columnDimensions' + } +}; +``` + +### 2. Logger Module (`/static/js/logger.js`) + +Structured logging with levels: + +```javascript +export class Logger { + static debug(message, ...args) + static info(message, ...args) + static warn(message, ...args) + static error(message, ...args) + static setLevel(level) +} +``` + +### 3. Event Bus Module (`/static/js/event-bus.js`) + +Centralized event system: + +```javascript +export class EventBus { + on(event, callback) + off(event, callback) + once(event, callback) + dispatch(event, data) + clear(event) +} +``` + +### 4. Utilities Module (`/static/js/utils.js`) + +Common utility functions: + +```javascript +export const PathUtils = { + getFileName(path), + getParentPath(path), + normalizePath(path), + joinPaths(...paths), + getExtension(path) +}; + +export const TimingUtils = { + debounce(func, wait), + throttle(func, wait) +}; + +export const DownloadUtils = { + triggerDownload(content, filename), + downloadAsBlob(blob, filename) +}; + +export const ValidationUtils = { + validateFileName(name, isFolder), + sanitizeFileName(name) +}; +``` + +### 5. Notification Service (`/static/js/notification-service.js`) + +Standardized notifications: + +```javascript +export class NotificationService { + static success(message) + static error(message) + static warning(message) + static info(message) +} +``` + +--- + +## Phase 3: Refactoring Tasks + +### 🔴 HIGH PRIORITY + +**Task 1: Remove Dead Code** + +- Files: `/static/js/file-tree-actions.js` +- Lines: 262-474 (213 lines) +- Risk: LOW +- Dependencies: None + +**Task 2: Extract Event Bus** + +- Files: NEW `/static/js/event-bus.js`, MODIFY `app.js`, `editor.js` +- Risk: MEDIUM +- Dependencies: None + +**Task 3: Create Utilities Module** + +- Files: NEW `/static/js/utils.js`, MODIFY multiple files +- Risk: MEDIUM +- Dependencies: None + +**Task 4: Create Config Module** + +- Files: NEW `/static/js/config.js`, MODIFY multiple files +- Risk: LOW +- Dependencies: None + +**Task 5: Standardize Notification Usage** + +- Files: NEW `/static/js/notification-service.js`, MODIFY multiple files +- Risk: LOW +- Dependencies: None + +### 🟡 MEDIUM PRIORITY + +**Task 6: Fix Deprecated Event Handler** + +- Files: `/static/js/file-tree-actions.js` line 329 +- Risk: LOW +- Dependencies: None + +**Task 7: Refactor ui-utils.js** + +- Files: DELETE `ui-utils.js`, CREATE 5 new modules +- Risk: HIGH +- Dependencies: Task 5 + +**Task 8: Standardize Class Export Pattern** + +- Files: All class files +- Risk: MEDIUM +- Dependencies: None + +**Task 9: Create Logger Module** + +- Files: NEW `/static/js/logger.js`, MODIFY multiple files +- Risk: LOW +- Dependencies: None + +**Task 10: Implement Download Action** + +- Files: `/static/js/file-tree-actions.js` +- Risk: LOW +- Dependencies: Task 3 + +### 🟢 LOW PRIORITY + +**Task 11: Standardize JSDoc Comments** +**Task 12: Extract Magic Numbers to CSS** +**Task 13: Add Error Boundaries** +**Task 14: Cache DOM Elements** + +--- + +## Phase 4: Implementation Order + +### Step 1: Foundation (Do First) + +1. Create Config Module (Task 4) +2. Create Logger Module (Task 9) +3. Create Event Bus Module (Task 2) + +### Step 2: Utilities (Do Second) + +4. Create Utilities Module (Task 3) +5. Create Notification Service (Task 5) + +### Step 3: Cleanup (Do Third) + +6. Remove Dead Code (Task 1) +7. Fix Deprecated Event Handler (Task 6) + +### Step 4: Restructuring (Do Fourth) + +8. Refactor ui-utils.js (Task 7) +9. Standardize Class Export Pattern (Task 8) + +### Step 5: Enhancements (Do Fifth) + +10. Implement Download Action (Task 10) +11. Add Error Boundaries (Task 13) + +### Step 6: Polish (Do Last) + +12. Standardize JSDoc Comments (Task 11) +13. Extract Magic Numbers to CSS (Task 12) +14. Cache DOM Elements (Task 14) + +--- + +## Phase 5: Testing Checklist + +### Core Functionality + +- [ ] File tree loads and displays correctly +- [ ] Files can be selected and opened +- [ ] Folders can be expanded/collapsed +- [ ] Editor loads file content +- [ ] Preview renders markdown correctly +- [ ] Save button saves files +- [ ] Delete button deletes files +- [ ] New button creates new files + +### Context Menu Actions + +- [ ] Right-click shows context menu +- [ ] New file action works +- [ ] New folder action works +- [ ] Rename action works +- [ ] Delete action works +- [ ] Copy/Cut/Paste actions work +- [ ] Upload action works + +### Drag and Drop + +- [ ] Long-press detection works +- [ ] Drag preview appears correctly +- [ ] Drop targets highlight properly +- [ ] Files can be moved +- [ ] Undo (Ctrl+Z) works + +### Modals + +- [ ] Confirmation modals appear +- [ ] Prompt modals appear +- [ ] Modals don't double-open +- [ ] Enter/Escape keys work + +### UI Features + +- [ ] Dark mode toggle works +- [ ] Collection selector works +- [ ] Column resizers work +- [ ] Notifications appear +- [ ] URL routing works +- [ ] View/Edit modes work + +--- + +## Recommendations + +### Immediate Actions (Before Production) + +1. Remove dead code (Task 1) +2. Fix deprecated event handler (Task 6) +3. Create config module (Task 4) + +### Short-term Actions (Next Sprint) + +4. Extract utilities (Task 3) +5. Standardize notifications (Task 5) +6. Create event bus (Task 2) + +### Medium-term Actions (Future Sprints) + +7. Refactor ui-utils.js (Task 7) +8. Add logger (Task 9) +9. Standardize exports (Task 8) + +--- + +## Success Metrics + +**Before Refactoring:** + +- Total Lines: ~3,587 +- Dead Code: 213 lines (6%) +- Duplicated Code: ~50 lines +- Hard-coded Values: 15+ + +**After Refactoring:** + +- Total Lines: ~3,400 (-5%) +- Dead Code: 0 lines +- Duplicated Code: 0 lines +- Hard-coded Values: 0 + +**Estimated Effort:** 5-8 days + +--- + +## Conclusion + +The UI codebase is generally well-structured. Main improvements needed: + +1. Remove dead code +2. Extract duplicated utilities +3. Centralize configuration +4. Standardize patterns + +Start with high-impact, low-risk changes first to ensure production readiness. diff --git a/collections/notes/introduction.md b/collections/notes/introduction.md new file mode 100644 index 0000000..6cbbf1e --- /dev/null +++ b/collections/notes/introduction.md @@ -0,0 +1,18 @@ +# Introduction + +### This is an introduction + + +* **This is an internal image** + +--- + +![My company logo](/images/logo-blue.png "Company Logo") + +--- + +* **This is an external image** + +![My company logo](https://images.pexels.com/photos/1054655/pexels-photo-1054655.jpeg "Another image") + +--- diff --git a/collections/notes/presentation.md b/collections/notes/presentation.md new file mode 100644 index 0000000..d9efbc8 --- /dev/null +++ b/collections/notes/presentation.md @@ -0,0 +1,40 @@ +## Mycelium Product Presentation + +This document provides an overview of the Mycelium technology stack (as commercially sold my our company GeoMind). + +
+ +
+ +
+ + Geomind Product Intro 2025 (based on mycelium technology) + +
diff --git a/collections/notes/why.md b/collections/notes/why.md new file mode 100644 index 0000000..8b57321 --- /dev/null +++ b/collections/notes/why.md @@ -0,0 +1,78 @@ +**Decentralized Infrastructure Technology for Everyone, Everywhere** + +Mycelium is a comprehensive DePIN (Decentralized Physical Infrastructure) system designed to scale to planetary level, capable of providing resilient services with end-to-end encryption, and enabling any machine and human to communicate efficiently over optimal paths. + +Mycelium is Compatible with Kubernetes, Docker, VMs, Web2, Web3 – and building towards Web4. + +## Terminology Clarification + +- **Mycelium Tech**: The core technology stack (ZOS, QSS, Mycelium Network) +- **ThreeFold Grid**: The decentralized infrastructure offering built on Mycelium Tech +- **GeoMind**: The commercial tech company operating tier-S/H datacenters with Mycelium + +## Why Decentralized Infrastructure Matters + +Traditional internet infrastructure is burdened with inefficiencies, risks, and growing dependency on centralization. + +### **The Challenges We Face** + +- **Centralization Risks**: Digital infrastructure is controlled by a few corporations, compromising autonomy and creating single points of failure. +- **Economic Inefficiency**: Current infrastructure models extract value from local economies, funneling resources to centralized providers. +- **Outdated Protocols**: TCP/IP, the internet's core protocol, was never designed for modern needs like dynamic networks, security, and session management. +- **Geographic Inefficiency**: Over 70% of the world relies on distant infrastructure, making access expensive, unreliable, and dependent on fragile global systems. +- **Limited Access**: Over 50% of the world lacks decent internet access, widening opportunity gaps. + +Mycelium addresses these challenges through a complete, integrated technology stack designed from first principles. + +## What Mycelium Provides + +Mycelium is unique in its ability to deliver an integrated platform covering all three fundamental layers of internet infrastructure: + +### **Compute Layer** - ZOS +- Autonomous, stateless operating system +- MyImage architecture (up to 100x faster deployment) +- Deterministic, cryptographically verified deployment +- Supports Kubernetes, containers, VMs, and Linux workloads +- Self-healing with no manual maintenance required + +### **Storage Layer** - Quantum Safe Storage (QSS) +- Mathematical encoding with forward error correction +- 20% overhead vs 400% for traditional replication +- Zero-knowledge design: storage nodes can't access data +- Petabyte-to-zetabyte scalability +- Self-healing bitrot protection + +### **Network Layer** - Mycelium Network +- End-to-end encrypted IPv6 overlay +- Shortest-path optimization +- Multi-protocol support (TCP, QUIC, UDP, satellite, wireless) +- Peer-to-peer architecture with no central points of failure +- Distributed secure name services + +## Key Differentiators + +| Feature | Mycelium | Traditional Cloud | +| ------------------------ | -------------------------------------------- | ------------------------------------------ | +| **Architecture** | Distributed peer-to-peer, no central control | Centralized control planes | +| **Deployment** | Stateless network boot, zero-install | Local image installation | +| **Storage Efficiency** | 20% overhead | 300-400% overhead | +| **Security** | End-to-end encrypted, zero-knowledge design | Perimeter-based, trust intermediaries | +| **Energy** | Up to 10x more efficient | Higher consumption | +| **Autonomy** | Self-healing, autonomous agents | Requires active management | +| **Geographic Awareness** | Shortest path routing, location-aware | Static routing, no geographic optimization | + +## Current Status + +- **Deployed**: 20+ countries, 30,000+ vCPU +- **Proof of Concept**: Technology validated in production +- **Commercialization**: Beginning phase with enterprise roadmap + +## Technology Maturity + +- **All our core cloud technology**: Production +- **Quantum Safe Storage**: Production (6+ years) +- **Mycelium Network**: Beta +- **Deterministic Deployment**: OEM only +- **FungiStor**: H1 2026 + +Mycelium represents not just an upgrade to existing infrastructure, but a fundamental rethinking of how internet infrastructure should be built—distributed, autonomous, secure, and efficient.% \ No newline at end of file diff --git a/config.yaml b/config.yaml index 4cfec89..27322e2 100644 --- a/config.yaml +++ b/config.yaml @@ -1,25 +1,31 @@ -# WsgiDAV Configuration -# Collections define WebDAV-accessible directories - collections: documents: - path: "./collections/documents" - description: "General documents and notes" - + path: ./collections/documents + description: General documents and notes notes: - path: "./collections/notes" - description: "Personal notes and drafts" - + path: ./collections/notes + description: Personal notes and drafts projects: - path: "./collections/projects" - description: "Project documentation" - -# Server settings + path: ./collections/projects + description: Project documentation + new_collectionss: + path: collections/new_collectionss + description: 'User-created collection: new_collectionss' + test_collection_new: + path: collections/test_collection_new + description: 'User-created collection: test_collection_new' + dynamic_test: + path: collections/dynamic_test + description: 'User-created collection: dynamic_test' + runtime_collection: + path: collections/runtime_collection + description: 'User-created collection: runtime_collection' + 7madah: + path: collections/7madah + description: 'User-created collection: 7madah' server: - host: "localhost" + host: localhost port: 8004 - -# WebDAV settings webdav: verbose: 1 enable_loggers: [] diff --git a/server_debug.log b/server_debug.log new file mode 100644 index 0000000..0e0487f --- /dev/null +++ b/server_debug.log @@ -0,0 +1,8 @@ +============================================== +Markdown Editor v3.0 - WebDAV Server +============================================== +Activating virtual environment... +Installing dependencies... +Audited 3 packages in 29ms +Checking for process on port 8004... +Starting WebDAV server... diff --git a/server_webdav.py b/server_webdav.py index 070bce1..8150cb0 100755 --- a/server_webdav.py +++ b/server_webdav.py @@ -28,8 +28,16 @@ class MarkdownEditorApp: 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""" @@ -92,6 +100,10 @@ class MarkdownEditorApp: 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) + # 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 @@ -113,14 +125,86 @@ class MarkdownEditorApp: """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_static(self, environ, start_response): """Serve static files""" diff --git a/static/css/components.css b/static/css/components.css index 0512ff5..803a515 100644 --- a/static/css/components.css +++ b/static/css/components.css @@ -143,6 +143,15 @@ body.dark-mode .context-menu { animation: slideIn 0.3s ease; } +/* Override Bootstrap warning background to be darker for better text contrast */ +.toast.bg-warning { + background-color: #cc9a06 !important; +} + +body.dark-mode .toast.bg-warning { + background-color: #b8860b !important; +} + @keyframes slideIn { from { transform: translateX(400px); @@ -276,4 +285,83 @@ body.dark-mode .modal-footer { color: var(--text-secondary); line-height: 1.4; margin-top: 8px; +} + +/* Flat Button Styles */ +.btn-flat { + border: none; + border-radius: 0; + padding: 6px 12px; + font-size: 14px; + font-weight: 500; + transition: all 0.2s ease; + background-color: transparent; + color: var(--text-primary); + position: relative; +} + +.btn-flat:hover { + background-color: var(--bg-tertiary); +} + +.btn-flat:active { + transform: scale(0.95); +} + +/* Flat button variants */ +.btn-flat-primary { + color: #0d6efd; +} + +.btn-flat-primary:hover { + background-color: rgba(13, 110, 253, 0.1); +} + +.btn-flat-success { + color: #198754; +} + +.btn-flat-success:hover { + background-color: rgba(25, 135, 84, 0.1); +} + +.btn-flat-danger { + color: #dc3545; +} + +.btn-flat-danger:hover { + background-color: rgba(220, 53, 69, 0.1); +} + +.btn-flat-warning { + color: #ffc107; +} + +.btn-flat-warning:hover { + background-color: rgba(255, 193, 7, 0.1); +} + +.btn-flat-secondary { + color: var(--text-secondary); +} + +.btn-flat-secondary:hover { + background-color: var(--bg-tertiary); +} + +/* Dark mode adjustments */ +body.dark-mode .btn-flat-primary { + color: #6ea8fe; +} + +body.dark-mode .btn-flat-success { + color: #75b798; +} + +body.dark-mode .btn-flat-danger { + color: #ea868f; +} + +body.dark-mode .btn-flat-warning { + color: #ffda6a; } \ No newline at end of file diff --git a/static/js/app.js b/static/js/app.js index a12f615..1eab987 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -208,10 +208,17 @@ async function loadFileFromURL(collection, filePath) { await showDirectoryPreview(filePath); fileTree.selectAndExpandPath(filePath); } else if (node) { - // It's a file, load it + // It's a file, check if it's binary console.log('[loadFileFromURL] Loading file'); - await editor.loadFile(filePath); - fileTree.selectAndExpandPath(filePath); + + // Use the fileTree.onFileSelect callback to handle both text and binary files + if (fileTree.onFileSelect) { + fileTree.onFileSelect({ path: filePath, isDirectory: false }); + } else { + // Fallback to direct loading + await editor.loadFile(filePath); + fileTree.selectAndExpandPath(filePath); + } } else { console.error(`[loadFileFromURL] Path not found in file tree: ${filePath}`); } @@ -269,6 +276,37 @@ document.addEventListener('DOMContentLoaded', async () => { collectionSelector = new CollectionSelector('collectionSelect', webdavClient); await collectionSelector.load(); + // Setup New Collection button + document.getElementById('newCollectionBtn').addEventListener('click', async () => { + try { + const collectionName = await window.ModalManager.prompt( + 'Enter new collection name (lowercase, underscore only):', + 'new_collection' + ); + + if (!collectionName) return; + + // Validate collection name + const validation = ValidationUtils.validateFileName(collectionName, true); + if (!validation.valid) { + window.showNotification(validation.message, 'warning'); + return; + } + + // Create the collection + await webdavClient.createCollection(validation.sanitized); + + // Reload collections and switch to the new one + await collectionSelector.load(); + await collectionSelector.setCollection(validation.sanitized); + + window.showNotification(`Collection "${validation.sanitized}" created`, 'success'); + } catch (error) { + Logger.error('Failed to create collection:', error); + window.showNotification('Failed to create collection', 'error'); + } + }); + // Setup URL routing setupPopStateListener(); @@ -281,11 +319,102 @@ document.addEventListener('DOMContentLoaded', async () => { fileTree = new FileTree('fileTree', webdavClient); fileTree.onFileSelect = async (item) => { try { + const currentCollection = collectionSelector.getCurrentCollection(); + + // Check if the file is a binary/non-editable file + if (PathUtils.isBinaryFile(item.path)) { + const fileType = PathUtils.getFileType(item.path); + const fileName = PathUtils.getFileName(item.path); + + Logger.info(`Previewing binary file: ${item.path}`); + + // Set flag to prevent auto-update of preview + editor.isShowingCustomPreview = true; + + // In edit mode, show a warning notification + if (isEditMode) { + if (window.showNotification) { + window.showNotification( + `"${fileName}" is read-only. Showing preview only.`, + 'warning' + ); + } + + // Hide the editor pane temporarily + const editorPane = document.getElementById('editorPane'); + const resizer1 = document.getElementById('resizer1'); + if (editorPane) editorPane.style.display = 'none'; + if (resizer1) resizer1.style.display = 'none'; + } + + // Clear the editor (but don't trigger preview update due to flag) + if (editor.editor) { + editor.editor.setValue(''); + } + editor.filenameInput.value = item.path; + editor.currentFile = item.path; + + // Build the file URL using the WebDAV client's method + const fileUrl = webdavClient.getFullUrl(item.path); + Logger.debug(`Binary file URL: ${fileUrl}`); + + // Generate preview HTML based on file type + let previewHtml = ''; + + if (fileType === 'Image') { + // Preview images + previewHtml = ` +
+

${fileName}

+

Image Preview (Read-only)

+ ${fileName} +
+ `; + } else if (fileType === 'PDF') { + // Preview PDFs + previewHtml = ` +
+

${fileName}

+

PDF Preview (Read-only)

+ +
+ `; + } else { + // For other binary files, show download link + previewHtml = ` +
+

${fileName}

+

${fileType} File (Read-only)

+

This file cannot be previewed in the browser.

+ Download ${fileName} +
+ `; + } + + // Display in preview pane + editor.previewElement.innerHTML = previewHtml; + + // Highlight the file in the tree + fileTree.selectAndExpandPath(item.path); + + // Update URL to reflect current file + updateURL(currentCollection, item.path, isEditMode); + + return; + } + + // For text files, restore the editor pane if it was hidden + if (isEditMode) { + const editorPane = document.getElementById('editorPane'); + const resizer1 = document.getElementById('resizer1'); + if (editorPane) editorPane.style.display = ''; + if (resizer1) resizer1.style.display = ''; + } + await editor.loadFile(item.path); // Highlight the file in the tree and expand parent directories fileTree.selectAndExpandPath(item.path); // Update URL to reflect current file - const currentCollection = collectionSelector.getCurrentCollection(); updateURL(currentCollection, item.path, isEditMode); } catch (error) { Logger.error('Failed to select file:', error); @@ -332,9 +461,7 @@ document.addEventListener('DOMContentLoaded', async () => { const { collection: urlCollection, filePath: urlFilePath } = parseURLPath(); console.log('[URL PARSE]', { urlCollection, urlFilePath }); - if (urlCollection && urlFilePath) { - console.log('[URL LOAD] Loading from URL:', urlCollection, urlFilePath); - + if (urlCollection) { // First ensure the collection is set const currentCollection = collectionSelector.getCurrentCollection(); if (currentCollection !== urlCollection) { @@ -343,11 +470,17 @@ document.addEventListener('DOMContentLoaded', async () => { await fileTree.load(); } - // Now load the file from URL - console.log('[URL LOAD] Calling loadFileFromURL'); - await loadFileFromURL(urlCollection, urlFilePath); + // If there's a file path in the URL, load it + if (urlFilePath) { + console.log('[URL LOAD] Loading file from URL:', urlCollection, urlFilePath); + await loadFileFromURL(urlCollection, urlFilePath); + } else if (!isEditMode) { + // Collection-only URL in view mode: auto-load last viewed page + console.log('[URL LOAD] Collection-only URL, auto-loading page'); + await autoLoadPageInViewMode(); + } } else if (!isEditMode) { - // In view mode, auto-load last viewed page if no URL file specified + // No URL collection specified, in view mode: auto-load last viewed page await autoLoadPageInViewMode(); } @@ -405,11 +538,34 @@ document.addEventListener('DOMContentLoaded', async () => { // Initialize file tree actions manager window.fileTreeActions = new FileTreeActions(webdavClient, fileTree, editor); + + // Setup Exit Edit Mode button + document.getElementById('exitEditModeBtn').addEventListener('click', () => { + // Switch to view mode by removing edit=true from URL + const url = new URL(window.location.href); + url.searchParams.delete('edit'); + window.location.href = url.toString(); + }); + + // Hide Edit Mode button in edit mode + document.getElementById('editModeBtn').style.display = 'none'; } else { // In view mode, hide editor buttons document.getElementById('newBtn').style.display = 'none'; document.getElementById('saveBtn').style.display = 'none'; document.getElementById('deleteBtn').style.display = 'none'; + document.getElementById('exitEditModeBtn').style.display = 'none'; + + // Show Edit Mode button in view mode + document.getElementById('editModeBtn').style.display = 'block'; + + // Setup Edit Mode button + document.getElementById('editModeBtn').addEventListener('click', () => { + // Switch to edit mode by adding edit=true to URL + const url = new URL(window.location.href); + url.searchParams.set('edit', 'true'); + window.location.href = url.toString(); + }); // Auto-load last viewed page or first file await autoLoadPageInViewMode(); @@ -498,10 +654,11 @@ async function handleEditorFileDrop(file) { const uploadedPath = await fileTree.uploadFile(targetDir, file); // Insert markdown link at cursor + // Use relative path (without collection name) so the image renderer can resolve it correctly const isImage = file.type.startsWith('image/'); const link = isImage - ? `![${file.name}](/${webdavClient.currentCollection}/${uploadedPath})` - : `[${file.name}](/${webdavClient.currentCollection}/${uploadedPath})`; + ? `![${file.name}](${uploadedPath})` + : `[${file.name}](${uploadedPath})`; editor.insertAtCursor(link); showNotification(`Uploaded and inserted link`, 'success'); diff --git a/static/js/collection-selector.js b/static/js/collection-selector.js index b40ee5d..3c24665 100644 --- a/static/js/collection-selector.js +++ b/static/js/collection-selector.js @@ -26,12 +26,21 @@ class CollectionSelector { this.select.appendChild(option); }); - // Try to restore previously selected collection from localStorage - const savedCollection = localStorage.getItem(this.storageKey); + // Determine which collection to select (priority: URL > localStorage > first) let collectionToSelect = collections[0]; // Default to first - if (savedCollection && collections.includes(savedCollection)) { - collectionToSelect = savedCollection; + // Check URL first (highest priority) + const urlCollection = this.getCollectionFromURL(); + if (urlCollection && collections.includes(urlCollection)) { + collectionToSelect = urlCollection; + Logger.info(`Using collection from URL: ${urlCollection}`); + } else { + // Fall back to localStorage + const savedCollection = localStorage.getItem(this.storageKey); + if (savedCollection && collections.includes(savedCollection)) { + collectionToSelect = savedCollection; + Logger.info(`Using collection from localStorage: ${savedCollection}`); + } } if (collections.length > 0) { @@ -48,14 +57,17 @@ class CollectionSelector { // Save to localStorage localStorage.setItem(this.storageKey, collection); this.webdavClient.setCollection(collection); - + Logger.info(`Collection changed to: ${collection}`); - + + // Update URL to reflect collection change + this.updateURLForCollection(collection); + if (this.onChange) { this.onChange(collection); } }); - + Logger.debug(`Loaded ${collections.length} collections`); } catch (error) { Logger.error('Failed to load collections:', error); @@ -83,9 +95,12 @@ class CollectionSelector { this.select.value = collection; localStorage.setItem(this.storageKey, collection); this.webdavClient.setCollection(collection); - + Logger.info(`Collection set to: ${collection}`); - + + // Update URL to reflect collection change + this.updateURLForCollection(collection); + if (this.onChange) { this.onChange(collection); } @@ -93,6 +108,43 @@ class CollectionSelector { Logger.warn(`Collection "${collection}" not found in available collections`); } } + + /** + * Update the browser URL to reflect the current collection + * @param {string} collection - The collection name + */ + updateURLForCollection(collection) { + // Get current URL parameters + const urlParams = new URLSearchParams(window.location.search); + const isEditMode = urlParams.get('edit') === 'true'; + + // Build new URL with collection + let url = `/${collection}/`; + if (isEditMode) { + url += '?edit=true'; + } + + // Use pushState to update URL without reloading + window.history.pushState({ collection, filePath: null }, '', url); + Logger.debug(`Updated URL to: ${url}`); + } + + /** + * Extract collection name from current URL + * URL format: // or // + * @returns {string|null} The collection name or null if not found + */ + getCollectionFromURL() { + const pathname = window.location.pathname; + const parts = pathname.split('/').filter(p => p); // Remove empty parts + + if (parts.length === 0) { + return null; + } + + // First part is the collection + return parts[0]; + } } // Make CollectionSelector globally available diff --git a/static/js/confirmation.js b/static/js/confirmation.js index 7c95a8a..4c50176 100644 --- a/static/js/confirmation.js +++ b/static/js/confirmation.js @@ -49,11 +49,11 @@ class ModalManager { // Update button styling based on danger level if (isDangerous) { - this.confirmButton.className = 'btn btn-danger'; - this.confirmButton.textContent = 'Delete'; + this.confirmButton.className = 'btn-flat btn-flat-danger'; + this.confirmButton.innerHTML = ' Delete'; } else { - this.confirmButton.className = 'btn btn-primary'; - this.confirmButton.textContent = 'OK'; + this.confirmButton.className = 'btn-flat btn-flat-primary'; + this.confirmButton.innerHTML = ' OK'; } // Set up event handlers @@ -74,6 +74,8 @@ class ModalManager { // Focus confirm button after modal is shown this.modalElement.addEventListener('shown.bs.modal', () => { + // Ensure aria-hidden is removed (Bootstrap should do this, but be explicit) + this.modalElement.removeAttribute('aria-hidden'); this.confirmButton.focus(); }, { once: true }); }); @@ -103,8 +105,8 @@ class ModalManager { this.inputElement.value = defaultValue; // Reset button to primary style for prompts - this.confirmButton.className = 'btn btn-primary'; - this.confirmButton.textContent = 'OK'; + this.confirmButton.className = 'btn-flat btn-flat-primary'; + this.confirmButton.innerHTML = ' OK'; // Set up event handlers this.confirmButton.onclick = (e) => { @@ -132,6 +134,8 @@ class ModalManager { // Focus and select input after modal is shown this.modalElement.addEventListener('shown.bs.modal', () => { + // Ensure aria-hidden is removed (Bootstrap should do this, but be explicit) + this.modalElement.removeAttribute('aria-hidden'); this.inputElement.focus(); this.inputElement.select(); }, { once: true }); @@ -161,6 +165,11 @@ class ModalManager { this.currentResolver = null; this.isShowing = false; this.modal.hide(); + + // Restore aria-hidden after modal is hidden + this.modalElement.addEventListener('hidden.bs.modal', () => { + this.modalElement.setAttribute('aria-hidden', 'true'); + }, { once: true }); } } diff --git a/static/js/editor.js b/static/js/editor.js index c98169c..1a91b57 100644 --- a/static/js/editor.js +++ b/static/js/editor.js @@ -14,6 +14,7 @@ class MarkdownEditor { this.lastViewedStorageKey = 'lastViewedPage'; // localStorage key for tracking last viewed page this.readOnly = readOnly; // Whether editor is in read-only mode this.editor = null; // Will be initialized later + this.isShowingCustomPreview = false; // Flag to prevent auto-update when showing binary files // Only initialize CodeMirror if not in read-only mode (view mode) if (!readOnly) { @@ -87,9 +88,88 @@ class MarkdownEditor { initMarkdown() { if (window.marked) { this.marked = window.marked; + + // Create custom renderer for images + const renderer = new marked.Renderer(); + + renderer.image = (token) => { + // Handle both old API (string params) and new API (token object) + let href, title, text; + + if (typeof token === 'object' && token !== null) { + // New API: token is an object + href = token.href || ''; + title = token.title || ''; + text = token.text || ''; + } else { + // Old API: separate parameters (href, title, text) + href = arguments[0] || ''; + title = arguments[1] || ''; + text = arguments[2] || ''; + } + + // Ensure all are strings + href = String(href || ''); + title = String(title || ''); + text = String(text || ''); + + Logger.debug(`Image renderer called with href="${href}", title="${title}", text="${text}"`); + + // Check if href contains binary data (starts with non-printable characters) + if (href && href.length > 100 && /^[\x00-\x1F\x7F-\xFF]/.test(href)) { + Logger.error('Image href contains binary data - this should not happen!'); + Logger.error('First 50 chars:', href.substring(0, 50)); + // Return a placeholder image + return `
⚠️ Invalid image data detected. Please re-upload the image.
`; + } + + // Fix relative image paths to use WebDAV base URL + if (href && !href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('data:')) { + // Get the directory of the current file + const currentDir = this.currentFile ? PathUtils.getParentPath(this.currentFile) : ''; + + // Resolve relative path + let imagePath = href; + if (href.startsWith('./')) { + // Relative to current directory + imagePath = PathUtils.joinPaths(currentDir, href.substring(2)); + } else if (href.startsWith('../')) { + // Relative to parent directory + imagePath = PathUtils.joinPaths(currentDir, href); + } else if (!href.startsWith('/')) { + // Relative to current directory (no ./) + imagePath = PathUtils.joinPaths(currentDir, href); + } else { + // Absolute path from collection root + imagePath = href.substring(1); // Remove leading / + } + + // Build WebDAV URL - ensure no double slashes + if (this.webdavClient && this.webdavClient.currentCollection) { + // Remove trailing slash from baseUrl if present + const baseUrl = this.webdavClient.baseUrl.endsWith('/') + ? this.webdavClient.baseUrl.slice(0, -1) + : this.webdavClient.baseUrl; + + // Ensure imagePath doesn't start with / + const cleanImagePath = imagePath.startsWith('/') ? imagePath.substring(1) : imagePath; + + href = `${baseUrl}/${this.webdavClient.currentCollection}/${cleanImagePath}`; + + Logger.debug(`Resolved image URL: ${href}`); + } + } + + // Generate HTML directly + const titleAttr = title ? ` title="${title}"` : ''; + const altAttr = text ? ` alt="${text}"` : ''; + return ``; + }; + this.marked.setOptions({ breaks: true, gfm: true, + renderer: renderer, highlight: (code, lang) => { if (lang && window.Prism.languages[lang]) { return window.Prism.highlight(code, window.Prism.languages[lang], lang); @@ -131,6 +211,9 @@ class MarkdownEditor { */ async loadFile(path) { try { + // Reset custom preview flag when loading text files + this.isShowingCustomPreview = false; + const content = await this.webdavClient.get(path); this.currentFile = path; @@ -337,6 +420,12 @@ class MarkdownEditor { * Calls renderPreview with content from editor */ async updatePreview() { + // Skip auto-update if showing custom preview (e.g., binary files) + if (this.isShowingCustomPreview) { + Logger.debug('Skipping auto-update: showing custom preview'); + return; + } + if (this.editor) { await this.renderPreview(); } diff --git a/static/js/file-tree-actions.js b/static/js/file-tree-actions.js index a391b61..39701e8 100644 --- a/static/js/file-tree-actions.js +++ b/static/js/file-tree-actions.js @@ -240,6 +240,56 @@ class FileTreeActions { }; input.click(); + }, + + 'copy-to-collection': async function (path, isDir) { + // Get list of available collections + const collections = await this.webdavClient.getCollections(); + const currentCollection = this.webdavClient.currentCollection; + + // Filter out current collection + const otherCollections = collections.filter(c => c !== currentCollection); + + if (otherCollections.length === 0) { + showNotification('No other collections available', 'warning'); + return; + } + + // Show collection selection dialog + const targetCollection = await this.showCollectionSelectionDialog( + otherCollections, + `Copy ${PathUtils.getFileName(path)} to collection:` + ); + + if (!targetCollection) return; + + // Copy the file/folder + await this.copyToCollection(path, isDir, currentCollection, targetCollection); + }, + + 'move-to-collection': async function (path, isDir) { + // Get list of available collections + const collections = await this.webdavClient.getCollections(); + const currentCollection = this.webdavClient.currentCollection; + + // Filter out current collection + const otherCollections = collections.filter(c => c !== currentCollection); + + if (otherCollections.length === 0) { + showNotification('No other collections available', 'warning'); + return; + } + + // Show collection selection dialog + const targetCollection = await this.showCollectionSelectionDialog( + otherCollections, + `Move ${PathUtils.getFileName(path)} to collection:` + ); + + if (!targetCollection) return; + + // Move the file/folder + await this.moveToCollection(path, isDir, currentCollection, targetCollection); } }; @@ -251,4 +301,268 @@ class FileTreeActions { pasteItem.style.display = this.clipboard ? 'flex' : 'none'; } } + + /** + * Show a dialog to select a collection + * @param {Array} collections - List of collection names + * @param {string} message - Dialog message + * @returns {Promise} Selected collection or null if cancelled + */ + async showCollectionSelectionDialog(collections, message) { + // Prevent duplicate modals + if (this._collectionModalShowing) { + Logger.warn('Collection selection modal is already showing'); + return null; + } + this._collectionModalShowing = true; + + // Create a custom modal with radio buttons for collection selection + const modal = document.createElement('div'); + modal.className = 'modal fade'; + modal.innerHTML = ` + + `; + + document.body.appendChild(modal); + const bsModal = new bootstrap.Modal(modal); + + // Extract file name and action from message + // Message format: "Copy filename to collection:" or "Move filename to collection:" + const messageMatch = message.match(/(Copy|Move)\s+(.+?)\s+to collection:/); + const action = messageMatch ? messageMatch[1].toLowerCase() : 'copy'; + const fileName = messageMatch ? messageMatch[2] : 'item'; + + // Get confirmation preview elements + const confirmationPreview = modal.querySelector('#confirmationPreview'); + const confirmationText = modal.querySelector('#confirmationText'); + + // Function to update confirmation message + const updateConfirmation = (collectionName) => { + confirmationText.textContent = `"${fileName}" will be ${action}d to "${collectionName}"`; + confirmationPreview.style.display = 'block'; + }; + + // Add hover effects and click handlers for collection options + const collectionOptions = modal.querySelectorAll('.collection-option'); + collectionOptions.forEach(option => { + // Hover effect + option.addEventListener('mouseenter', () => { + option.style.backgroundColor = 'var(--bs-light)'; + option.style.borderColor = 'var(--bs-primary)'; + }); + option.addEventListener('mouseleave', () => { + const radio = option.querySelector('input[type="radio"]'); + if (!radio.checked) { + option.style.backgroundColor = ''; + option.style.borderColor = ''; + } + }); + + // Click on the whole div to select + option.addEventListener('click', () => { + const radio = option.querySelector('input[type="radio"]'); + radio.checked = true; + + // Update confirmation message + updateConfirmation(radio.value); + + // Update all options styling + collectionOptions.forEach(opt => { + const r = opt.querySelector('input[type="radio"]'); + if (r.checked) { + opt.style.backgroundColor = 'var(--bs-primary-bg-subtle)'; + opt.style.borderColor = 'var(--bs-primary)'; + } else { + opt.style.backgroundColor = ''; + opt.style.borderColor = ''; + } + }); + }); + + // Set initial styling for checked option + const radio = option.querySelector('input[type="radio"]'); + if (radio.checked) { + option.style.backgroundColor = 'var(--bs-primary-bg-subtle)'; + option.style.borderColor = 'var(--bs-primary)'; + // Show initial confirmation + updateConfirmation(radio.value); + } + }); + + return new Promise((resolve) => { + const confirmBtn = modal.querySelector('#confirmCollectionBtn'); + + confirmBtn.addEventListener('click', () => { + const selected = modal.querySelector('input[name="collection"]:checked'); + this._collectionModalShowing = false; + bsModal.hide(); + resolve(selected ? selected.value : null); + }); + + modal.addEventListener('hidden.bs.modal', () => { + modal.remove(); + this._collectionModalShowing = false; + resolve(null); + }); + + bsModal.show(); + }); + } + + /** + * Copy a file or folder to another collection + */ + async copyToCollection(path, isDir, sourceCollection, targetCollection) { + try { + Logger.info(`Copying ${path} from ${sourceCollection} to ${targetCollection}`); + + if (isDir) { + // Copy folder recursively + await this.copyFolderToCollection(path, sourceCollection, targetCollection); + } else { + // Copy single file + await this.copyFileToCollection(path, sourceCollection, targetCollection); + } + + showNotification(`Copied to ${targetCollection}`, 'success'); + } catch (error) { + Logger.error('Failed to copy to collection:', error); + showNotification('Failed to copy to collection', 'error'); + throw error; + } + } + + /** + * Move a file or folder to another collection + */ + async moveToCollection(path, isDir, sourceCollection, targetCollection) { + try { + Logger.info(`Moving ${path} from ${sourceCollection} to ${targetCollection}`); + + // First copy + await this.copyToCollection(path, isDir, sourceCollection, targetCollection); + + // Then delete from source + await this.webdavClient.delete(path); + await this.fileTree.load(); + + showNotification(`Moved to ${targetCollection}`, 'success'); + } catch (error) { + Logger.error('Failed to move to collection:', error); + showNotification('Failed to move to collection', 'error'); + throw error; + } + } + + /** + * Copy a single file to another collection + */ + async copyFileToCollection(path, sourceCollection, targetCollection) { + // Read file from source collection + const content = await this.webdavClient.get(path); + + // Write to target collection + const originalCollection = this.webdavClient.currentCollection; + this.webdavClient.setCollection(targetCollection); + + // Ensure parent directories exist in target collection + await this.webdavClient.ensureParentDirectories(path); + + await this.webdavClient.put(path, content); + this.webdavClient.setCollection(originalCollection); + } + + /** + * Copy a folder recursively to another collection + * @param {string} folderPath - Path of the folder to copy + * @param {string} sourceCollection - Source collection name + * @param {string} targetCollection - Target collection name + * @param {Set} visitedPaths - Set of already visited paths to prevent infinite loops + */ + async copyFolderToCollection(folderPath, sourceCollection, targetCollection, visitedPaths = new Set()) { + // Prevent infinite loops by tracking visited paths + if (visitedPaths.has(folderPath)) { + Logger.warn(`Skipping already visited path: ${folderPath}`); + return; + } + visitedPaths.add(folderPath); + + Logger.info(`Copying folder: ${folderPath} from ${sourceCollection} to ${targetCollection}`); + + // Set to source collection to list items + const originalCollection = this.webdavClient.currentCollection; + this.webdavClient.setCollection(sourceCollection); + + // Get only direct children (not recursive to avoid infinite loop) + const items = await this.webdavClient.list(folderPath, false); + Logger.debug(`Found ${items.length} items in ${folderPath}:`, items.map(i => i.path)); + + // Create the folder in target collection + this.webdavClient.setCollection(targetCollection); + + try { + // Ensure parent directories exist first + await this.webdavClient.ensureParentDirectories(folderPath + '/dummy.txt'); + // Then create the folder itself + await this.webdavClient.createFolder(folderPath); + Logger.debug(`Created folder: ${folderPath}`); + } catch (error) { + // Folder might already exist (405 Method Not Allowed), ignore error + if (error.message && error.message.includes('405')) { + Logger.debug(`Folder ${folderPath} already exists (405)`); + } else { + Logger.debug('Folder might already exist:', error); + } + } + + // Copy all items + for (const item of items) { + if (item.isDirectory) { + // Recursively copy subdirectory + await this.copyFolderToCollection(item.path, sourceCollection, targetCollection, visitedPaths); + } else { + // Copy file + this.webdavClient.setCollection(sourceCollection); + const content = await this.webdavClient.get(item.path); + this.webdavClient.setCollection(targetCollection); + // Ensure parent directories exist before copying file + await this.webdavClient.ensureParentDirectories(item.path); + await this.webdavClient.put(item.path, content); + Logger.debug(`Copied file: ${item.path}`); + } + } + + this.webdavClient.setCollection(originalCollection); + } } \ No newline at end of file diff --git a/static/js/utils.js b/static/js/utils.js index 8bee004..926863a 100644 --- a/static/js/utils.js +++ b/static/js/utils.js @@ -75,6 +75,55 @@ const PathUtils = { isDescendant(path, ancestorPath) { if (!path || !ancestorPath) return false; return path.startsWith(ancestorPath + '/'); + }, + + /** + * Check if a file is a binary/non-editable file based on extension + * @param {string} path - The file path + * @returns {boolean} True if the file is binary/non-editable + * @example PathUtils.isBinaryFile('image.png') // true + * @example PathUtils.isBinaryFile('document.md') // false + */ + isBinaryFile(path) { + const extension = PathUtils.getExtension(path).toLowerCase(); + const binaryExtensions = [ + // Images + 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'svg', 'webp', 'tiff', 'tif', + // Documents + 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', + // Archives + 'zip', 'rar', '7z', 'tar', 'gz', 'bz2', + // Executables + 'exe', 'dll', 'so', 'dylib', 'app', + // Media + 'mp3', 'mp4', 'avi', 'mov', 'wmv', 'flv', 'wav', 'ogg', + // Other binary formats + 'bin', 'dat', 'db', 'sqlite' + ]; + return binaryExtensions.includes(extension); + }, + + /** + * Get a human-readable file type description + * @param {string} path - The file path + * @returns {string} The file type description + * @example PathUtils.getFileType('image.png') // 'Image' + */ + getFileType(path) { + const extension = PathUtils.getExtension(path).toLowerCase(); + + const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'svg', 'webp', 'tiff', 'tif']; + const documentExtensions = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']; + const archiveExtensions = ['zip', 'rar', '7z', 'tar', 'gz', 'bz2']; + const mediaExtensions = ['mp3', 'mp4', 'avi', 'mov', 'wmv', 'flv', 'wav', 'ogg']; + + if (imageExtensions.includes(extension)) return 'Image'; + if (documentExtensions.includes(extension)) return 'Document'; + if (archiveExtensions.includes(extension)) return 'Archive'; + if (mediaExtensions.includes(extension)) return 'Media'; + if (extension === 'pdf') return 'PDF'; + + return 'File'; } }; diff --git a/static/js/webdav-client.js b/static/js/webdav-client.js index c3aa858..c2577d0 100644 --- a/static/js/webdav-client.js +++ b/static/js/webdav-client.js @@ -8,11 +8,11 @@ class WebDAVClient { this.baseUrl = baseUrl; this.currentCollection = null; } - + setCollection(collection) { this.currentCollection = collection; } - + getFullUrl(path) { if (!this.currentCollection) { throw new Error('No collection selected'); @@ -20,7 +20,7 @@ class WebDAVClient { const cleanPath = path.startsWith('/') ? path.slice(1) : path; return `${this.baseUrl}${this.currentCollection}/${cleanPath}`; } - + async getCollections() { const response = await fetch(this.baseUrl); if (!response.ok) { @@ -28,7 +28,25 @@ class WebDAVClient { } return await response.json(); } - + + async createCollection(collectionName) { + // Use POST API to create collection (not MKCOL, as collections are managed by the server) + const response = await fetch(this.baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ name: collectionName }) + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(errorData.error || `Failed to create collection: ${response.status} ${response.statusText}`); + } + + return true; + } + async propfind(path = '', depth = '1') { const url = this.getFullUrl(path); const response = await fetch(url, { @@ -38,37 +56,64 @@ class WebDAVClient { 'Content-Type': 'application/xml' } }); - + if (!response.ok) { throw new Error(`PROPFIND failed: ${response.statusText}`); } - + const xml = await response.text(); return this.parseMultiStatus(xml); } - + + /** + * List files and directories in a path + * Returns only direct children (depth=1) to avoid infinite recursion + * @param {string} path - Path to list + * @param {boolean} recursive - If true, returns all nested items (depth=infinity) + * @returns {Promise} Array of items + */ + async list(path = '', recursive = false) { + const depth = recursive ? 'infinity' : '1'; + const items = await this.propfind(path, depth); + + // If not recursive, filter to only direct children + if (!recursive && path) { + // Normalize path (remove trailing slash) + const normalizedPath = path.endsWith('/') ? path.slice(0, -1) : path; + const pathDepth = normalizedPath.split('/').length; + + // Filter items to only include direct children + return items.filter(item => { + const itemDepth = item.path.split('/').length; + return itemDepth === pathDepth + 1; + }); + } + + return items; + } + async get(path) { const url = this.getFullUrl(path); const response = await fetch(url); - + if (!response.ok) { throw new Error(`GET failed: ${response.statusText}`); } - + return await response.text(); } - + async getBinary(path) { const url = this.getFullUrl(path); const response = await fetch(url); - + if (!response.ok) { throw new Error(`GET failed: ${response.statusText}`); } - + return await response.blob(); } - + async put(path, content) { const url = this.getFullUrl(path); const response = await fetch(url, { @@ -78,109 +123,144 @@ class WebDAVClient { }, body: content }); - + if (!response.ok) { throw new Error(`PUT failed: ${response.statusText}`); } - + return true; } - + async putBinary(path, content) { const url = this.getFullUrl(path); const response = await fetch(url, { method: 'PUT', body: content }); - + if (!response.ok) { throw new Error(`PUT failed: ${response.statusText}`); } - + return true; } - + async delete(path) { const url = this.getFullUrl(path); const response = await fetch(url, { method: 'DELETE' }); - + if (!response.ok) { throw new Error(`DELETE failed: ${response.statusText}`); } - + return true; } - + async copy(sourcePath, destPath) { const sourceUrl = this.getFullUrl(sourcePath); const destUrl = this.getFullUrl(destPath); - + const response = await fetch(sourceUrl, { method: 'COPY', headers: { 'Destination': destUrl } }); - + if (!response.ok) { throw new Error(`COPY failed: ${response.statusText}`); } - + return true; } - + async move(sourcePath, destPath) { const sourceUrl = this.getFullUrl(sourcePath); const destUrl = this.getFullUrl(destPath); - + const response = await fetch(sourceUrl, { method: 'MOVE', headers: { 'Destination': destUrl } }); - + if (!response.ok) { throw new Error(`MOVE failed: ${response.statusText}`); } - + return true; } - + async mkcol(path) { const url = this.getFullUrl(path); const response = await fetch(url, { method: 'MKCOL' }); - + if (!response.ok && response.status !== 405) { // 405 means already exists throw new Error(`MKCOL failed: ${response.statusText}`); } - + return true; } - + + // Alias for mkcol + async createFolder(path) { + return await this.mkcol(path); + } + + /** + * Ensure all parent directories exist for a given path + * Creates missing parent directories recursively + */ + async ensureParentDirectories(filePath) { + const parts = filePath.split('/'); + + // Remove the filename (last part) + parts.pop(); + + // If no parent directories, nothing to do + if (parts.length === 0) { + return; + } + + // Create each parent directory level + let currentPath = ''; + for (const part of parts) { + currentPath = currentPath ? `${currentPath}/${part}` : part; + + try { + await this.mkcol(currentPath); + } catch (error) { + // Ignore errors - directory might already exist + // Only log for debugging + console.debug(`Directory ${currentPath} might already exist:`, error.message); + } + } + } + async includeFile(path) { try { // Parse path: "collection:path/to/file" or "path/to/file" let targetCollection = this.currentCollection; let targetPath = path; - + if (path.includes(':')) { [targetCollection, targetPath] = path.split(':'); } - + // Temporarily switch collection const originalCollection = this.currentCollection; this.currentCollection = targetCollection; - + const content = await this.get(targetPath); - + // Restore collection this.currentCollection = originalCollection; - + return content; } catch (error) { throw new Error(`Cannot include file "${path}": ${error.message}`); @@ -191,32 +271,32 @@ class WebDAVClient { const parser = new DOMParser(); const doc = parser.parseFromString(xml, 'text/xml'); const responses = doc.getElementsByTagNameNS('DAV:', 'response'); - + const items = []; for (let i = 0; i < responses.length; i++) { const response = responses[i]; const href = response.getElementsByTagNameNS('DAV:', 'href')[0].textContent; const propstat = response.getElementsByTagNameNS('DAV:', 'propstat')[0]; const prop = propstat.getElementsByTagNameNS('DAV:', 'prop')[0]; - + // Check if it's a collection (directory) const resourcetype = prop.getElementsByTagNameNS('DAV:', 'resourcetype')[0]; const isDirectory = resourcetype.getElementsByTagNameNS('DAV:', 'collection').length > 0; - + // Get size const contentlengthEl = prop.getElementsByTagNameNS('DAV:', 'getcontentlength')[0]; const size = contentlengthEl ? parseInt(contentlengthEl.textContent) : 0; - + // Extract path relative to collection const pathParts = href.split(`/${this.currentCollection}/`); const relativePath = pathParts.length > 1 ? pathParts[1] : ''; - + // Skip the collection root itself if (!relativePath) continue; - + // Remove trailing slash from directories const cleanPath = relativePath.endsWith('/') ? relativePath.slice(0, -1) : relativePath; - + items.push({ path: cleanPath, name: cleanPath.split('/').pop(), @@ -224,14 +304,14 @@ class WebDAVClient { size }); } - + return items; } - + buildTree(items) { const root = []; const map = {}; - + // Sort items by path depth and name items.sort((a, b) => { const depthA = a.path.split('/').length; @@ -239,26 +319,26 @@ class WebDAVClient { if (depthA !== depthB) return depthA - depthB; return a.path.localeCompare(b.path); }); - + items.forEach(item => { const parts = item.path.split('/'); const parentPath = parts.slice(0, -1).join('/'); - + const node = { ...item, children: [] }; - + map[item.path] = node; - + if (parentPath && map[parentPath]) { map[parentPath].children.push(node); } else { root.push(node); } - + }); - + return root; } } diff --git a/templates/index.html b/templates/index.html index e5865d9..ad4cec0 100644 --- a/templates/index.html +++ b/templates/index.html @@ -35,22 +35,32 @@ Markdown Editor - - + - - @@ -65,7 +75,12 @@
- +
+ + +
@@ -129,6 +144,13 @@ Paste
+
+ Copy to Collection... +
+
+ Move to Collection... +
+
Delete
@@ -148,8 +170,12 @@