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 = `
([\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 @@
+
+