diff --git a/start.sh b/start.sh index b3d5849..ea907c0 100755 --- a/start.sh +++ b/start.sh @@ -19,5 +19,8 @@ echo "Activating virtual environment..." source .venv/bin/activate echo "Installing dependencies..." uv pip install wsgidav cheroot pyyaml -echo "Starting WebDAV server on port $PORT..." +PORT=8004 +echo "Checking for process on port $PORT..." +lsof -ti:$PORT | xargs -r kill -9 +echo "Starting WebDAV server..." python server_webdav.py diff --git a/static/css/file-tree.css b/static/css/file-tree.css index 13cbf87..216c835 100644 --- a/static/css/file-tree.css +++ b/static/css/file-tree.css @@ -111,14 +111,19 @@ display: none; } -/* Drag and drop */ +.tree-node { + cursor: move; +} + .tree-node.dragging { opacity: 0.5; + background-color: rgba(13, 110, 253, 0.1); } .tree-node.drag-over { - background-color: rgba(13, 110, 253, 0.2); + background-color: rgba(13, 110, 253, 0.2) !important; border: 1px dashed var(--link-color); + border-radius: 4px; } /* Collection selector - Bootstrap styled */ diff --git a/static/js/editor.js b/static/js/editor.js index c7042ca..d3bc3eb 100644 --- a/static/js/editor.js +++ b/static/js/editor.js @@ -10,7 +10,9 @@ class MarkdownEditor { this.filenameInput = document.getElementById(filenameInputId); this.currentFile = null; this.webdavClient = null; - this.macroProcessor = new MacroProcessor(null); // Will be set later + + // Initialize macro processor AFTER webdavClient is set + this.macroProcessor = null; this.initCodeMirror(); this.initMarkdown(); @@ -88,10 +90,8 @@ class MarkdownEditor { setWebDAVClient(client) { this.webdavClient = client; - // Update macro processor with client - if (this.macroProcessor) { - this.macroProcessor.webdavClient = client; - } + // NOW initialize macro processor + this.macroProcessor = new MacroProcessor(client); } /** @@ -196,38 +196,29 @@ class MarkdownEditor { const previewDiv = this.previewElement; if (!markdown || !markdown.trim()) { - previewDiv.innerHTML = ` -
-

Start typing to see preview...

-
- `; + previewDiv.innerHTML = `
Start typing...
`; return; } try { // Step 1: Process macros + console.log('[Editor] Processing macros...'); let processedContent = markdown; if (this.macroProcessor) { - const processingResult = await this.macroProcessor.processMacros(markdown); - processedContent = processingResult.content; + const result = await this.macroProcessor.processMacros(markdown); + processedContent = result.content; - // Log errors if any - if (processingResult.errors.length > 0) { - console.warn('Macro processing errors:', processingResult.errors); + if (result.errors.length > 0) { + console.warn('[Editor] Macro errors:', result.errors); } } - // Step 2: Parse markdown to HTML - if (!this.marked) { - console.error("Markdown parser (marked) not initialized."); - previewDiv.innerHTML = `
Preview engine not loaded.
`; - return; - } - + // Step 2: Parse markdown + console.log('[Editor] Parsing markdown...'); let html = this.marked.parse(processedContent); - // Replace mermaid code blocks + // Step 3: Handle mermaid html = html.replace( /
([\s\S]*?)<\/code><\/pre>/g,
                 (match, code) => {
@@ -238,35 +229,23 @@ class MarkdownEditor {
 
             previewDiv.innerHTML = html;
 
-            // Apply syntax highlighting
+            // Step 4: Syntax highlighting
             const codeBlocks = previewDiv.querySelectorAll('pre code');
             codeBlocks.forEach(block => {
-                const languageClass = Array.from(block.classList)
+                const lang = Array.from(block.classList)
                     .find(cls => cls.startsWith('language-'));
-                if (languageClass && languageClass !== 'language-mermaid') {
-                    if (window.Prism) {
-                        window.Prism.highlightElement(block);
-                    }
+                if (lang && lang !== 'language-mermaid' && window.Prism) {
+                    window.Prism.highlightElement(block);
                 }
             });
 
-            // Render mermaid diagrams
-            const mermaidElements = previewDiv.querySelectorAll('.mermaid');
-            if (mermaidElements.length > 0 && window.mermaid) {
-                try {
-                    window.mermaid.contentLoaded();
-                } catch (error) {
-                    console.warn('Mermaid rendering error:', error);
-                }
+            // Step 5: Render mermaid
+            if (window.mermaid) {
+                await window.mermaid.run();
             }
         } catch (error) {
-            console.error('Preview rendering error:', error);
-            previewDiv.innerHTML = `
-                
-            `;
+            console.error('[Editor] Preview error:', error);
+            previewDiv.innerHTML = `
Error: ${error.message}
`; } } diff --git a/static/js/file-tree.js b/static/js/file-tree.js index 29a3fd6..e53f67f 100644 --- a/static/js/file-tree.js +++ b/static/js/file-tree.js @@ -16,23 +16,101 @@ class FileTree { } setupEventListeners() { - // Click handler for tree nodes this.container.addEventListener('click', (e) => { - console.log('Container clicked', e.target); const node = e.target.closest('.tree-node'); if (!node) return; - console.log('Node found', node); + const path = node.dataset.path; + const isDir = node.dataset.isdir === 'true'; + + // If it's a directory, and the click was on the title, select the folder + if (isDir && e.target.classList.contains('tree-node-title')) { + this.selectFolder(path); + } else if (!isDir) { // If it's a file, select the file + this.selectFile(path); + } + // Clicks on the toggle are handled by the toggle's specific event listener + }); + + // DRAG AND DROP + this.container.addEventListener('dragstart', (e) => { + const node = e.target.closest('.tree-node'); + if (!node) return; + const path = node.dataset.path; const isDir = node.dataset.isdir === 'true'; - // The toggle is handled inside renderNodes now + console.log('[FileTree] Drag start:', path); - // Select node - if (isDir) { - this.selectFolder(path); - } else { - this.selectFile(path); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', path); + e.dataTransfer.setData('application/json', JSON.stringify({ + path, + isDir, + name: node.querySelector('.tree-node-title').textContent + })); + + node.classList.add('dragging'); + setTimeout(() => node.classList.remove('dragging'), 0); + }); + + this.container.addEventListener('dragover', (e) => { + const node = e.target.closest('.tree-node'); + if (!node) return; + + const isDir = node.dataset.isdir === 'true'; + if (!isDir) return; + + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + node.classList.add('drag-over'); + }); + + this.container.addEventListener('dragleave', (e) => { + const node = e.target.closest('.tree-node'); + if (node) { + node.classList.remove('drag-over'); + } + }); + + this.container.addEventListener('drop', async (e) => { + const targetNode = e.target.closest('.tree-node'); + if (!targetNode) return; + + e.preventDefault(); + e.stopPropagation(); + + const targetPath = targetNode.dataset.path; + const isDir = targetNode.dataset.isdir === 'true'; + + if (!isDir) { + console.log('[FileTree] Target is not a directory'); + return; + } + + try { + const data = JSON.parse(e.dataTransfer.getData('application/json')); + const sourcePath = data.path; + const sourceName = data.name; + + if (sourcePath === targetPath) { + console.log('[FileTree] Source and target are same'); + return; + } + + const destPath = `${targetPath}/${sourceName}`.replace(/\/+/g, '/'); + + console.log('[FileTree] Moving:', sourcePath, '→', destPath); + + await this.webdavClient.move(sourcePath, destPath); + await this.load(); + + showNotification(`Moved to ${targetNode.querySelector('.tree-node-title').textContent}`, 'success'); + } catch (error) { + console.error('[FileTree] Drop error:', error); + showNotification(`Failed to move: ${error.message}`, 'error'); + } finally { + targetNode.classList.remove('drag-over'); } }); @@ -42,13 +120,9 @@ class FileTree { e.preventDefault(); if (node) { - // Clicked on a node const path = node.dataset.path; const isDir = node.dataset.isdir === 'true'; window.showContextMenu(e.clientX, e.clientY, { path, isDir }); - } else if (e.target === this.container) { - // Clicked on the empty space in the file tree container - window.showContextMenu(e.clientX, e.clientY, { path: '', isDir: true }); } }); } @@ -91,18 +165,8 @@ class FileTree { nodeWrapper.appendChild(childContainer); // Make toggle functional - const toggle = nodeElement.querySelector('.tree-node-toggle'); - if (toggle) { - toggle.addEventListener('click', (e) => { - console.log('Toggle clicked', e.target); - e.stopPropagation(); - const isHidden = childContainer.style.display === 'none'; - console.log('Is hidden?', isHidden); - childContainer.style.display = isHidden ? 'block' : 'none'; - toggle.innerHTML = isHidden ? '▼' : '▶'; - toggle.classList.toggle('expanded'); - }); - } + // The toggle functionality is already handled in renderNodes, no need to duplicate here. + // Ensure the toggle's click event stops propagation to prevent the parent node's click from firing. } parentElement.appendChild(nodeWrapper); diff --git a/static/js/macro-parser.js b/static/js/macro-parser.js index dd8488e..62c8f37 100644 --- a/static/js/macro-parser.js +++ b/static/js/macro-parser.js @@ -5,8 +5,7 @@ class MacroParser { /** - * Parse and extract all macros from content - * Returns array of { fullMatch, actor, method, params } + * Extract macros with improved parsing */ static extractMacros(content) { const macroRegex = /!!([\w.]+)\s*([\s\S]*?)(?=\n!!|\n#|$)/g; @@ -15,17 +14,17 @@ class MacroParser { while ((match = macroRegex.exec(content)) !== null) { const fullMatch = match[0]; - const actionPart = match[1]; // e.g., "include" or "core.include" + const actionPart = match[1]; 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); + console.log(`[MacroParser] Extracted: !!${actor}.${method}`, params); + macros.push({ fullMatch: fullMatch.trim(), actor, @@ -40,12 +39,13 @@ class MacroParser { } /** - * Parse HeroScript-style parameters - * key: value - * key: 'value with spaces' - * key: | - * multiline - * value + * Parse HeroScript parameters with multiline support + * Supports: + * key: 'value' + * key: '''multiline value''' + * key: | + * multiline + * value */ static parseParams(paramsPart) { const params = {}; @@ -54,49 +54,95 @@ class MacroParser { return params; } - // Split by newlines but preserve multiline values - const lines = paramsPart.split('\n'); - let currentKey = null; - let currentValue = []; + let lines = paramsPart.split('\n'); + let i = 0; - for (const line of lines) { - const trimmed = line.trim(); + while (i < lines.length) { + const line = lines[i].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); + if (!line) { + i++; + continue; } + + // Check for key: value pattern + if (line.includes(':')) { + const colonIndex = line.indexOf(':'); + const key = line.substring(0, colonIndex).trim(); + let value = line.substring(colonIndex + 1).trim(); + + // Handle triple-quoted multiline + if (value.startsWith("'''")) { + value = value.substring(3); + const valueLines = [value]; + i++; + + while (i < lines.length) { + const contentLine = lines[i]; + if (contentLine.trim().endsWith("'''")) { + valueLines.push(contentLine.trim().slice(0, -3)); + break; + } + valueLines.push(contentLine); + i++; + } + + // Remove leading whitespace from multiline + const processedValue = this.dedent(valueLines.join('\n')); + params[key] = processedValue; + } + // Handle pipe multiline + else if (value === '|') { + const valueLines = []; + i++; + + while (i < lines.length && lines[i].startsWith('\t')) { + valueLines.push(lines[i].substring(1)); // Remove tab + i++; + } + i--; // Back up one since loop will increment + + const processedValue = this.dedent(valueLines.join('\n')); + params[key] = processedValue; + } + // Handle quoted value + else if (value.startsWith("'") && value.endsWith("'")) { + params[key] = value.slice(1, -1); + } + // Handle unquoted value + else { + params[key] = value; + } + } + + i++; } - // Save last key-value - if (currentKey) { - params[currentKey] = currentValue.join('\n').trim(); - } - + console.log(`[MacroParser] Parsed parameters:`, params); return params; } /** - * Check if macro is valid + * Remove common leading whitespace from multiline strings */ - static validateMacro(macro) { - if (!macro.actor || !macro.method) { - return { valid: false, error: 'Invalid macro format' }; + static dedent(text) { + const lines = text.split('\n'); + + // Find minimum indentation + let minIndent = Infinity; + for (const line of lines) { + if (line.trim().length === 0) continue; + const indent = line.search(/\S/); + minIndent = Math.min(minIndent, indent); } - return { valid: true }; + if (minIndent === Infinity) minIndent = 0; + + // Remove common indentation + return lines + .map(line => line.slice(minIndent)) + .join('\n') + .trim(); } } diff --git a/static/js/macro-processor.js b/static/js/macro-processor.js index 3a7174d..0b6dad7 100644 --- a/static/js/macro-processor.js +++ b/static/js/macro-processor.js @@ -6,53 +6,45 @@ class MacroProcessor { constructor(webdavClient) { this.webdavClient = webdavClient; - this.plugins = new Map(); - this.includeStack = []; // Track includes to detect cycles + this.macroRegistry = new MacroRegistry(); + this.includeStack = []; + this.faqItems = []; + this.registerDefaultPlugins(); + console.log('[MacroProcessor] Initialized'); } /** - * 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: [] } + * Process all macros in markdown */ async processMacros(content) { - console.log('MacroProcessor: Starting macro processing for content:', content); + console.log('[MacroProcessor] Processing content, length:', content.length); + const macros = MacroParser.extractMacros(content); - console.log('MacroProcessor: Extracted macros:', macros); + console.log(`[MacroProcessor] Found ${macros.length} macros`); + const errors = []; let processedContent = content; + let faqOutput = ''; - // Process macros in reverse order to preserve positions + // Process in reverse to preserve positions for (let i = macros.length - 1; i >= 0; i--) { const macro = macros[i]; - console.log('MacroProcessor: Processing macro:', macro); + console.log(`[MacroProcessor] Processing macro ${i}:`, macro.actor, macro.method); try { const result = await this.processMacro(macro); - console.log('MacroProcessor: Macro processing result:', result); if (result.success) { - // Replace macro with result + console.log(`[MacroProcessor] Macro succeeded, replacing content`); processedContent = processedContent.substring(0, macro.start) + result.content + processedContent.substring(macro.end); } else { - errors.push({ - macro: macro.fullMatch, - error: result.error - }); + console.error(`[MacroProcessor] Macro failed:`, result.error); + 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) + @@ -60,12 +52,10 @@ class MacroProcessor { processedContent.substring(macro.end); } } catch (error) { - errors.push({ - macro: macro.fullMatch, - error: error.message - }); + console.error(`[MacroProcessor] Macro exception:`, error); + errors.push({ macro: macro.fullMatch, error: error.message }); - const errorMsg = `\n\n⚠️ **Macro Error**: ${error.message}\n\n`; + const errorMsg = `\n\n⚠️ **Macro Exception**: ${error.message}\n\n`; processedContent = processedContent.substring(0, macro.start) + errorMsg + @@ -73,7 +63,17 @@ class MacroProcessor { } } - console.log('MacroProcessor: Final processed content:', processedContent); + // Append FAQ if any FAQ macros were used + if (this.faqItems.length > 0) { + faqOutput = '\n\n---\n\n## FAQ\n\n'; + faqOutput += this.faqItems + .map((item, idx) => `### ${idx + 1}. ${item.title}\n\n${item.response}`) + .join('\n\n'); + processedContent += faqOutput; + } + + console.log('[MacroProcessor] Processing complete, errors:', errors.length); + return { success: errors.length === 0, content: processedContent, @@ -85,37 +85,30 @@ class MacroProcessor { * 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}` - }; - } - } + const plugin = this.macroRegistry.resolve(macro.actor, macro.method); if (!plugin) { return { success: false, - error: `Unknown macro: !!${key}` + error: `Unknown macro: !!${macro.actor}.${macro.method}` }; } - // Validate macro - const validation = MacroParser.validateMacro(macro); - if (!validation.valid) { - return { success: false, error: validation.error }; + // Check for circular includes + if (macro.method === 'include') { + const path = macro.params.path; + if (this.includeStack.includes(path)) { + return { + success: false, + error: `Circular include: ${this.includeStack.join(' → ')} → ${path}` + }; + } } - // Execute plugin try { return await plugin.process(macro, this.webdavClient); } catch (error) { + console.error('[MacroProcessor] Plugin error:', error); return { success: false, error: `Plugin error: ${error.message}` @@ -128,34 +121,12 @@ class MacroProcessor { */ 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}` - }; - } - } - }); + this.macroRegistry.register('core', 'include', new IncludePlugin(this)); + + // FAQ plugin + this.macroRegistry.register('core', 'faq', new FAQPlugin(this)); + + console.log('[MacroProcessor] Registered default plugins'); } } diff --git a/static/js/macro-system.js b/static/js/macro-system.js new file mode 100644 index 0000000..42d1d45 --- /dev/null +++ b/static/js/macro-system.js @@ -0,0 +1,50 @@ +/** + * Macro System + * Generic plugin-based macro processor + */ + +class MacroPlugin { + /** + * Base class for macro plugins + * Subclass and implement these methods: + * - canHandle(actor, method): boolean + * - process(macro, context): Promise<{ success, content, error }> + */ + + canHandle(actor, method) { + throw new Error('Must implement canHandle()'); + } + + async process(macro, context) { + throw new Error('Must implement process()'); + } +} + +class MacroRegistry { + constructor() { + this.plugins = new Map(); + console.log('[MacroRegistry] Initializing macro registry'); + } + + register(actor, method, plugin) { + const key = `${actor}.${method}`; + this.plugins.set(key, plugin); + console.log(`[MacroRegistry] Registered plugin: ${key}`); + } + + resolve(actor, method) { + // Try exact match + let key = `${actor}.${method}`; + if (this.plugins.has(key)) { + console.log(`[MacroRegistry] Found plugin: ${key}`); + return this.plugins.get(key); + } + + // No plugin found + console.warn(`[MacroRegistry] No plugin found for: ${key}`); + return null; + } +} + +window.MacroRegistry = MacroRegistry; +window.MacroPlugin = MacroPlugin; \ No newline at end of file diff --git a/static/js/plugins/faq-plugin.js b/static/js/plugins/faq-plugin.js new file mode 100644 index 0000000..a9094f6 --- /dev/null +++ b/static/js/plugins/faq-plugin.js @@ -0,0 +1,70 @@ +/** + * FAQ Plugin + * Creates FAQ entries that are collected and displayed at bottom of preview + * + * Usage: + * !!faq title: 'My Question' + * response: ''' + * This is the answer with **markdown** support. + * + * - Point 1 + * - Point 2 + * ''' + */ +class FAQPlugin extends MacroPlugin { + constructor(processor) { + super(); + this.processor = processor; + } + + canHandle(actor, method) { + return actor === 'core' && method === 'faq'; + } + + async process(macro, webdavClient) { + const title = macro.params.title; + const response = macro.params.response; + + console.log('[FAQPlugin] Processing FAQ:', title); + + if (!title) { + console.error('[FAQPlugin] Missing title parameter'); + return { + success: false, + error: 'FAQ macro requires "title" parameter' + }; + } + + if (!response) { + console.error('[FAQPlugin] Missing response parameter'); + return { + success: false, + error: 'FAQ macro requires "response" parameter' + }; + } + + try { + // Store FAQ item for later display + this.processor.faqItems.push({ + title: title.trim(), + response: response.trim() + }); + + console.log('[FAQPlugin] FAQ item added, total:', this.processor.faqItems.length); + + // Return empty string since FAQ is shown at bottom + return { + success: true, + content: '' + }; + } catch (error) { + console.error('[FAQPlugin] Error:', error); + return { + success: false, + error: `FAQ processing error: ${error.message}` + }; + } + } +} + +window.FAQPlugin = FAQPlugin; \ No newline at end of file diff --git a/static/js/plugins/include-plugin.js b/static/js/plugins/include-plugin.js new file mode 100644 index 0000000..0768050 --- /dev/null +++ b/static/js/plugins/include-plugin.js @@ -0,0 +1,97 @@ +/** + * Include Plugin + * Includes content from other files + * + * Usage: + * !!include path: 'myfile.md' + * !!include path: 'collection:folder/file.md' + */ +class IncludePlugin extends MacroPlugin { + constructor(processor) { + super(); + this.processor = processor; + } + + canHandle(actor, method) { + return actor === 'core' && method === 'include'; + } + + async process(macro, webdavClient) { + const path = macro.params.path; + + console.log('[IncludePlugin] Processing include:', path); + + if (!path) { + console.error('[IncludePlugin] Missing path parameter'); + return { + success: false, + error: 'Include macro requires "path" parameter' + }; + } + + try { + // Parse path format: "collection:path/to/file" or "path/to/file" + let targetCollection = webdavClient.currentCollection; + let targetPath = path; + + if (path.includes(':')) { + [targetCollection, targetPath] = path.split(':', 2); + console.log('[IncludePlugin] Using external collection:', targetCollection); + } else { + console.log('[IncludePlugin] Using current collection:', targetCollection); + } + + // Check for circular includes + const fullPath = `${targetCollection}:${targetPath}`; + if (this.processor.includeStack.includes(fullPath)) { + console.error('[IncludePlugin] Circular include detected'); + return { + success: false, + error: `Circular include detected: ${this.processor.includeStack.join(' → ')} → ${fullPath}` + }; + } + + // Add to include stack + this.processor.includeStack.push(fullPath); + + // Switch collection temporarily + const originalCollection = webdavClient.currentCollection; + webdavClient.setCollection(targetCollection); + + // Fetch file + console.log('[IncludePlugin] Fetching:', targetPath); + const content = await webdavClient.get(targetPath); + + // Restore collection + webdavClient.setCollection(originalCollection); + + // Remove from stack + this.processor.includeStack.pop(); + + console.log('[IncludePlugin] Include successful, length:', content.length); + + return { + success: true, + content: content + }; + } catch (error) { + console.error('[IncludePlugin] Error:', error); + + // Restore collection on error + if (webdavClient.currentCollection !== this.processor.webdavClient?.currentCollection) { + webdavClient.setCollection(this.processor.webdavClient?.currentCollection); + } + + this.processor.includeStack = this.processor.includeStack.filter( + item => !item.includes(path) + ); + + return { + success: false, + error: `Cannot include "${path}": ${error.message}` + }; + } + } +} + +window.IncludePlugin = IncludePlugin; \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index b59e6f1..e182066 100644 --- a/templates/index.html +++ b/templates/index.html @@ -186,8 +186,12 @@ - - + + + + + + \ No newline at end of file