diff --git a/lib/__tests__/placeholder-check.test.ts b/lib/__tests__/placeholder-check.test.ts new file mode 100644 index 000000000..897b5f921 --- /dev/null +++ b/lib/__tests__/placeholder-check.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; +import { findUnfilledPlaceholders, stripEmbeddedSignatureHtml } from '../placeholder-check'; + +describe('findUnfilledPlaceholders', () => { + it('finds template placeholders in the subject and the body', () => { + expect(findUnfilledPlaceholders('Follow-up: {{subject}}', 'Hi {{ recipient_name }},')).toEqual([ + '{{subject}}', + '{{ recipient_name }}', + ]); + }); + + it('finds upper-case fill-ins in brackets', () => { + expect(findUnfilledPlaceholders('', 'See you at [TIME]. It costs [PRICE] per [UNIT_NAME].')) + .toEqual(['[TIME]', '[PRICE]', '[UNIT_NAME]']); + expect(findUnfilledPlaceholders('', 'Best regards, [YOUR NAME]')).toEqual(['[YOUR NAME]']); + expect(findUnfilledPlaceholders('', 'Treffpunkt: [ÜBERGABEORT]')).toEqual(['[ÜBERGABEORT]']); + }); + + it('finds fill-ins that ask the sender to write something', () => { + expect(findUnfilledPlaceholders('', 'Best regards,\n[Your Name]\n[Insert date]')).toEqual([ + '[Your Name]', + '[Insert date]', + ]); + expect(findUnfilledPlaceholders('', 'Viele Grüße\n[Ihr Name], [Datum einfügen]')).toEqual([ + '[Ihr Name]', + '[Datum einfügen]', + ]); + }); + + it('finds unfinished-text markers', () => { + expect(findUnfilledPlaceholders('', 'The venue is TBD. TODO: add the agenda. Room XXX')).toEqual([ + 'TBD', + 'TODO', + 'XXX', + ]); + }); + + it('reports a marker in brackets once', () => { + expect(findUnfilledPlaceholders('', 'Agenda: [TODO]')).toEqual(['[TODO]']); + }); + + it('lists each placeholder once', () => { + expect(findUnfilledPlaceholders('{{name}}', 'Hi {{name}}, at [TIME] or [TIME]?')).toEqual([ + '{{name}}', + '[TIME]', + ]); + }); + + it('leaves ordinary text alone', () => { + const text = [ + 'See footnote [1] and tick [x].', + 'Option [A] is fine, so is [3D] and [2026].', + 'I still have a todo list and the date is tba.', + 'Read [the docs](https://example.com) and [Your account](https://example.com/me).', + 'The mastodon handle is TODOS and the size is XXXL.', + 'Signed, [Yours truly].', + 'A {single} brace and {{}} are not placeholders.', + ].join('\n'); + expect(findUnfilledPlaceholders('', text)).toEqual([]); + }); + + it('ignores bracket tags and markers in the subject', () => { + expect(findUnfilledPlaceholders('Re: [EXTERNAL] Venue TBD', 'Thanks!')).toEqual([]); + }); +}); + +describe('stripEmbeddedSignatureHtml', () => { + it('drops the bracketed signature range and keeps the rest', () => { + const html = '

Hello

[COMPANY] TODO

After

'; + const stripped = stripEmbeddedSignatureHtml(html); + expect(stripped).toContain('Hello'); + expect(stripped).toContain('After'); + expect(stripped).not.toContain('[COMPANY]'); + }); + + it('drops a signature atom without range markers', () => { + const html = '

Hello [TIME]

[COMPANY]
'; + expect(stripEmbeddedSignatureHtml(html)).toBe('

Hello [TIME]

'); + }); + + it('returns a body without a signature unchanged', () => { + expect(stripEmbeddedSignatureHtml('

Hello

')).toBe('

Hello

'); + }); +}); diff --git a/lib/placeholder-check.ts b/lib/placeholder-check.ts new file mode 100644 index 000000000..a02666092 --- /dev/null +++ b/lib/placeholder-check.ts @@ -0,0 +1,83 @@ +// Finds text the sender meant to replace before sending: template +// placeholders, bracketed fill-ins that AI drafts and copied templates leave +// behind, and markers like TODO. + +// Template syntax, as in template-utils. Spaces inside the braces are allowed +// because templates pasted from other tools often have them. +const TEMPLATE_PLACEHOLDER = /\{\{\s*[\w.-]+\s*\}\}/g; + +// [TIME], [PRICE], [YOUR NAME]. Not followed by "(", which would make it a +// Markdown link. +const CAPS_BRACKET = /\[[\p{Lu}\d][\p{Lu}\d _./-]{0,28}[\p{Lu}\d]\](?!\()/gu; + +// [Your Name], [Insert date], [Ihr Name], [Datum einfügen]. +const PROMPT_BRACKET = /\[(?:(?:your|insert|enter|add|ihr|ihre|ihren|dein|deine|deinen)\s[^[\]\n]{1,40}|[^[\]\n]{1,40}\seinfügen)\](?!\()/giu; + +// Upper case only: "todo" or "tba" in a sentence is usually meant. +const UNFINISHED_WORD = /(? a.start - b.start || b.end - a.end); + const kept: Match[] = []; + for (const match of matches) { + const last = kept[kept.length - 1]; + if (last && match.start < last.end) continue; + kept.push(match); + } + return kept; +} + +/** + * The unfilled placeholders in a message, each once, subject first. The + * subject is only checked for template placeholders: it often carries tags + * like [EXTERNAL] or words from the original message, which the sender did + * not write. `bodyText` should be the text the sender wrote, without quoted + * replies and the signature. + */ +export function findUnfilledPlaceholders(subject: string, bodyText: string): string[] { + const found = [ + ...collect(subject, [TEMPLATE_PLACEHOLDER]), + ...collect(bodyText, [TEMPLATE_PLACEHOLDER, CAPS_BRACKET, PROMPT_BRACKET, UNFINISHED_WORD]), + ].map((m) => m.text); + return Array.from(new Set(found)); +} + +/** + * A compose body without its embedded signature. A signature is the same in + * every message, so a placeholder in it would stop every send. + */ +export function stripEmbeddedSignatureHtml(html: string): string { + if (!html.includes('data-signature-block')) return html; + const doc = new DOMParser().parseFromString(`${html}`, 'text/html'); + const startEl = doc.querySelector(SIGNATURE_START_SELECTOR); + if (startEl) { + const endEl = doc.querySelector(SIGNATURE_END_SELECTOR); + let cursor: Node | null = startEl; + while (cursor) { + const next: Node | null = cursor.nextSibling; + const isEnd = cursor === endEl; + cursor.parentNode?.removeChild(cursor); + if (isEnd) break; + cursor = next; + } + } + doc.querySelectorAll('[data-signature-block-node]').forEach((el) => el.remove()); + return doc.body.innerHTML; +}