import { linkifyHtml } from './linkify' /** * Rich rendering for C_ContactActivity notes (lead / partner timelines). * * Activity text stays PLAIN TEXT in iDempiere (Comments, 2000 chars) so mails, * search and the mobile app keep readable text. Only the timeline renders it: * * Label: value → Label: value (line-initial, or after " · ") * Label (…): https://… → the label rendered as the link (raw URL hidden) * http(s)://… / www.… → (via linkifyHtml) * name@host.tld → * Something_2026.pdf → (file names) * **bold** / _italic_ → / *
… → kept when the note was written as simple HTML * blank line → paragraph gap * * Safety: plain text goes escape → linkify → markers (never inside
). Notes * that already contain HTML are reduced to a small whitelist of tags/attributes * (no scripts, no event handlers, http(s)/mailto hrefs only), then linkified in * their text nodes. The result is safe for `v-html`. */ const escapeHtml = (s: string): string => s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') // A label is a short phrase before the first ':' of a line (or after " · "), // with balanced parentheses — so "Online-Bestätigungs-Link (gültig bis …):" is a // label but "… e-mailed to x@y.de (CC:" is not (unbalanced "(" before the colon). const LABEL_MAX = 90 const isLabel = (s: string): boolean => { const t = s.trim() if (!t || t.length > LABEL_MAX) return false if (/https?:\/\/|www\./i.test(t)) return false let depth = 0 for (const ch of t) { if (ch === '(') depth++; else if (ch === ')') depth--; if (depth < 0) return false } if (depth !== 0) return false return /^[\p{L}\p{N}][\p{L}\p{N} .,'’\-/&()]*$/u.test(t) } const EMAIL_RE = /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/g const FILE_RE = /\b[\w][\w.\-()]*\.(?:pdf|docx?|xlsx?|csv|png|jpe?g|zip)\b/gi const BOLD_RE = /\*\*(\S(?:[^*\n]*?\S)?)\*\*/g const ITALIC_RE = /(^|[\s(>])_(\S(?:[^_\n]*?\S)?)_(?=$|[\s).,!?:;<])/g // Marks applied to an already-escaped text part (never inside ). const decoratePlainPart = (html: string): string => html .replace(EMAIL_RE, (m) => `${m}`) .replace(FILE_RE, (m) => `${m}`) .replace(BOLD_RE, '$1') .replace(ITALIC_RE, '$1$2') const inline = (segment: string): string => linkifyHtml(segment) .split(/(]*>.*?<\/a>)/) .map((part, i) => (i % 2 === 1 ? part : decoratePlainPart(part))) .join('') const formatLine = (line: string): string => { if (!line.trim()) return '' // Split on " · " separators so mid-line labels (· E-Mail: …) get bolded too. const chunks = line.split(/( · )/) return chunks.map((chunk, i) => { if (i % 2 === 1) return '·' const colon = chunk.indexOf(':') if (colon > 0) { const label = chunk.slice(0, colon) if (isLabel(label)) { const rest = chunk.slice(colon + 1) // "Label (…): https://…" → the label itself becomes the link (the raw // token URL is noise for the reader; the href keeps it). const only = rest.trim() if (/^(?:https?:\/\/|www\.)\S+$/i.test(only)) { const href = /^www\./i.test(only) ? `https://${only}` : only return ` ${escapeHtml(label.trim())}` } return `${escapeHtml(label.trim())}:${inline(rest)}` } } return inline(chunk) }).join('') } const formatPlain = (text: string): string => { const lines = text.replace(/\r\n?/g, '\n').split('\n') const out: string[] = [] let gap = false for (const raw of lines) { if (!raw.trim()) { gap = true; continue } if (gap && out.length) out.push('
') gap = false out.push(`
${formatLine(raw)}
`) } return out.join('') } // ---- simple-HTML notes ------------------------------------------------------ const ALLOWED_TAGS = new Set(['b', 'strong', 'i', 'em', 'u', 's', 'br', 'p', 'div', 'ul', 'ol', 'li', 'a', 'code', 'span', 'h1', 'h2', 'h3', 'h4']) const looksLikeHtml = (text: string): boolean => /<\/?(?:b|strong|i|em|u|s|br|p|div|ul|ol|li|a|code|span|h[1-4])\b[^>]*>/i.test(text) const sanitizeHtml = (html: string): string => { if (typeof document === 'undefined') return escapeHtml(html) const tpl = document.createElement('template') tpl.innerHTML = html const walk = (node: Node): string => { if (node.nodeType === Node.TEXT_NODE) return inline(node.textContent || '') if (node.nodeType !== Node.ELEMENT_NODE) return '' const el = node as Element const tag = el.tagName.toLowerCase() const inner = Array.from(el.childNodes).map(walk).join('') if (!ALLOWED_TAGS.has(tag)) return inner if (tag === 'br') return '
' if (tag === 'a') { const href = String(el.getAttribute('href') || '').trim() if (!/^(https?:\/\/|mailto:)/i.test(href)) return inner return `${inner}` } return `<${tag}>${inner}` } return Array.from(tpl.content.childNodes).map(walk).join('') } export const formatActivityHtml = (input: any): string => { const text = input == null ? '' : String(input) if (!text.trim()) return '' return looksLikeHtml(text) ? sanitizeHtml(text) : formatPlain(text) } export default formatActivityHtml