Files
tinker_tickets/assets/js/markdown.js

420 lines
14 KiB
JavaScript
Raw Normal View History

2026-01-08 23:35:49 -05:00
/**
* Simple Markdown Parser for Tinker Tickets
* Supports basic markdown formatting without external dependencies
*/
function parseMarkdown(markdown) {
if (!markdown) return '';
let html = markdown;
// Escape HTML first to prevent XSS
html = html.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// Ticket references (#123456789) - convert to clickable links
html = html.replace(/#(\d{9})\b/g, '<a href="/ticket/$1" class="ticket-link-ref">#$1</a>');
// Code blocks (```code```) - preserve content and don't process further
const codeBlocks = [];
html = html.replace(/```([\s\S]*?)```/g, function(match, code) {
codeBlocks.push('<pre class="code-block"><code>' + code + '</code></pre>');
return '%%CODEBLOCK' + (codeBlocks.length - 1) + '%%';
});
// Inline code (`code`) - preserve and don't process further
const inlineCodes = [];
html = html.replace(/`([^`]+)`/g, function(match, code) {
inlineCodes.push('<code class="inline-code">' + code + '</code>');
return '%%INLINECODE' + (inlineCodes.length - 1) + '%%';
});
2026-01-08 23:35:49 -05:00
// Tables (must be processed before other block elements)
html = parseMarkdownTables(html);
2026-01-08 23:35:49 -05:00
// Bold (**text** or __text__)
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
// Italic (*text* or _text_)
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
html = html.replace(/_(.+?)_/g, '<em>$1</em>');
Implement comprehensive improvement plan (Phases 1-6) Security (Phase 1-2): - Add SecurityHeadersMiddleware with CSP, X-Frame-Options, etc. - Add RateLimitMiddleware for API rate limiting - Add security event logging to AuditLogModel - Add ResponseHelper for standardized API responses - Update config.php with security constants Database (Phase 3): - Add migration 014 for additional indexes - Add migration 015 for ticket dependencies - Add migration 016 for ticket attachments - Add migration 017 for recurring tickets - Add migration 018 for custom fields Features (Phase 4-5): - Add ticket dependencies with DependencyModel and API - Add duplicate detection with check_duplicates API - Add file attachments with AttachmentModel and upload/download APIs - Add @mentions with autocomplete and highlighting - Add quick actions on dashboard rows Collaboration (Phase 5): - Add mention extraction in CommentModel - Add mention autocomplete dropdown in ticket.js - Add mention highlighting CSS styles Admin & Export (Phase 6): - Add StatsModel for dashboard widgets - Add dashboard stats cards (open, critical, unassigned, etc.) - Add CSV/JSON export via export_tickets API - Add rich text editor toolbar in markdown.js - Add RecurringTicketModel with cron job - Add CustomFieldModel for per-category fields - Add admin views: RecurringTickets, CustomFields, Workflow, Templates, AuditLog, UserActivity - Add admin APIs: manage_workflows, manage_templates, manage_recurring, custom_fields, get_users - Add admin routes in index.php Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 09:55:01 -05:00
// Links [text](url) - only allow safe protocols
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(match, text, url) {
// Only allow http, https, mailto protocols
if (/^(https?:|mailto:|\/)/i.test(url)) {
return '<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + text + '</a>';
}
// Block potentially dangerous protocols (javascript:, data:, etc.)
return text;
});
2026-01-08 23:35:49 -05:00
// Auto-link bare URLs (http, https, ftp)
html = html.replace(/(?<!["\'>])(\bhttps?:\/\/[^\s<>\[\]()]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
2026-01-08 23:35:49 -05:00
// Headers (# H1, ## H2, etc.)
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
// Lists
// Unordered lists (- item or * item)
html = html.replace(/^\s*[-*]\s+(.+)$/gm, '<li>$1</li>');
html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>');
// Ordered lists (1. item)
html = html.replace(/^\s*\d+\.\s+(.+)$/gm, '<li>$1</li>');
// Blockquotes (> text)
html = html.replace(/^&gt;\s+(.+)$/gm, '<blockquote>$1</blockquote>');
2026-01-08 23:35:49 -05:00
// Horizontal rules (--- or ***)
html = html.replace(/^(?:---|___|\*\*\*)$/gm, '<hr>');
// Line breaks (two spaces at end of line or double newline)
html = html.replace(/ \n/g, '<br>');
html = html.replace(/\n\n/g, '</p><p>');
// Restore code blocks and inline code
codeBlocks.forEach((block, i) => {
html = html.replace('%%CODEBLOCK' + i + '%%', block);
});
inlineCodes.forEach((code, i) => {
html = html.replace('%%INLINECODE' + i + '%%', code);
});
2026-01-08 23:35:49 -05:00
// Wrap in paragraph if not already wrapped
if (!html.startsWith('<')) {
html = '<p>' + html + '</p>';
}
return html;
}
/**
* Parse markdown tables
* Supports: | Header | Header |
* |--------|--------|
* | Cell | Cell |
*/
function parseMarkdownTables(html) {
const lines = html.split('\n');
const result = [];
let inTable = false;
let tableRows = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// Check if line is a table row (starts and ends with |, or has | in the middle)
if (line.match(/^\|.*\|$/) || line.match(/^[^|]+\|[^|]+/)) {
// Check if next line is separator (|---|---|)
const nextLine = lines[i + 1] ? lines[i + 1].trim() : '';
const isSeparator = line.match(/^\|?[\s-:|]+\|[\s-:|]+\|?$/);
if (!inTable && !isSeparator) {
// Start of table - check if this is a header row
if (nextLine.match(/^\|?[\s-:|]+\|[\s-:|]+\|?$/)) {
inTable = true;
tableRows.push({ type: 'header', content: line });
continue;
}
}
if (inTable) {
if (isSeparator) {
// Skip separator line
continue;
}
tableRows.push({ type: 'body', content: line });
continue;
}
}
// Not a table row - flush any accumulated table
if (inTable && tableRows.length > 0) {
result.push(buildTable(tableRows));
tableRows = [];
inTable = false;
}
result.push(lines[i]);
}
// Flush remaining table
if (tableRows.length > 0) {
result.push(buildTable(tableRows));
}
return result.join('\n');
}
/**
* Build HTML table from parsed rows
*/
function buildTable(rows) {
if (rows.length === 0) return '';
let html = '<table class="markdown-table">';
rows.forEach((row, index) => {
const cells = row.content.split('|').filter(cell => cell.trim() !== '');
const tag = row.type === 'header' ? 'th' : 'td';
const wrapper = row.type === 'header' ? 'thead' : (index === 1 ? 'tbody' : '');
if (wrapper === 'thead') html += '<thead>';
if (wrapper === 'tbody') html += '<tbody>';
html += '<tr>';
cells.forEach(cell => {
html += `<${tag}>${cell.trim()}</${tag}>`;
});
html += '</tr>';
if (row.type === 'header') html += '</thead>';
});
html += '</tbody></table>';
return html;
}
2026-01-08 23:35:49 -05:00
// Apply markdown rendering to all elements with data-markdown attribute
function renderMarkdownElements() {
document.querySelectorAll('[data-markdown]').forEach(element => {
const markdownText = element.getAttribute('data-markdown') || element.textContent;
element.innerHTML = parseMarkdown(markdownText);
});
}
// Apply markdown to description and comments on page load
document.addEventListener('DOMContentLoaded', renderMarkdownElements);
// Expose for manual use
window.parseMarkdown = parseMarkdown;
window.renderMarkdownElements = renderMarkdownElements;
Implement comprehensive improvement plan (Phases 1-6) Security (Phase 1-2): - Add SecurityHeadersMiddleware with CSP, X-Frame-Options, etc. - Add RateLimitMiddleware for API rate limiting - Add security event logging to AuditLogModel - Add ResponseHelper for standardized API responses - Update config.php with security constants Database (Phase 3): - Add migration 014 for additional indexes - Add migration 015 for ticket dependencies - Add migration 016 for ticket attachments - Add migration 017 for recurring tickets - Add migration 018 for custom fields Features (Phase 4-5): - Add ticket dependencies with DependencyModel and API - Add duplicate detection with check_duplicates API - Add file attachments with AttachmentModel and upload/download APIs - Add @mentions with autocomplete and highlighting - Add quick actions on dashboard rows Collaboration (Phase 5): - Add mention extraction in CommentModel - Add mention autocomplete dropdown in ticket.js - Add mention highlighting CSS styles Admin & Export (Phase 6): - Add StatsModel for dashboard widgets - Add dashboard stats cards (open, critical, unassigned, etc.) - Add CSV/JSON export via export_tickets API - Add rich text editor toolbar in markdown.js - Add RecurringTicketModel with cron job - Add CustomFieldModel for per-category fields - Add admin views: RecurringTickets, CustomFields, Workflow, Templates, AuditLog, UserActivity - Add admin APIs: manage_workflows, manage_templates, manage_recurring, custom_fields, get_users - Add admin routes in index.php Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 09:55:01 -05:00
// ========================================
// Rich Text Editor Toolbar Functions
// ========================================
/**
* Insert markdown formatting around selection
*/
function insertMarkdownFormat(textareaId, prefix, suffix) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const selectedText = text.substring(start, end);
// Insert formatting
const newText = text.substring(0, start) + prefix + selectedText + suffix + text.substring(end);
textarea.value = newText;
// Set cursor position
if (selectedText) {
textarea.setSelectionRange(start + prefix.length, end + prefix.length);
} else {
textarea.setSelectionRange(start + prefix.length, start + prefix.length);
}
textarea.focus();
// Trigger input event to update preview if enabled
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
/**
* Insert markdown at cursor position
*/
function insertMarkdownText(textareaId, text) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
const start = textarea.selectionStart;
const value = textarea.value;
textarea.value = value.substring(0, start) + text + value.substring(start);
textarea.setSelectionRange(start + text.length, start + text.length);
textarea.focus();
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
/**
* Toolbar button handlers
*/
function toolbarBold(textareaId) {
insertMarkdownFormat(textareaId, '**', '**');
}
function toolbarItalic(textareaId) {
insertMarkdownFormat(textareaId, '_', '_');
}
function toolbarCode(textareaId) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
const selectedText = textarea.value.substring(textarea.selectionStart, textarea.selectionEnd);
// Use code block for multi-line, inline code for single line
if (selectedText.includes('\n')) {
insertMarkdownFormat(textareaId, '```\n', '\n```');
} else {
insertMarkdownFormat(textareaId, '`', '`');
}
}
function toolbarLink(textareaId) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
const selectedText = textarea.value.substring(textarea.selectionStart, textarea.selectionEnd);
if (selectedText) {
// Wrap selected text as link text
insertMarkdownFormat(textareaId, '[', '](url)');
} else {
insertMarkdownText(textareaId, '[link text](url)');
}
}
function toolbarList(textareaId) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
const start = textarea.selectionStart;
const text = textarea.value;
// Find start of current line
let lineStart = start;
while (lineStart > 0 && text[lineStart - 1] !== '\n') {
lineStart--;
}
// Insert list marker at beginning of line
textarea.value = text.substring(0, lineStart) + '- ' + text.substring(lineStart);
textarea.setSelectionRange(start + 2, start + 2);
textarea.focus();
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
function toolbarHeading(textareaId) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
const start = textarea.selectionStart;
const text = textarea.value;
// Find start of current line
let lineStart = start;
while (lineStart > 0 && text[lineStart - 1] !== '\n') {
lineStart--;
}
// Insert heading marker at beginning of line
textarea.value = text.substring(0, lineStart) + '## ' + text.substring(lineStart);
textarea.setSelectionRange(start + 3, start + 3);
textarea.focus();
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
function toolbarQuote(textareaId) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
const start = textarea.selectionStart;
const text = textarea.value;
// Find start of current line
let lineStart = start;
while (lineStart > 0 && text[lineStart - 1] !== '\n') {
lineStart--;
}
// Insert quote marker at beginning of line
textarea.value = text.substring(0, lineStart) + '> ' + text.substring(lineStart);
textarea.setSelectionRange(start + 2, start + 2);
textarea.focus();
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
/**
* Create and insert toolbar HTML for a textarea
*/
function createEditorToolbar(textareaId, containerId) {
const container = document.getElementById(containerId);
if (!container) return;
const toolbar = document.createElement('div');
toolbar.className = 'editor-toolbar';
toolbar.innerHTML = `
<button type="button" onclick="toolbarBold('${textareaId}')" title="Bold (Ctrl+B)"><b>B</b></button>
<button type="button" onclick="toolbarItalic('${textareaId}')" title="Italic (Ctrl+I)"><i>I</i></button>
<button type="button" onclick="toolbarCode('${textareaId}')" title="Code">&lt;/&gt;</button>
<span class="toolbar-separator"></span>
<button type="button" onclick="toolbarHeading('${textareaId}')" title="Heading">H</button>
<button type="button" onclick="toolbarList('${textareaId}')" title="List"></button>
<button type="button" onclick="toolbarQuote('${textareaId}')" title="Quote">"</button>
<span class="toolbar-separator"></span>
<button type="button" onclick="toolbarLink('${textareaId}')" title="Link">🔗</button>
`;
container.insertBefore(toolbar, container.firstChild);
}
// Expose toolbar functions globally
window.toolbarBold = toolbarBold;
window.toolbarItalic = toolbarItalic;
window.toolbarCode = toolbarCode;
window.toolbarLink = toolbarLink;
window.toolbarList = toolbarList;
window.toolbarHeading = toolbarHeading;
window.toolbarQuote = toolbarQuote;
window.createEditorToolbar = createEditorToolbar;
window.insertMarkdownFormat = insertMarkdownFormat;
window.insertMarkdownText = insertMarkdownText;
// ========================================
// Auto-link URLs in plain text (non-markdown)
// ========================================
/**
* Convert plain text URLs to clickable links
* Used for non-markdown comments
*/
function autoLinkUrls(text) {
if (!text) return '';
// Match URLs that aren't already in an href attribute
return text.replace(/(?<!["\'>])(https?:\/\/[^\s<>\[\]()]+)/g,
'<a href="$1" target="_blank" rel="noopener noreferrer" class="auto-link">$1</a>');
}
/**
* Process all non-markdown comment elements to auto-link URLs
*/
function processPlainTextComments() {
document.querySelectorAll('.comment-text:not([data-markdown])').forEach(element => {
// Only process if not already processed
if (element.dataset.linksProcessed) return;
element.innerHTML = autoLinkUrls(element.innerHTML);
element.dataset.linksProcessed = 'true';
});
}
// Run on page load
document.addEventListener('DOMContentLoaded', function() {
processPlainTextComments();
});
// Expose for manual use
window.autoLinkUrls = autoLinkUrls;
window.processPlainTextComments = processPlainTextComments;