This commit is contained in:
2025-10-26 10:27:48 +04:00
parent 11038e0bcd
commit d48e25ce90
11 changed files with 447 additions and 32 deletions

View File

@@ -1,3 +1,10 @@
# New File
Start typing...
# test
- 1
- 2
[2025 SeaweedFS Intro Slides.pdf](/notes/2025 SeaweedFS Intro Slides.pdf)

View File

@@ -6,4 +6,4 @@
!!include path:test2.md

View File

@@ -0,0 +1,12 @@
## test2
- something
- another thing

View File

@@ -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:")

View File

@@ -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

View File

@@ -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)

View File

@@ -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
View 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;

View 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;

View File

@@ -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;

View File

@@ -186,6 +186,8 @@
<script src="/static/js/file-tree-actions.js" defer></script>
<script src="/static/js/column-resizer.js" defer></script>
<script src="/static/js/app.js" defer></script>
<script src="/static/js/macro-parser.js" defer></script>
<script src="/static/js/macro-processor.js" defer></script>
</body>
</html>