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
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1 @@
|
|||||||
.venv
|
.venv
|
||||||
server.log
|
|
||||||
|
|||||||
22
collections/7madah/tests/sub_tests/file1.md
Normal file
22
collections/7madah/tests/sub_tests/file1.md
Normal file
@@ -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];
|
||||||
|
```
|
||||||
|
|
||||||
9
collections/7madah/tests/test.md
Normal file
9
collections/7madah/tests/test.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
|
||||||
|
# test
|
||||||
|
|
||||||
|
- 1
|
||||||
|
- 2
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
!!include path:test2.md
|
||||||
12
collections/7madah/tests/test2.md
Normal file
12
collections/7madah/tests/test2.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
|
||||||
|
## test2
|
||||||
|
|
||||||
|
- something
|
||||||
|
- another thing
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
426
collections/7madah/tests/test3.md
Normal file
426
collections/7madah/tests/test3.md
Normal file
@@ -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.
|
||||||
18
collections/notes/introduction.md
Normal file
18
collections/notes/introduction.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Introduction
|
||||||
|
|
||||||
|
### This is an introduction
|
||||||
|
|
||||||
|
|
||||||
|
* **This is an internal image**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
* **This is an external image**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
40
collections/notes/presentation.md
Normal file
40
collections/notes/presentation.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
## Mycelium Product Presentation
|
||||||
|
|
||||||
|
This document provides an overview of the Mycelium technology stack (as commercially sold my our company GeoMind).
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
height: 0,
|
||||||
|
paddingTop: '56.25%',
|
||||||
|
marginTop: '1.6em',
|
||||||
|
marginBottom: '0.9em',
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: '8px',
|
||||||
|
willChange: 'transform'
|
||||||
|
}}>
|
||||||
|
<iframe
|
||||||
|
src="https://www.canva.com/design/DAG0UtzICsk/rqXpn5f3ibo2OpX-yDWmPQ/view?embed"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
border: 'none',
|
||||||
|
padding: 0,
|
||||||
|
margin: 0
|
||||||
|
}}
|
||||||
|
allowFullScreen={true}
|
||||||
|
allow="fullscreen">
|
||||||
|
</iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '10px' }}>
|
||||||
|
<a href="https://www.canva.com/design/DAG0UtzICsk/rqXpn5f3ibo2OpX-yDWmPQ/view"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
style={{ textDecoration: 'none' }}>
|
||||||
|
Geomind Product Intro 2025 (based on mycelium technology)
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
78
collections/notes/why.md
Normal file
78
collections/notes/why.md
Normal file
@@ -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.%
|
||||||
38
config.yaml
38
config.yaml
@@ -1,25 +1,31 @@
|
|||||||
# WsgiDAV Configuration
|
|
||||||
# Collections define WebDAV-accessible directories
|
|
||||||
|
|
||||||
collections:
|
collections:
|
||||||
documents:
|
documents:
|
||||||
path: "./collections/documents"
|
path: ./collections/documents
|
||||||
description: "General documents and notes"
|
description: General documents and notes
|
||||||
|
|
||||||
notes:
|
notes:
|
||||||
path: "./collections/notes"
|
path: ./collections/notes
|
||||||
description: "Personal notes and drafts"
|
description: Personal notes and drafts
|
||||||
|
|
||||||
projects:
|
projects:
|
||||||
path: "./collections/projects"
|
path: ./collections/projects
|
||||||
description: "Project documentation"
|
description: Project documentation
|
||||||
|
new_collectionss:
|
||||||
# Server settings
|
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:
|
server:
|
||||||
host: "localhost"
|
host: localhost
|
||||||
port: 8004
|
port: 8004
|
||||||
|
|
||||||
# WebDAV settings
|
|
||||||
webdav:
|
webdav:
|
||||||
verbose: 1
|
verbose: 1
|
||||||
enable_loggers: []
|
enable_loggers: []
|
||||||
|
|||||||
8
server_debug.log
Normal file
8
server_debug.log
Normal file
@@ -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...
|
||||||
@@ -28,8 +28,16 @@ class MarkdownEditorApp:
|
|||||||
|
|
||||||
def load_config(self, config_path):
|
def load_config(self, config_path):
|
||||||
"""Load configuration from YAML file"""
|
"""Load configuration from YAML file"""
|
||||||
|
self.config_path = config_path
|
||||||
with open(config_path, 'r') as f:
|
with open(config_path, 'r') as f:
|
||||||
return yaml.safe_load(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):
|
def setup_collections(self):
|
||||||
"""Create collection directories if they don't exist"""
|
"""Create collection directories if they don't exist"""
|
||||||
@@ -92,6 +100,10 @@ class MarkdownEditorApp:
|
|||||||
if path == '/fs/' and method == 'GET':
|
if path == '/fs/' and method == 'GET':
|
||||||
return self.handle_collections_list(environ, start_response)
|
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)
|
# Check if path starts with a collection name (for SPA routing)
|
||||||
# This handles URLs like /notes/ttt or /documents/file.md
|
# This handles URLs like /notes/ttt or /documents/file.md
|
||||||
# MUST be checked BEFORE WebDAV routing to prevent WebDAV from intercepting SPA routes
|
# MUST be checked BEFORE WebDAV routing to prevent WebDAV from intercepting SPA routes
|
||||||
@@ -113,14 +125,86 @@ class MarkdownEditorApp:
|
|||||||
"""Return list of available collections"""
|
"""Return list of available collections"""
|
||||||
collections = list(self.collections.keys())
|
collections = list(self.collections.keys())
|
||||||
response_body = json.dumps(collections).encode('utf-8')
|
response_body = json.dumps(collections).encode('utf-8')
|
||||||
|
|
||||||
start_response('200 OK', [
|
start_response('200 OK', [
|
||||||
('Content-Type', 'application/json'),
|
('Content-Type', 'application/json'),
|
||||||
('Content-Length', str(len(response_body))),
|
('Content-Length', str(len(response_body))),
|
||||||
('Access-Control-Allow-Origin', '*')
|
('Access-Control-Allow-Origin', '*')
|
||||||
])
|
])
|
||||||
|
|
||||||
return [response_body]
|
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):
|
def handle_static(self, environ, start_response):
|
||||||
"""Serve static files"""
|
"""Serve static files"""
|
||||||
|
|||||||
@@ -143,6 +143,15 @@ body.dark-mode .context-menu {
|
|||||||
animation: slideIn 0.3s ease;
|
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 {
|
@keyframes slideIn {
|
||||||
from {
|
from {
|
||||||
transform: translateX(400px);
|
transform: translateX(400px);
|
||||||
@@ -276,4 +285,83 @@ body.dark-mode .modal-footer {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
margin-top: 8px;
|
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;
|
||||||
}
|
}
|
||||||
183
static/js/app.js
183
static/js/app.js
@@ -208,10 +208,17 @@ async function loadFileFromURL(collection, filePath) {
|
|||||||
await showDirectoryPreview(filePath);
|
await showDirectoryPreview(filePath);
|
||||||
fileTree.selectAndExpandPath(filePath);
|
fileTree.selectAndExpandPath(filePath);
|
||||||
} else if (node) {
|
} else if (node) {
|
||||||
// It's a file, load it
|
// It's a file, check if it's binary
|
||||||
console.log('[loadFileFromURL] Loading file');
|
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 {
|
} else {
|
||||||
console.error(`[loadFileFromURL] Path not found in file tree: ${filePath}`);
|
console.error(`[loadFileFromURL] Path not found in file tree: ${filePath}`);
|
||||||
}
|
}
|
||||||
@@ -269,6 +276,37 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
collectionSelector = new CollectionSelector('collectionSelect', webdavClient);
|
collectionSelector = new CollectionSelector('collectionSelect', webdavClient);
|
||||||
await collectionSelector.load();
|
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
|
// Setup URL routing
|
||||||
setupPopStateListener();
|
setupPopStateListener();
|
||||||
|
|
||||||
@@ -281,11 +319,102 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
fileTree = new FileTree('fileTree', webdavClient);
|
fileTree = new FileTree('fileTree', webdavClient);
|
||||||
fileTree.onFileSelect = async (item) => {
|
fileTree.onFileSelect = async (item) => {
|
||||||
try {
|
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 = `
|
||||||
|
<div style="padding: 20px; text-align: center;">
|
||||||
|
<h3>${fileName}</h3>
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 20px;">Image Preview (Read-only)</p>
|
||||||
|
<img src="${fileUrl}" alt="${fileName}" style="max-width: 100%; height: auto; border: 1px solid var(--border-color); border-radius: 4px;">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else if (fileType === 'PDF') {
|
||||||
|
// Preview PDFs
|
||||||
|
previewHtml = `
|
||||||
|
<div style="padding: 20px;">
|
||||||
|
<h3>${fileName}</h3>
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 20px;">PDF Preview (Read-only)</p>
|
||||||
|
<iframe src="${fileUrl}" style="width: 100%; height: 80vh; border: 1px solid var(--border-color); border-radius: 4px;"></iframe>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
// For other binary files, show download link
|
||||||
|
previewHtml = `
|
||||||
|
<div style="padding: 20px;">
|
||||||
|
<h3>${fileName}</h3>
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 20px;">${fileType} File (Read-only)</p>
|
||||||
|
<p>This file cannot be previewed in the browser.</p>
|
||||||
|
<a href="${fileUrl}" download="${fileName}" class="btn btn-primary">Download ${fileName}</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
await editor.loadFile(item.path);
|
||||||
// Highlight the file in the tree and expand parent directories
|
// Highlight the file in the tree and expand parent directories
|
||||||
fileTree.selectAndExpandPath(item.path);
|
fileTree.selectAndExpandPath(item.path);
|
||||||
// Update URL to reflect current file
|
// Update URL to reflect current file
|
||||||
const currentCollection = collectionSelector.getCurrentCollection();
|
|
||||||
updateURL(currentCollection, item.path, isEditMode);
|
updateURL(currentCollection, item.path, isEditMode);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error('Failed to select file:', error);
|
Logger.error('Failed to select file:', error);
|
||||||
@@ -332,9 +461,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
const { collection: urlCollection, filePath: urlFilePath } = parseURLPath();
|
const { collection: urlCollection, filePath: urlFilePath } = parseURLPath();
|
||||||
console.log('[URL PARSE]', { urlCollection, urlFilePath });
|
console.log('[URL PARSE]', { urlCollection, urlFilePath });
|
||||||
|
|
||||||
if (urlCollection && urlFilePath) {
|
if (urlCollection) {
|
||||||
console.log('[URL LOAD] Loading from URL:', urlCollection, urlFilePath);
|
|
||||||
|
|
||||||
// First ensure the collection is set
|
// First ensure the collection is set
|
||||||
const currentCollection = collectionSelector.getCurrentCollection();
|
const currentCollection = collectionSelector.getCurrentCollection();
|
||||||
if (currentCollection !== urlCollection) {
|
if (currentCollection !== urlCollection) {
|
||||||
@@ -343,11 +470,17 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
await fileTree.load();
|
await fileTree.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now load the file from URL
|
// If there's a file path in the URL, load it
|
||||||
console.log('[URL LOAD] Calling loadFileFromURL');
|
if (urlFilePath) {
|
||||||
await loadFileFromURL(urlCollection, 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) {
|
} 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();
|
await autoLoadPageInViewMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,11 +538,34 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
|
|
||||||
// Initialize file tree actions manager
|
// Initialize file tree actions manager
|
||||||
window.fileTreeActions = new FileTreeActions(webdavClient, fileTree, editor);
|
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 {
|
} else {
|
||||||
// In view mode, hide editor buttons
|
// In view mode, hide editor buttons
|
||||||
document.getElementById('newBtn').style.display = 'none';
|
document.getElementById('newBtn').style.display = 'none';
|
||||||
document.getElementById('saveBtn').style.display = 'none';
|
document.getElementById('saveBtn').style.display = 'none';
|
||||||
document.getElementById('deleteBtn').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
|
// Auto-load last viewed page or first file
|
||||||
await autoLoadPageInViewMode();
|
await autoLoadPageInViewMode();
|
||||||
@@ -498,10 +654,11 @@ async function handleEditorFileDrop(file) {
|
|||||||
const uploadedPath = await fileTree.uploadFile(targetDir, file);
|
const uploadedPath = await fileTree.uploadFile(targetDir, file);
|
||||||
|
|
||||||
// Insert markdown link at cursor
|
// 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 isImage = file.type.startsWith('image/');
|
||||||
const link = isImage
|
const link = isImage
|
||||||
? ``
|
? ``
|
||||||
: `[${file.name}](/${webdavClient.currentCollection}/${uploadedPath})`;
|
: `[${file.name}](${uploadedPath})`;
|
||||||
|
|
||||||
editor.insertAtCursor(link);
|
editor.insertAtCursor(link);
|
||||||
showNotification(`Uploaded and inserted link`, 'success');
|
showNotification(`Uploaded and inserted link`, 'success');
|
||||||
|
|||||||
@@ -26,12 +26,21 @@ class CollectionSelector {
|
|||||||
this.select.appendChild(option);
|
this.select.appendChild(option);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Try to restore previously selected collection from localStorage
|
// Determine which collection to select (priority: URL > localStorage > first)
|
||||||
const savedCollection = localStorage.getItem(this.storageKey);
|
|
||||||
let collectionToSelect = collections[0]; // Default to first
|
let collectionToSelect = collections[0]; // Default to first
|
||||||
|
|
||||||
if (savedCollection && collections.includes(savedCollection)) {
|
// Check URL first (highest priority)
|
||||||
collectionToSelect = savedCollection;
|
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) {
|
if (collections.length > 0) {
|
||||||
@@ -48,14 +57,17 @@ class CollectionSelector {
|
|||||||
// Save to localStorage
|
// Save to localStorage
|
||||||
localStorage.setItem(this.storageKey, collection);
|
localStorage.setItem(this.storageKey, collection);
|
||||||
this.webdavClient.setCollection(collection);
|
this.webdavClient.setCollection(collection);
|
||||||
|
|
||||||
Logger.info(`Collection changed to: ${collection}`);
|
Logger.info(`Collection changed to: ${collection}`);
|
||||||
|
|
||||||
|
// Update URL to reflect collection change
|
||||||
|
this.updateURLForCollection(collection);
|
||||||
|
|
||||||
if (this.onChange) {
|
if (this.onChange) {
|
||||||
this.onChange(collection);
|
this.onChange(collection);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Logger.debug(`Loaded ${collections.length} collections`);
|
Logger.debug(`Loaded ${collections.length} collections`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error('Failed to load collections:', error);
|
Logger.error('Failed to load collections:', error);
|
||||||
@@ -83,9 +95,12 @@ class CollectionSelector {
|
|||||||
this.select.value = collection;
|
this.select.value = collection;
|
||||||
localStorage.setItem(this.storageKey, collection);
|
localStorage.setItem(this.storageKey, collection);
|
||||||
this.webdavClient.setCollection(collection);
|
this.webdavClient.setCollection(collection);
|
||||||
|
|
||||||
Logger.info(`Collection set to: ${collection}`);
|
Logger.info(`Collection set to: ${collection}`);
|
||||||
|
|
||||||
|
// Update URL to reflect collection change
|
||||||
|
this.updateURLForCollection(collection);
|
||||||
|
|
||||||
if (this.onChange) {
|
if (this.onChange) {
|
||||||
this.onChange(collection);
|
this.onChange(collection);
|
||||||
}
|
}
|
||||||
@@ -93,6 +108,43 @@ class CollectionSelector {
|
|||||||
Logger.warn(`Collection "${collection}" not found in available collections`);
|
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: /<collection>/ or /<collection>/<file_path>
|
||||||
|
* @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
|
// Make CollectionSelector globally available
|
||||||
|
|||||||
@@ -49,11 +49,11 @@ class ModalManager {
|
|||||||
|
|
||||||
// Update button styling based on danger level
|
// Update button styling based on danger level
|
||||||
if (isDangerous) {
|
if (isDangerous) {
|
||||||
this.confirmButton.className = 'btn btn-danger';
|
this.confirmButton.className = 'btn-flat btn-flat-danger';
|
||||||
this.confirmButton.textContent = 'Delete';
|
this.confirmButton.innerHTML = '<i class="bi bi-trash"></i> Delete';
|
||||||
} else {
|
} else {
|
||||||
this.confirmButton.className = 'btn btn-primary';
|
this.confirmButton.className = 'btn-flat btn-flat-primary';
|
||||||
this.confirmButton.textContent = 'OK';
|
this.confirmButton.innerHTML = '<i class="bi bi-check-circle"></i> OK';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up event handlers
|
// Set up event handlers
|
||||||
@@ -74,6 +74,8 @@ class ModalManager {
|
|||||||
|
|
||||||
// Focus confirm button after modal is shown
|
// Focus confirm button after modal is shown
|
||||||
this.modalElement.addEventListener('shown.bs.modal', () => {
|
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();
|
this.confirmButton.focus();
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
});
|
});
|
||||||
@@ -103,8 +105,8 @@ class ModalManager {
|
|||||||
this.inputElement.value = defaultValue;
|
this.inputElement.value = defaultValue;
|
||||||
|
|
||||||
// Reset button to primary style for prompts
|
// Reset button to primary style for prompts
|
||||||
this.confirmButton.className = 'btn btn-primary';
|
this.confirmButton.className = 'btn-flat btn-flat-primary';
|
||||||
this.confirmButton.textContent = 'OK';
|
this.confirmButton.innerHTML = '<i class="bi bi-check-circle"></i> OK';
|
||||||
|
|
||||||
// Set up event handlers
|
// Set up event handlers
|
||||||
this.confirmButton.onclick = (e) => {
|
this.confirmButton.onclick = (e) => {
|
||||||
@@ -132,6 +134,8 @@ class ModalManager {
|
|||||||
|
|
||||||
// Focus and select input after modal is shown
|
// Focus and select input after modal is shown
|
||||||
this.modalElement.addEventListener('shown.bs.modal', () => {
|
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.focus();
|
||||||
this.inputElement.select();
|
this.inputElement.select();
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
@@ -161,6 +165,11 @@ class ModalManager {
|
|||||||
this.currentResolver = null;
|
this.currentResolver = null;
|
||||||
this.isShowing = false;
|
this.isShowing = false;
|
||||||
this.modal.hide();
|
this.modal.hide();
|
||||||
|
|
||||||
|
// Restore aria-hidden after modal is hidden
|
||||||
|
this.modalElement.addEventListener('hidden.bs.modal', () => {
|
||||||
|
this.modalElement.setAttribute('aria-hidden', 'true');
|
||||||
|
}, { once: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class MarkdownEditor {
|
|||||||
this.lastViewedStorageKey = 'lastViewedPage'; // localStorage key for tracking last viewed page
|
this.lastViewedStorageKey = 'lastViewedPage'; // localStorage key for tracking last viewed page
|
||||||
this.readOnly = readOnly; // Whether editor is in read-only mode
|
this.readOnly = readOnly; // Whether editor is in read-only mode
|
||||||
this.editor = null; // Will be initialized later
|
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)
|
// Only initialize CodeMirror if not in read-only mode (view mode)
|
||||||
if (!readOnly) {
|
if (!readOnly) {
|
||||||
@@ -87,9 +88,88 @@ class MarkdownEditor {
|
|||||||
initMarkdown() {
|
initMarkdown() {
|
||||||
if (window.marked) {
|
if (window.marked) {
|
||||||
this.marked = 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 `<div class="alert alert-warning">⚠️ Invalid image data detected. Please re-upload the image.</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 `<img src="${href}"${altAttr}${titleAttr}>`;
|
||||||
|
};
|
||||||
|
|
||||||
this.marked.setOptions({
|
this.marked.setOptions({
|
||||||
breaks: true,
|
breaks: true,
|
||||||
gfm: true,
|
gfm: true,
|
||||||
|
renderer: renderer,
|
||||||
highlight: (code, lang) => {
|
highlight: (code, lang) => {
|
||||||
if (lang && window.Prism.languages[lang]) {
|
if (lang && window.Prism.languages[lang]) {
|
||||||
return window.Prism.highlight(code, window.Prism.languages[lang], lang);
|
return window.Prism.highlight(code, window.Prism.languages[lang], lang);
|
||||||
@@ -131,6 +211,9 @@ class MarkdownEditor {
|
|||||||
*/
|
*/
|
||||||
async loadFile(path) {
|
async loadFile(path) {
|
||||||
try {
|
try {
|
||||||
|
// Reset custom preview flag when loading text files
|
||||||
|
this.isShowingCustomPreview = false;
|
||||||
|
|
||||||
const content = await this.webdavClient.get(path);
|
const content = await this.webdavClient.get(path);
|
||||||
this.currentFile = path;
|
this.currentFile = path;
|
||||||
|
|
||||||
@@ -337,6 +420,12 @@ class MarkdownEditor {
|
|||||||
* Calls renderPreview with content from editor
|
* Calls renderPreview with content from editor
|
||||||
*/
|
*/
|
||||||
async updatePreview() {
|
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) {
|
if (this.editor) {
|
||||||
await this.renderPreview();
|
await this.renderPreview();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,6 +240,56 @@ class FileTreeActions {
|
|||||||
};
|
};
|
||||||
|
|
||||||
input.click();
|
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';
|
pasteItem.style.display = this.clipboard ? 'flex' : 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a dialog to select a collection
|
||||||
|
* @param {Array<string>} collections - List of collection names
|
||||||
|
* @param {string} message - Dialog message
|
||||||
|
* @returns {Promise<string|null>} 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 = `
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title"><i class="bi bi-folder-symlink"></i> Select Collection</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="mb-3">${message}</p>
|
||||||
|
<div class="collection-list" style="max-height: 300px; overflow-y: auto;">
|
||||||
|
${collections.map((c, i) => `
|
||||||
|
<div class="form-check p-2 mb-2 rounded border collection-option" style="cursor: pointer; transition: all 0.2s;">
|
||||||
|
<input class="form-check-input" type="radio" name="collection" id="collection-${i}" value="${c}" ${i === 0 ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label w-100" for="collection-${i}" style="cursor: pointer;">
|
||||||
|
<i class="bi bi-folder"></i> <strong>${c}</strong>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
<div id="confirmationPreview" class="alert alert-info mt-3" style="display: none;">
|
||||||
|
<i class="bi bi-info-circle"></i> <span id="confirmationText"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn-flat btn-flat-secondary" data-bs-dismiss="modal">
|
||||||
|
<i class="bi bi-x-circle"></i> Cancel
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-flat btn-flat-primary" id="confirmCollectionBtn">
|
||||||
|
<i class="bi bi-check-circle"></i> OK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -75,6 +75,55 @@ const PathUtils = {
|
|||||||
isDescendant(path, ancestorPath) {
|
isDescendant(path, ancestorPath) {
|
||||||
if (!path || !ancestorPath) return false;
|
if (!path || !ancestorPath) return false;
|
||||||
return path.startsWith(ancestorPath + '/');
|
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';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ class WebDAVClient {
|
|||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
this.currentCollection = null;
|
this.currentCollection = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
setCollection(collection) {
|
setCollection(collection) {
|
||||||
this.currentCollection = collection;
|
this.currentCollection = collection;
|
||||||
}
|
}
|
||||||
|
|
||||||
getFullUrl(path) {
|
getFullUrl(path) {
|
||||||
if (!this.currentCollection) {
|
if (!this.currentCollection) {
|
||||||
throw new Error('No collection selected');
|
throw new Error('No collection selected');
|
||||||
@@ -20,7 +20,7 @@ class WebDAVClient {
|
|||||||
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
|
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
|
||||||
return `${this.baseUrl}${this.currentCollection}/${cleanPath}`;
|
return `${this.baseUrl}${this.currentCollection}/${cleanPath}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCollections() {
|
async getCollections() {
|
||||||
const response = await fetch(this.baseUrl);
|
const response = await fetch(this.baseUrl);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -28,7 +28,25 @@ class WebDAVClient {
|
|||||||
}
|
}
|
||||||
return await response.json();
|
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') {
|
async propfind(path = '', depth = '1') {
|
||||||
const url = this.getFullUrl(path);
|
const url = this.getFullUrl(path);
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
@@ -38,37 +56,64 @@ class WebDAVClient {
|
|||||||
'Content-Type': 'application/xml'
|
'Content-Type': 'application/xml'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`PROPFIND failed: ${response.statusText}`);
|
throw new Error(`PROPFIND failed: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const xml = await response.text();
|
const xml = await response.text();
|
||||||
return this.parseMultiStatus(xml);
|
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>} 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) {
|
async get(path) {
|
||||||
const url = this.getFullUrl(path);
|
const url = this.getFullUrl(path);
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`GET failed: ${response.statusText}`);
|
throw new Error(`GET failed: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.text();
|
return await response.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBinary(path) {
|
async getBinary(path) {
|
||||||
const url = this.getFullUrl(path);
|
const url = this.getFullUrl(path);
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`GET failed: ${response.statusText}`);
|
throw new Error(`GET failed: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.blob();
|
return await response.blob();
|
||||||
}
|
}
|
||||||
|
|
||||||
async put(path, content) {
|
async put(path, content) {
|
||||||
const url = this.getFullUrl(path);
|
const url = this.getFullUrl(path);
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
@@ -78,109 +123,144 @@ class WebDAVClient {
|
|||||||
},
|
},
|
||||||
body: content
|
body: content
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`PUT failed: ${response.statusText}`);
|
throw new Error(`PUT failed: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async putBinary(path, content) {
|
async putBinary(path, content) {
|
||||||
const url = this.getFullUrl(path);
|
const url = this.getFullUrl(path);
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: content
|
body: content
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`PUT failed: ${response.statusText}`);
|
throw new Error(`PUT failed: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(path) {
|
async delete(path) {
|
||||||
const url = this.getFullUrl(path);
|
const url = this.getFullUrl(path);
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`DELETE failed: ${response.statusText}`);
|
throw new Error(`DELETE failed: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async copy(sourcePath, destPath) {
|
async copy(sourcePath, destPath) {
|
||||||
const sourceUrl = this.getFullUrl(sourcePath);
|
const sourceUrl = this.getFullUrl(sourcePath);
|
||||||
const destUrl = this.getFullUrl(destPath);
|
const destUrl = this.getFullUrl(destPath);
|
||||||
|
|
||||||
const response = await fetch(sourceUrl, {
|
const response = await fetch(sourceUrl, {
|
||||||
method: 'COPY',
|
method: 'COPY',
|
||||||
headers: {
|
headers: {
|
||||||
'Destination': destUrl
|
'Destination': destUrl
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`COPY failed: ${response.statusText}`);
|
throw new Error(`COPY failed: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async move(sourcePath, destPath) {
|
async move(sourcePath, destPath) {
|
||||||
const sourceUrl = this.getFullUrl(sourcePath);
|
const sourceUrl = this.getFullUrl(sourcePath);
|
||||||
const destUrl = this.getFullUrl(destPath);
|
const destUrl = this.getFullUrl(destPath);
|
||||||
|
|
||||||
const response = await fetch(sourceUrl, {
|
const response = await fetch(sourceUrl, {
|
||||||
method: 'MOVE',
|
method: 'MOVE',
|
||||||
headers: {
|
headers: {
|
||||||
'Destination': destUrl
|
'Destination': destUrl
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`MOVE failed: ${response.statusText}`);
|
throw new Error(`MOVE failed: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async mkcol(path) {
|
async mkcol(path) {
|
||||||
const url = this.getFullUrl(path);
|
const url = this.getFullUrl(path);
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'MKCOL'
|
method: 'MKCOL'
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok && response.status !== 405) { // 405 means already exists
|
if (!response.ok && response.status !== 405) { // 405 means already exists
|
||||||
throw new Error(`MKCOL failed: ${response.statusText}`);
|
throw new Error(`MKCOL failed: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
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) {
|
async includeFile(path) {
|
||||||
try {
|
try {
|
||||||
// Parse path: "collection:path/to/file" or "path/to/file"
|
// Parse path: "collection:path/to/file" or "path/to/file"
|
||||||
let targetCollection = this.currentCollection;
|
let targetCollection = this.currentCollection;
|
||||||
let targetPath = path;
|
let targetPath = path;
|
||||||
|
|
||||||
if (path.includes(':')) {
|
if (path.includes(':')) {
|
||||||
[targetCollection, targetPath] = path.split(':');
|
[targetCollection, targetPath] = path.split(':');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Temporarily switch collection
|
// Temporarily switch collection
|
||||||
const originalCollection = this.currentCollection;
|
const originalCollection = this.currentCollection;
|
||||||
this.currentCollection = targetCollection;
|
this.currentCollection = targetCollection;
|
||||||
|
|
||||||
const content = await this.get(targetPath);
|
const content = await this.get(targetPath);
|
||||||
|
|
||||||
// Restore collection
|
// Restore collection
|
||||||
this.currentCollection = originalCollection;
|
this.currentCollection = originalCollection;
|
||||||
|
|
||||||
return content;
|
return content;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Cannot include file "${path}": ${error.message}`);
|
throw new Error(`Cannot include file "${path}": ${error.message}`);
|
||||||
@@ -191,32 +271,32 @@ class WebDAVClient {
|
|||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
const doc = parser.parseFromString(xml, 'text/xml');
|
const doc = parser.parseFromString(xml, 'text/xml');
|
||||||
const responses = doc.getElementsByTagNameNS('DAV:', 'response');
|
const responses = doc.getElementsByTagNameNS('DAV:', 'response');
|
||||||
|
|
||||||
const items = [];
|
const items = [];
|
||||||
for (let i = 0; i < responses.length; i++) {
|
for (let i = 0; i < responses.length; i++) {
|
||||||
const response = responses[i];
|
const response = responses[i];
|
||||||
const href = response.getElementsByTagNameNS('DAV:', 'href')[0].textContent;
|
const href = response.getElementsByTagNameNS('DAV:', 'href')[0].textContent;
|
||||||
const propstat = response.getElementsByTagNameNS('DAV:', 'propstat')[0];
|
const propstat = response.getElementsByTagNameNS('DAV:', 'propstat')[0];
|
||||||
const prop = propstat.getElementsByTagNameNS('DAV:', 'prop')[0];
|
const prop = propstat.getElementsByTagNameNS('DAV:', 'prop')[0];
|
||||||
|
|
||||||
// Check if it's a collection (directory)
|
// Check if it's a collection (directory)
|
||||||
const resourcetype = prop.getElementsByTagNameNS('DAV:', 'resourcetype')[0];
|
const resourcetype = prop.getElementsByTagNameNS('DAV:', 'resourcetype')[0];
|
||||||
const isDirectory = resourcetype.getElementsByTagNameNS('DAV:', 'collection').length > 0;
|
const isDirectory = resourcetype.getElementsByTagNameNS('DAV:', 'collection').length > 0;
|
||||||
|
|
||||||
// Get size
|
// Get size
|
||||||
const contentlengthEl = prop.getElementsByTagNameNS('DAV:', 'getcontentlength')[0];
|
const contentlengthEl = prop.getElementsByTagNameNS('DAV:', 'getcontentlength')[0];
|
||||||
const size = contentlengthEl ? parseInt(contentlengthEl.textContent) : 0;
|
const size = contentlengthEl ? parseInt(contentlengthEl.textContent) : 0;
|
||||||
|
|
||||||
// Extract path relative to collection
|
// Extract path relative to collection
|
||||||
const pathParts = href.split(`/${this.currentCollection}/`);
|
const pathParts = href.split(`/${this.currentCollection}/`);
|
||||||
const relativePath = pathParts.length > 1 ? pathParts[1] : '';
|
const relativePath = pathParts.length > 1 ? pathParts[1] : '';
|
||||||
|
|
||||||
// Skip the collection root itself
|
// Skip the collection root itself
|
||||||
if (!relativePath) continue;
|
if (!relativePath) continue;
|
||||||
|
|
||||||
// Remove trailing slash from directories
|
// Remove trailing slash from directories
|
||||||
const cleanPath = relativePath.endsWith('/') ? relativePath.slice(0, -1) : relativePath;
|
const cleanPath = relativePath.endsWith('/') ? relativePath.slice(0, -1) : relativePath;
|
||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
path: cleanPath,
|
path: cleanPath,
|
||||||
name: cleanPath.split('/').pop(),
|
name: cleanPath.split('/').pop(),
|
||||||
@@ -224,14 +304,14 @@ class WebDAVClient {
|
|||||||
size
|
size
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTree(items) {
|
buildTree(items) {
|
||||||
const root = [];
|
const root = [];
|
||||||
const map = {};
|
const map = {};
|
||||||
|
|
||||||
// Sort items by path depth and name
|
// Sort items by path depth and name
|
||||||
items.sort((a, b) => {
|
items.sort((a, b) => {
|
||||||
const depthA = a.path.split('/').length;
|
const depthA = a.path.split('/').length;
|
||||||
@@ -239,26 +319,26 @@ class WebDAVClient {
|
|||||||
if (depthA !== depthB) return depthA - depthB;
|
if (depthA !== depthB) return depthA - depthB;
|
||||||
return a.path.localeCompare(b.path);
|
return a.path.localeCompare(b.path);
|
||||||
});
|
});
|
||||||
|
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
const parts = item.path.split('/');
|
const parts = item.path.split('/');
|
||||||
const parentPath = parts.slice(0, -1).join('/');
|
const parentPath = parts.slice(0, -1).join('/');
|
||||||
|
|
||||||
const node = {
|
const node = {
|
||||||
...item,
|
...item,
|
||||||
children: []
|
children: []
|
||||||
};
|
};
|
||||||
|
|
||||||
map[item.path] = node;
|
map[item.path] = node;
|
||||||
|
|
||||||
if (parentPath && map[parentPath]) {
|
if (parentPath && map[parentPath]) {
|
||||||
map[parentPath].children.push(node);
|
map[parentPath].children.push(node);
|
||||||
} else {
|
} else {
|
||||||
root.push(node);
|
root.push(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,22 +35,32 @@
|
|||||||
<i class="bi bi-markdown"></i> Markdown Editor
|
<i class="bi bi-markdown"></i> Markdown Editor
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- Center: Edit Mode Buttons -->
|
<!-- Right: All Buttons -->
|
||||||
<div class="navbar-center d-flex gap-2">
|
<div class="ms-auto d-flex gap-2 align-items-center">
|
||||||
<button id="newBtn" class="btn btn-success btn-sm">
|
<!-- View Mode Button -->
|
||||||
|
<button id="editModeBtn" class="btn-flat btn-flat-warning" style="display: none;">
|
||||||
|
<i class="bi bi-pencil-square"></i> Edit this file
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Edit Mode Buttons -->
|
||||||
|
<button id="newBtn" class="btn-flat btn-flat-success">
|
||||||
<i class="bi bi-file-plus"></i> New
|
<i class="bi bi-file-plus"></i> New
|
||||||
</button>
|
</button>
|
||||||
<button id="saveBtn" class="btn btn-primary btn-sm">
|
<button id="saveBtn" class="btn-flat btn-flat-primary">
|
||||||
<i class="bi bi-save"></i> Save
|
<i class="bi bi-save"></i> Save
|
||||||
</button>
|
</button>
|
||||||
<button id="deleteBtn" class="btn btn-danger btn-sm">
|
<button id="deleteBtn" class="btn-flat btn-flat-danger">
|
||||||
<i class="bi bi-trash"></i> Delete
|
<i class="bi bi-trash"></i> Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<button id="exitEditModeBtn" class="btn-flat btn-flat-secondary">
|
||||||
|
<i class="bi bi-eye"></i> Exit Edit Mode
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Right: Dark Mode Toggle -->
|
<!-- Divider -->
|
||||||
<div class="navbar-right">
|
<div class="vr" style="height: 24px;"></div>
|
||||||
<button id="darkModeBtn" class="btn btn-secondary btn-sm">
|
|
||||||
|
<!-- Dark Mode Toggle -->
|
||||||
|
<button id="darkModeBtn" class="btn-flat btn-flat-secondary">
|
||||||
<i class="bi bi-moon-fill"></i>
|
<i class="bi bi-moon-fill"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,7 +75,12 @@
|
|||||||
<!-- Collection Selector -->
|
<!-- Collection Selector -->
|
||||||
<div class="collection-selector">
|
<div class="collection-selector">
|
||||||
<label class="form-label small">Collection:</label>
|
<label class="form-label small">Collection:</label>
|
||||||
<select id="collectionSelect" class="form-select form-select-sm"></select>
|
<div class="d-flex gap-1">
|
||||||
|
<select id="collectionSelect" class="form-select form-select-sm flex-grow-1"></select>
|
||||||
|
<button id="newCollectionBtn" class="btn btn-success btn-sm" title="Create New Collection">
|
||||||
|
<i class="bi bi-plus-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- File Tree -->
|
<!-- File Tree -->
|
||||||
<div id="fileTree" class="file-tree"></div>
|
<div id="fileTree" class="file-tree"></div>
|
||||||
@@ -129,6 +144,13 @@
|
|||||||
<i class="bi bi-clipboard"></i> Paste
|
<i class="bi bi-clipboard"></i> Paste
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu-divider"></div>
|
<div class="context-menu-divider"></div>
|
||||||
|
<div class="context-menu-item" data-action="copy-to-collection">
|
||||||
|
<i class="bi bi-box-arrow-right"></i> Copy to Collection...
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="move-to-collection">
|
||||||
|
<i class="bi bi-arrow-right-square"></i> Move to Collection...
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-divider"></div>
|
||||||
<div class="context-menu-item text-danger" data-action="delete">
|
<div class="context-menu-item text-danger" data-action="delete">
|
||||||
<i class="bi bi-trash"></i> Delete
|
<i class="bi bi-trash"></i> Delete
|
||||||
</div>
|
</div>
|
||||||
@@ -148,8 +170,12 @@
|
|||||||
<input type="text" id="confirmationInput" class="form-control" style="display: none;">
|
<input type="text" id="confirmationInput" class="form-control" style="display: none;">
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn-flat btn-flat-secondary" data-bs-dismiss="modal">
|
||||||
<button type="button" class="btn btn-primary" id="confirmButton">OK</button>
|
<i class="bi bi-x-circle"></i> Cancel
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-flat btn-flat-primary" id="confirmButton">
|
||||||
|
<i class="bi bi-check-circle"></i> OK
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user