...
This commit is contained in:
@@ -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 = `<div class="alert alert-danger">Preview engine not loaded.</div>`;
|
||||
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(
|
||||
/<pre><code class="language-mermaid">([\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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
103
static/js/macro-parser.js
Normal file
103
static/js/macro-parser.js
Normal file
@@ -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;
|
||||
162
static/js/macro-processor.js
Normal file
162
static/js/macro-processor.js
Normal file
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user