175 lines
5.2 KiB
TypeScript
175 lines
5.2 KiB
TypeScript
/** Detect and split quoted reply tails from message HTML (thread view). */
|
|
|
|
const WROTE_LINE =
|
|
/(^|\n)\s*(Le\s.+a\sécrit\s*:|On\s.+wrote:|Am\s.+schrieb|El\s.+escribió|Il\s.+ha\s+scritto|.+a écrit\s*:)/i
|
|
|
|
const QUOTE_SELECTOR = [
|
|
".gmail_quote",
|
|
".gmail_extra",
|
|
".protonmail_quote",
|
|
".yahoo_quoted",
|
|
".moz-cite-prefix",
|
|
"#divRplyFwdMsg",
|
|
'blockquote[type="cite"]',
|
|
"blockquote.gmail_quote",
|
|
].join(",")
|
|
|
|
function wroteLineText(text: string | null | undefined): boolean {
|
|
if (!text?.trim()) return false
|
|
return WROTE_LINE.test(text.trim())
|
|
}
|
|
|
|
function isLikelyReplyBlockquote(el: Element): boolean {
|
|
if (el.tagName !== "BLOCKQUOTE") return false
|
|
const type = el.getAttribute("type")?.toLowerCase()
|
|
if (type === "cite") return true
|
|
const cls = el.className?.toString().toLowerCase() ?? ""
|
|
if (cls.includes("gmail_quote") || cls.includes("protonmail")) return true
|
|
const prev = el.previousElementSibling
|
|
if (prev && wroteLineText(prev.textContent)) return true
|
|
return false
|
|
}
|
|
|
|
function findQuoteElement(root: Element): Element | null {
|
|
for (const sel of QUOTE_SELECTOR.split(",")) {
|
|
const hit = root.querySelector(sel.trim())
|
|
if (hit) return hit
|
|
}
|
|
for (const bq of root.querySelectorAll("blockquote")) {
|
|
if (isLikelyReplyBlockquote(bq)) return bq
|
|
}
|
|
for (const child of root.children) {
|
|
if (wroteLineText(child.textContent) && child.textContent!.length < 500) {
|
|
const next = child.nextElementSibling
|
|
if (next && (next.tagName === "BLOCKQUOTE" || next.matches(QUOTE_SELECTOR))) {
|
|
return child
|
|
}
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function quoteSplitStart(quoteEl: Element): Element {
|
|
const prev = quoteEl.previousElementSibling
|
|
if (prev && (wroteLineText(prev.textContent) || prev.classList.contains("gmail_attr"))) {
|
|
return prev
|
|
}
|
|
return quoteEl
|
|
}
|
|
|
|
function fragmentHtml(doc: Document, frag: DocumentFragment): string {
|
|
const wrap = doc.createElement("div")
|
|
wrap.appendChild(frag)
|
|
return wrap.innerHTML.trim()
|
|
}
|
|
|
|
/** Split root contents at quote node (works when quote is nested in a single wrapper div). */
|
|
function splitAtQuote(root: Element, quoteEl: Element): {
|
|
mainHtml: string
|
|
quotedHtml: string | null
|
|
} {
|
|
const doc = root.ownerDocument
|
|
if (!doc || !root.firstChild) {
|
|
return { mainHtml: root.innerHTML, quotedHtml: null }
|
|
}
|
|
|
|
const start = quoteSplitStart(quoteEl)
|
|
try {
|
|
const range = doc.createRange()
|
|
range.setStartBefore(root.firstChild)
|
|
range.setEndBefore(start)
|
|
const mainFrag = range.cloneContents()
|
|
|
|
range.setStartBefore(start)
|
|
const last = root.lastChild
|
|
if (!last) return { mainHtml: root.innerHTML, quotedHtml: null }
|
|
range.setEndAfter(last)
|
|
const quotedFrag = range.cloneContents()
|
|
|
|
const mainHtml = fragmentHtml(doc, mainFrag)
|
|
const quotedHtml = fragmentHtml(doc, quotedFrag)
|
|
if (!quotedHtml) return { mainHtml: root.innerHTML, quotedHtml: null }
|
|
if (!mainHtml) return { mainHtml: root.innerHTML, quotedHtml: null }
|
|
return { mainHtml, quotedHtml }
|
|
} catch {
|
|
return { mainHtml: root.innerHTML, quotedHtml: null }
|
|
}
|
|
}
|
|
|
|
function splitHtmlRoot(root: Element): { mainHtml: string; quotedHtml: string | null } {
|
|
const quoteHit = findQuoteElement(root)
|
|
if (!quoteHit) {
|
|
return { mainHtml: root.innerHTML, quotedHtml: null }
|
|
}
|
|
return splitAtQuote(root, quoteHit)
|
|
}
|
|
|
|
function splitPlainTextBody(html: string): { mainHtml: string; quotedHtml: string | null } {
|
|
const preMatch = html.match(/^<pre[^>]*>([\s\S]*)<\/pre>$/i)
|
|
const text = preMatch ? preMatch[1]! : html
|
|
const decoded = text
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/&/g, "&")
|
|
const lines = decoded.split("\n")
|
|
let splitAt = -1
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i]!
|
|
if (/^>/.test(line.trim())) {
|
|
splitAt = i
|
|
break
|
|
}
|
|
if (WROTE_LINE.test(line)) {
|
|
splitAt = i
|
|
break
|
|
}
|
|
}
|
|
if (splitAt < 0) return { mainHtml: html, quotedHtml: null }
|
|
|
|
const mainLines = lines.slice(0, splitAt)
|
|
const quotedLines = lines.slice(splitAt)
|
|
const escape = (s: string) =>
|
|
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
const mainPre = escape(mainLines.join("\n").trimEnd())
|
|
const quotedPre = escape(quotedLines.join("\n").trimStart())
|
|
if (!quotedPre.trim()) return { mainHtml: html, quotedHtml: null }
|
|
|
|
const wrap = (body: string) =>
|
|
`<pre style="white-space:pre-wrap;font-family:inherit;margin:0;">${body}</pre>`
|
|
|
|
return {
|
|
mainHtml: wrap(mainPre),
|
|
quotedHtml: wrap(quotedPre),
|
|
}
|
|
}
|
|
|
|
export function splitQuotedHtml(html: string): {
|
|
mainHtml: string
|
|
quotedHtml: string | null
|
|
} {
|
|
const trimmed = html.trim()
|
|
if (!trimmed) return { mainHtml: html, quotedHtml: null }
|
|
|
|
if (typeof DOMParser === "undefined") {
|
|
return { mainHtml: html, quotedHtml: null }
|
|
}
|
|
|
|
if (trimmed.startsWith("<pre")) {
|
|
const plain = splitPlainTextBody(trimmed)
|
|
if (plain.quotedHtml) return plain
|
|
}
|
|
|
|
const doc = new DOMParser().parseFromString(
|
|
`<div id="mail-body-root">${trimmed}</div>`,
|
|
"text/html"
|
|
)
|
|
const root = doc.getElementById("mail-body-root")
|
|
if (!root) return { mainHtml: html, quotedHtml: null }
|
|
|
|
return splitHtmlRoot(root)
|
|
}
|
|
|
|
export function hasQuotedContent(html: string): boolean {
|
|
return splitQuotedHtml(html).quotedHtml !== null
|
|
}
|