Files
webmail/lib/attachment-text/plain.ts
T

158 lines
6.5 KiB
TypeScript

// Text out of the formats that already are text: plain text, CSV, HTML,
// calendar files (ICS) and address cards (VCF).
import { htmlToPlainText } from '@/lib/html-to-text';
import { parseVCard } from '@/lib/vcard';
import type { ContactCard } from '@/lib/jmap/types';
/**
* Bytes as text: UTF-8 (with or without a byte order mark), UTF-16 with a
* byte order mark, else Windows-1252, which never fails.
*/
export function decodeText(bytes: Uint8Array): string {
if (bytes[0] === 0xff && bytes[1] === 0xfe) return new TextDecoder('utf-16le').decode(bytes.subarray(2));
if (bytes[0] === 0xfe && bytes[1] === 0xff) return new TextDecoder('utf-16be').decode(bytes.subarray(2));
try {
return new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }).decode(bytes);
} catch {
return new TextDecoder('windows-1252').decode(bytes);
}
}
/** True when the bytes look like text rather than a binary file (no NUL bytes in the first 4 KB). */
export function looksLikeText(bytes: Uint8Array): boolean {
const head = bytes.subarray(0, 4096);
if (head[0] === 0xff && head[1] === 0xfe) return true;
if (head[0] === 0xfe && head[1] === 0xff) return true;
return !head.includes(0);
}
/** Characters of HTML read at most; the rest of a larger file is left out. */
export const MAX_HTML_CHARS = 5_000_000;
/**
* The readable text of an HTML file: its body without scripts, styles and
* templates. Parsed with DOMParser (linear in the file, whatever its tags);
* list markers left out, since numbering them is slow on long lists.
*/
export function htmlText(html: string): string {
const doc = new DOMParser().parseFromString(html, 'text/html');
for (const el of Array.from(doc.querySelectorAll('script, style, noscript, template'))) el.remove();
return htmlToPlainText(doc.body?.innerHTML ?? '', { paragraphSpacing: true, imageAlt: (alt) => `[Image: ${alt}]` });
}
// ─── ICS ─────────────────────────────────────────────────────
interface IcsLine { name: string; params: Record<string, string>; value: string }
function unfold(text: string): string[] {
return text.replace(/\r\n?/g, '\n').replace(/\n[ \t]/g, '').split('\n');
}
function parseIcsLine(line: string): IcsLine | null {
// NAME;PARAM=a;PARAM="b:c":value. A colon inside a quoted parameter is not the separator.
let quoted = false;
let colon = -1;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') quoted = !quoted;
else if (ch === ':' && !quoted) { colon = i; break; }
}
if (colon < 0) return null;
const [name, ...rawParams] = line.slice(0, colon).split(';');
const params: Record<string, string> = {};
for (const p of rawParams) {
const eq = p.indexOf('=');
if (eq > 0) params[p.slice(0, eq).toUpperCase()] = p.slice(eq + 1).replace(/^"|"$/g, '');
}
return { name: name.toUpperCase(), params, value: line.slice(colon + 1) };
}
function icsText(value: string): string {
return value.replace(/\\[nN]/g, ' ').replace(/\\([,;\\])/g, '$1').trim();
}
/** 20261105T090000Z → "2026-11-05 09:00 UTC"; with TZID the zone in brackets; a date alone stays a date. */
function icsWhen(line: IcsLine | undefined): string {
if (!line) return '';
const m = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})\d{0,2}(Z?))?$/.exec(line.value.trim());
if (!m) return line.value.trim();
const date = `${m[1]}-${m[2]}-${m[3]}`;
if (!m[4]) return date;
const zone = m[6] ? ' UTC' : line.params.TZID ? ` (${line.params.TZID})` : '';
return `${date} ${m[4]}:${m[5]}${zone}`;
}
function organiser(line: IcsLine | undefined): string {
if (!line) return '';
const address = line.value.replace(/^mailto:/i, '').trim();
const name = line.params.CN?.trim();
return name && address ? `${name} <${address}>` : (name || address);
}
/** One line per event and task: "event: title, start, end, location, organiser …". */
export function icsToText(ics: string): string {
const out: string[] = [];
let current: Map<string, IcsLine> | null = null;
let kind = '';
// Inside a component of the event (an alarm): its properties aren't the event's.
let nested = 0;
for (const raw of unfold(ics)) {
const line = parseIcsLine(raw);
if (!line) continue;
if (current && line.name === 'BEGIN') { nested++; continue; }
if (current && nested > 0) {
if (line.name === 'END') nested--;
continue;
}
if (line.name === 'BEGIN' && /^(VEVENT|VTODO)$/i.test(line.value.trim())) {
current = new Map();
kind = line.value.trim().toUpperCase() === 'VTODO' ? 'task' : 'event';
continue;
}
if (line.name === 'END' && current && /^(VEVENT|VTODO)$/i.test(line.value.trim())) {
const parts = [
icsText(current.get('SUMMARY')?.value ?? '') || '(no title)',
icsWhen(current.get('DTSTART')),
icsWhen(current.get('DTEND') ?? current.get('DUE')),
icsText(current.get('LOCATION')?.value ?? ''),
];
const org = organiser(current.get('ORGANIZER'));
if (org) parts.push(`organiser ${org}`);
out.push(`${kind}: ${parts.filter(Boolean).join(', ')}`);
const description = icsText(current.get('DESCRIPTION')?.value ?? '');
if (description) out.push(` ${description}`);
current = null;
continue;
}
if (current && !current.has(line.name)) current.set(line.name, line);
}
return out.join('\n');
}
// ─── VCF ─────────────────────────────────────────────────────
function cardName(card: ContactCard): string {
const full = card.name?.full?.trim();
if (full) return full;
return (card.name?.components ?? [])
.filter((c) => c.kind !== 'separator')
.map((c) => c.value)
.join(' ')
.trim();
}
/** One block per card: name, email addresses, phone numbers, organisation. */
export function vcfText(vcf: string): string {
return parseVCard(vcf).map((card) => {
const lines = [`contact: ${cardName(card) || '(no name)'}`];
const emails = Object.values(card.emails ?? {}).map((e) => e.address).filter(Boolean);
const phones = Object.values(card.phones ?? {}).map((p) => p.number).filter(Boolean);
const orgs = Object.values(card.organizations ?? {}).map((o) => o.name).filter(Boolean);
if (emails.length) lines.push(` email: ${emails.join(', ')}`);
if (phones.length) lines.push(` phone: ${phones.join(', ')}`);
if (orgs.length) lines.push(` organisation: ${orgs.join(', ')}`);
return lines.join('\n');
}).join('\n');
}