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}${tag}>`
}
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