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**
+
+---
+
+
+
+---
+
+* **This is an external 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).
+
+
+
+
+
+
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)
+

+
+ `;
+ } 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}](${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 = `
+
+
+
+
+
${message}
+
+ ${collections.map((c, i) => `
+
+
+
+
+ `).join('')}
+
+
+
+
+
+
+
+
+ `;
+
+ 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
-
-
-
+
+
+
@@ -148,8 +170,12 @@