diff --git a/collections/notes/test.md b/collections/notes/test.md index a62f225..43df96f 100644 --- a/collections/notes/test.md +++ b/collections/notes/test.md @@ -1,3 +1,10 @@ -# New File -Start typing... +# test + +- 1 +- 2 + +[2025 SeaweedFS Intro Slides.pdf](/notes/2025 SeaweedFS Intro Slides.pdf) + + + diff --git a/collections/notes/ttt/test.md b/collections/notes/ttt/test.md index 9fbcf0d..dc493b0 100644 --- a/collections/notes/ttt/test.md +++ b/collections/notes/ttt/test.md @@ -6,4 +6,4 @@ - +!!include path:test2.md diff --git a/collections/notes/ttt/test2.md b/collections/notes/ttt/test2.md new file mode 100644 index 0000000..cd8e39b --- /dev/null +++ b/collections/notes/ttt/test2.md @@ -0,0 +1,12 @@ + +## test2 + +- something +- another thing + + + + + + + diff --git a/server_webdav.py b/server_webdav.py index e5a75db..ac52ab1 100755 --- a/server_webdav.py +++ b/server_webdav.py @@ -53,7 +53,7 @@ class MarkdownEditorApp: config = { 'host': self.config['server']['host'], - 'port': self.config['server']['port'], + 'port': int(os.environ.get('PORT', self.config['server']['port'])), 'provider_mapping': provider_mapping, 'verbose': self.config['webdav'].get('verbose', 1), 'logging': { @@ -179,7 +179,7 @@ def main(): # Get server config host = app.config['server']['host'] - port = app.config['server']['port'] + port = int(os.environ.get('PORT', app.config['server']['port'])) print(f"\nServer starting on http://{host}:{port}") print(f"\nAvailable collections:") diff --git a/start.sh b/start.sh index ea907c0..b3d5849 100755 --- a/start.sh +++ b/start.sh @@ -19,8 +19,5 @@ echo "Activating virtual environment..." source .venv/bin/activate echo "Installing dependencies..." uv pip install wsgidav cheroot pyyaml -PORT=8004 -echo "Checking for process on port $PORT..." -lsof -ti:$PORT | xargs -r kill -9 -echo "Starting WebDAV server..." +echo "Starting WebDAV server on port $PORT..." python server_webdav.py diff --git a/static/js/editor.js b/static/js/editor.js index da22348..c7042ca 100644 --- a/static/js/editor.js +++ b/static/js/editor.js @@ -10,6 +10,7 @@ class MarkdownEditor { this.filenameInput = document.getElementById(filenameInputId); this.currentFile = null; this.webdavClient = null; + this.macroProcessor = new MacroProcessor(null); // Will be set later this.initCodeMirror(); this.initMarkdown(); @@ -86,6 +87,11 @@ class MarkdownEditor { */ setWebDAVClient(client) { this.webdavClient = client; + + // Update macro processor with client + if (this.macroProcessor) { + this.macroProcessor.webdavClient = client; + } } /** @@ -185,7 +191,7 @@ class MarkdownEditor { /** * Update preview */ - updatePreview() { + async updatePreview() { const markdown = this.editor.getValue(); const previewDiv = this.previewElement; @@ -199,15 +205,29 @@ class MarkdownEditor { } try { - // Parse markdown to HTML + // Step 1: Process macros + let processedContent = markdown; + + if (this.macroProcessor) { + const processingResult = await this.macroProcessor.processMacros(markdown); + processedContent = processingResult.content; + + // Log errors if any + if (processingResult.errors.length > 0) { + console.warn('Macro processing errors:', processingResult.errors); + } + } + + // Step 2: Parse markdown to HTML if (!this.marked) { console.error("Markdown parser (marked) not initialized."); previewDiv.innerHTML = `
Preview engine not loaded.
`; return; } - let html = this.marked.parse(markdown); + + let html = this.marked.parse(processedContent); - // Replace mermaid code blocks with div containers + // Replace mermaid code blocks html = html.replace( /
([\s\S]*?)<\/code><\/pre>/g,
                 (match, code) => {
@@ -218,7 +238,7 @@ class MarkdownEditor {
 
             previewDiv.innerHTML = html;
 
-            // Apply syntax highlighting to code blocks
+            // Apply syntax highlighting
             const codeBlocks = previewDiv.querySelectorAll('pre code');
             codeBlocks.forEach(block => {
                 const languageClass = Array.from(block.classList)
diff --git a/static/js/file-tree-actions.js b/static/js/file-tree-actions.js
index 512b749..399a1c1 100644
--- a/static/js/file-tree-actions.js
+++ b/static/js/file-tree-actions.js
@@ -11,6 +11,37 @@ class FileTreeActions {
         this.clipboard = null;
     }
 
+    /**
+     * Validate and sanitize filename/folder name
+     * Returns { valid: boolean, sanitized: string, message: string }
+     */
+    validateFileName(name, isFolder = false) {
+        const type = isFolder ? 'folder' : 'file';
+        
+        if (!name || name.trim().length === 0) {
+            return { valid: false, message: `${type} name cannot be empty` };
+        }
+        
+        // Check for invalid characters
+        const validPattern = /^[a-z0-9_]+(\.[a-z0-9_]+)*$/;
+        
+        if (!validPattern.test(name)) {
+            const sanitized = name
+                .toLowerCase()
+                .replace(/[^a-z0-9_.]/g, '_')
+                .replace(/_+/g, '_')
+                .replace(/^_+|_+$/g, '');
+            
+            return {
+                valid: false,
+                sanitized,
+                message: `Invalid characters in ${type} name. Only lowercase letters, numbers, and underscores allowed.\n\nSuggestion: "${sanitized}"`
+            };
+        }
+        
+        return { valid: true, sanitized: name, message: '' };
+    }
+
     async execute(action, targetPath, isDirectory) {
         const handler = this.actions[action];
         if (!handler) {
@@ -35,25 +66,62 @@ class FileTreeActions {
 
         'new-file': async function(path, isDir) {
             if (!isDir) return;
-            const filename = await this.showInputDialog('Enter filename:', 'new-file.md');
-            if (filename) {
+            
+            await this.showInputDialog('Enter filename (lowercase, underscore only):', 'new_file.md', async (filename) => {
+                if (!filename) return;
+                
+                const validation = this.validateFileName(filename, false);
+                
+                if (!validation.valid) {
+                    showNotification(validation.message, 'warning');
+                    
+                    // Ask if user wants to use sanitized version
+                    if (validation.sanitized) {
+                        if (await this.showConfirmDialog('Use sanitized name?', `${filename} → ${validation.sanitized}`)) {
+                            filename = validation.sanitized;
+                        } else {
+                            return;
+                        }
+                    } else {
+                        return;
+                    }
+                }
+                
                 const fullPath = `${path}/${filename}`.replace(/\/+/g, '/');
                 await this.webdavClient.put(fullPath, '# New File\n\n');
                 await this.fileTree.load();
                 showNotification(`Created ${filename}`, 'success');
                 await this.editor.loadFile(fullPath);
-            }
+            });
         },
 
         'new-folder': async function(path, isDir) {
             if (!isDir) return;
-            const foldername = await this.showInputDialog('Enter folder name:', 'new-folder');
-            if (foldername) {
+            
+            await this.showInputDialog('Enter folder name (lowercase, underscore only):', 'new_folder', async (foldername) => {
+                if (!foldername) return;
+                
+                const validation = this.validateFileName(foldername, true);
+                
+                if (!validation.valid) {
+                    showNotification(validation.message, 'warning');
+                    
+                    if (validation.sanitized) {
+                        if (await this.showConfirmDialog('Use sanitized name?', `${foldername} → ${validation.sanitized}`)) {
+                            foldername = validation.sanitized;
+                        } else {
+                            return;
+                        }
+                    } else {
+                        return;
+                    }
+                }
+                
                 const fullPath = `${path}/${foldername}`.replace(/\/+/g, '/');
                 await this.webdavClient.mkcol(fullPath);
                 await this.fileTree.load();
                 showNotification(`Created folder ${foldername}`, 'success');
-            }
+            });
         },
 
         rename: async function(path, isDir) {
@@ -140,27 +208,36 @@ class FileTreeActions {
     };
 
     // Modern dialog implementations
-    async showInputDialog(title, placeholder = '') {
+    async showInputDialog(title, placeholder = '', callback) {
         return new Promise((resolve) => {
             const dialog = this.createInputDialog(title, placeholder);
             const input = dialog.querySelector('input');
             const confirmBtn = dialog.querySelector('.btn-primary');
+            const cancelBtn = dialog.querySelector('.btn-secondary');
 
-            const cleanup = () => {
+            const cleanup = (value) => {
+                const modalInstance = bootstrap.Modal.getInstance(dialog);
+                if (modalInstance) {
+                    modalInstance.hide();
+                }
                 dialog.remove();
                 const backdrop = document.querySelector('.modal-backdrop');
                 if (backdrop) backdrop.remove();
                 document.body.classList.remove('modal-open');
+                resolve(value);
+                if (callback) callback(value);
             };
 
             confirmBtn.onclick = () => {
-                resolve(input.value.trim());
-                cleanup();
+                cleanup(input.value.trim());
+            };
+
+            cancelBtn.onclick = () => {
+                cleanup(null);
             };
 
             dialog.addEventListener('hidden.bs.modal', () => {
-                resolve(null);
-                cleanup();
+                cleanup(null);
             });
 
             input.onkeypress = (e) => {
@@ -175,26 +252,35 @@ class FileTreeActions {
         });
     }
 
-    async showConfirmDialog(title, message = '') {
+    async showConfirmDialog(title, message = '', callback) {
         return new Promise((resolve) => {
             const dialog = this.createConfirmDialog(title, message);
             const confirmBtn = dialog.querySelector('.btn-danger');
+            const cancelBtn = dialog.querySelector('.btn-secondary');
 
-            const cleanup = () => {
+            const cleanup = (value) => {
+                const modalInstance = bootstrap.Modal.getInstance(dialog);
+                if (modalInstance) {
+                    modalInstance.hide();
+                }
                 dialog.remove();
                 const backdrop = document.querySelector('.modal-backdrop');
                 if (backdrop) backdrop.remove();
                 document.body.classList.remove('modal-open');
+                resolve(value);
+                if (callback) callback(value);
             };
 
             confirmBtn.onclick = () => {
-                resolve(true);
-                cleanup();
+                cleanup(true);
+            };
+
+            cancelBtn.onclick = () => {
+                cleanup(false);
             };
 
             dialog.addEventListener('hidden.bs.modal', () => {
-                resolve(false);
-                cleanup();
+                cleanup(false);
             });
 
             document.body.appendChild(dialog);
diff --git a/static/js/macro-parser.js b/static/js/macro-parser.js
new file mode 100644
index 0000000..dd8488e
--- /dev/null
+++ b/static/js/macro-parser.js
@@ -0,0 +1,103 @@
+/**
+ * Macro Parser and Processor
+ * Parses HeroScript-style macros from markdown content
+ */
+
+class MacroParser {
+    /**
+     * Parse and extract all macros from content
+     * Returns array of { fullMatch, actor, method, params }
+     */
+    static extractMacros(content) {
+        const macroRegex = /!!([\w.]+)\s*([\s\S]*?)(?=\n!!|\n#|$)/g;
+        const macros = [];
+        let match;
+        
+        while ((match = macroRegex.exec(content)) !== null) {
+            const fullMatch = match[0];
+            const actionPart = match[1]; // e.g., "include" or "core.include"
+            const paramsPart = match[2];
+            
+            // Parse action: "method" or "actor.method"
+            const [actor, method] = actionPart.includes('.')
+                ? actionPart.split('.')
+                : ['core', actionPart];
+            
+            // Parse parameters from HeroScript-like syntax
+            const params = this.parseParams(paramsPart);
+            
+            macros.push({
+                fullMatch: fullMatch.trim(),
+                actor,
+                method,
+                params,
+                start: match.index,
+                end: match.index + fullMatch.length
+            });
+        }
+        
+        return macros;
+    }
+    
+    /**
+     * Parse HeroScript-style parameters
+     * key: value
+     * key: 'value with spaces'
+     * key: |
+     *   multiline
+     *   value
+     */
+    static parseParams(paramsPart) {
+        const params = {};
+        
+        if (!paramsPart || !paramsPart.trim()) {
+            return params;
+        }
+        
+        // Split by newlines but preserve multiline values
+        const lines = paramsPart.split('\n');
+        let currentKey = null;
+        let currentValue = [];
+        
+        for (const line of lines) {
+            const trimmed = line.trim();
+            
+            if (!trimmed) continue;
+            
+            // Check if this is a key: value line
+            if (trimmed.includes(':')) {
+                // Save previous key-value
+                if (currentKey) {
+                    params[currentKey] = currentValue.join('\n').trim();
+                }
+                
+                const [key, ...valueParts] = trimmed.split(':');
+                currentKey = key.trim();
+                currentValue = [valueParts.join(':').trim()];
+            } else if (currentKey) {
+                // Continuation of multiline value
+                currentValue.push(trimmed);
+            }
+        }
+        
+        // Save last key-value
+        if (currentKey) {
+            params[currentKey] = currentValue.join('\n').trim();
+        }
+        
+        return params;
+    }
+    
+    /**
+     * Check if macro is valid
+     */
+    static validateMacro(macro) {
+        if (!macro.actor || !macro.method) {
+            return { valid: false, error: 'Invalid macro format' };
+        }
+        
+        return { valid: true };
+    }
+}
+
+window.MacroParser = MacroParser;
\ No newline at end of file
diff --git a/static/js/macro-processor.js b/static/js/macro-processor.js
new file mode 100644
index 0000000..3a7174d
--- /dev/null
+++ b/static/js/macro-processor.js
@@ -0,0 +1,162 @@
+/**
+ * Macro Processor
+ * Handles macro execution and result rendering
+ */
+
+class MacroProcessor {
+    constructor(webdavClient) {
+        this.webdavClient = webdavClient;
+        this.plugins = new Map();
+        this.includeStack = []; // Track includes to detect cycles
+        this.registerDefaultPlugins();
+    }
+    
+    /**
+     * Register a macro plugin
+     * Plugin must implement: { canHandle(actor, method), process(macro, webdavClient) }
+     */
+    registerPlugin(actor, method, plugin) {
+        const key = `${actor}.${method}`;
+        this.plugins.set(key, plugin);
+    }
+    
+    /**
+     * Process all macros in content
+     * Returns { success: boolean, content: string, errors: [] }
+     */
+    async processMacros(content) {
+        console.log('MacroProcessor: Starting macro processing for content:', content);
+        const macros = MacroParser.extractMacros(content);
+        console.log('MacroProcessor: Extracted macros:', macros);
+        const errors = [];
+        let processedContent = content;
+        
+        // Process macros in reverse order to preserve positions
+        for (let i = macros.length - 1; i >= 0; i--) {
+            const macro = macros[i];
+            console.log('MacroProcessor: Processing macro:', macro);
+            
+            try {
+                const result = await this.processMacro(macro);
+                console.log('MacroProcessor: Macro processing result:', result);
+                
+                if (result.success) {
+                    // Replace macro with result
+                    processedContent =
+                        processedContent.substring(0, macro.start) +
+                        result.content +
+                        processedContent.substring(macro.end);
+                } else {
+                    errors.push({
+                        macro: macro.fullMatch,
+                        error: result.error
+                    });
+                    
+                    // Replace with error message
+                    const errorMsg = `\n\n⚠️ **Macro Error**: ${result.error}\n\n`;
+                    processedContent =
+                        processedContent.substring(0, macro.start) +
+                        errorMsg +
+                        processedContent.substring(macro.end);
+                }
+            } catch (error) {
+                errors.push({
+                    macro: macro.fullMatch,
+                    error: error.message
+                });
+                
+                const errorMsg = `\n\n⚠️ **Macro Error**: ${error.message}\n\n`;
+                processedContent =
+                    processedContent.substring(0, macro.start) +
+                    errorMsg +
+                    processedContent.substring(macro.end);
+            }
+        }
+        
+        console.log('MacroProcessor: Final processed content:', processedContent);
+        return {
+            success: errors.length === 0,
+            content: processedContent,
+            errors
+        };
+    }
+    
+    /**
+     * Process single macro
+     */
+    async processMacro(macro) {
+        const key = `${macro.actor}.${macro.method}`;
+        const plugin = this.plugins.get(key);
+
+        // Check for circular includes
+        if (macro.method === 'include') {
+            const path = macro.params.path || macro.params[''];
+            if (this.includeStack.includes(path)) {
+                return {
+                    success: false,
+                    error: `Circular include detected: ${this.includeStack.join(' → ')} → ${path}`
+                };
+            }
+        }
+        
+        if (!plugin) {
+            return {
+                success: false,
+                error: `Unknown macro: !!${key}`
+            };
+        }
+        
+        // Validate macro
+        const validation = MacroParser.validateMacro(macro);
+        if (!validation.valid) {
+            return { success: false, error: validation.error };
+        }
+        
+        // Execute plugin
+        try {
+            return await plugin.process(macro, this.webdavClient);
+        } catch (error) {
+            return {
+                success: false,
+                error: `Plugin error: ${error.message}`
+            };
+        }
+    }
+    
+    /**
+     * Register default plugins
+     */
+    registerDefaultPlugins() {
+        // Include plugin
+        this.registerPlugin('core', 'include', {
+            process: async (macro, webdavClient) => {
+                const path = macro.params.path || macro.params[''];
+                
+                if (!path) {
+                    return {
+                        success: false,
+                        error: 'include macro requires "path" parameter'
+                    };
+                }
+                
+                try {
+                    // Add to include stack
+                    this.includeStack.push(path);
+                    const content = await webdavClient.includeFile(path);
+                    // Remove from include stack
+                    this.includeStack.pop();
+                    return { success: true, content };
+                } catch (error) {
+                    // Remove from include stack on error
+                    this.includeStack.pop();
+                    return {
+                        success: false,
+                        error: `Failed to include "${path}": ${error.message}`
+                    };
+                }
+            }
+        });
+    }
+}
+
+window.MacroProcessor = MacroProcessor;
\ No newline at end of file
diff --git a/static/js/webdav-client.js b/static/js/webdav-client.js
index 2b93cb5..c3aa858 100644
--- a/static/js/webdav-client.js
+++ b/static/js/webdav-client.js
@@ -162,6 +162,31 @@ class WebDAVClient {
         return true;
     }
     
+    async includeFile(path) {
+        try {
+            // Parse path: "collection:path/to/file" or "path/to/file"
+            let targetCollection = this.currentCollection;
+            let targetPath = path;
+            
+            if (path.includes(':')) {
+                [targetCollection, targetPath] = path.split(':');
+            }
+            
+            // Temporarily switch collection
+            const originalCollection = this.currentCollection;
+            this.currentCollection = targetCollection;
+            
+            const content = await this.get(targetPath);
+            
+            // Restore collection
+            this.currentCollection = originalCollection;
+            
+            return content;
+        } catch (error) {
+            throw new Error(`Cannot include file "${path}": ${error.message}`);
+        }
+    }
+
     parseMultiStatus(xml) {
         const parser = new DOMParser();
         const doc = parser.parseFromString(xml, 'text/xml');
@@ -231,6 +256,7 @@ class WebDAVClient {
             } else {
                 root.push(node);
             }
+            
         });
         
         return root;
diff --git a/templates/index.html b/templates/index.html
index e691aa2..b59e6f1 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -186,6 +186,8 @@
     
     
     
+   
+   
 
 
 
\ No newline at end of file