mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
Reported from the demo: a template inserted into a reply to Priya Raman kept its "First name" placeholder, with her name in To. recipientValues() parsed `Name (address)`. That is how Contact::__toString() spells a contact, and it is not what the To field shows: ContactAutocompleteField's choice_label writes `Name <address>`. So no chip for a named contact ever parsed, and neither the name nor — for those contacts — the address was filled in. Only an address typed by hand worked, because that chip is the bare address. It now reads the angle-bracket form first and still accepts the parenthesised one, and strips the quotes address syntax puts round a name with a comma in it. The browser test for this passed all along, because it wrote its own chip label in the format the parser expected: it agreed with the code and not with the app. It now uses the real format, and the reply test asserts the first name of a recipient the SERVER put in To, which is a label no test chooses. The preview's sample recipient in the three catalogues was in the same wrong format and is corrected with it. Refs #26 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
331 lines
12 KiB
JavaScript
331 lines
12 KiB
JavaScript
/**
|
|
* Template variables in the browser: the notation, the chips the editor draws
|
|
* for it, and filling in a recipient.
|
|
*
|
|
* A module rather than a controller because two controllers need it and
|
|
* neither owns it — the template editor in Settings draws tokens as chips, and
|
|
* the compose window fills recipient variables when a recipient appears. The
|
|
* picker's preview uses the same chips as the editor, so what is previewed is
|
|
* drawn by the code that drew it when it was written.
|
|
*
|
|
* THE NOTATION is `{{name}}` or `{{name|key=value|key=value}}`, and it is the
|
|
* server's: App\Service\Template\TemplateTokens reads the same grammar and its
|
|
* docblock is the reference. The pattern below is that class's PATTERN.
|
|
*
|
|
* RECIPIENT VARIABLES ARE FILLED HERE, FOR THE WEB. The server hands an
|
|
* inserted template over with `recipient.*` still open (TemplateRenderer says
|
|
* why), in two shapes:
|
|
*
|
|
* in the HTML body <span data-pl-var="recipient.first_name">First name</span>
|
|
* in text {{recipient.first_name}} (subject, plain-text body)
|
|
*
|
|
* and both are closed by the functions at the foot of this file. A variable
|
|
* the recipient cannot answer — a first name, for an address typed with no
|
|
* name — is left open rather than filled with a guess, and the window asks
|
|
* about it before sending.
|
|
*
|
|
* THE SAME RULE EXISTS ONCE MORE, IN PHP:
|
|
* App\Service\Template\TemplateRecipientFiller does for JMAP clients what
|
|
* recipientValues() and fillMarkers() do here, because a native app has the
|
|
* recipient as data and already asks the server for every template it
|
|
* inserts. Change what a first name is in one and change it in the other;
|
|
* TemplateRecipientFillerTest lists the cases both have to agree on.
|
|
*/
|
|
|
|
const TOKEN = /\{\{\s*([a-z_.]+)\s*((?:\|[^|{}]*)*)\}\}/g;
|
|
|
|
/** The three the composer fills. Everything else the server already has. */
|
|
const RECIPIENT = ['recipient.first_name', 'recipient.name', 'recipient.email'];
|
|
|
|
// ── Notation ──────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* @param {string} name
|
|
* @param {string} rawArguments everything after the name, pipes included
|
|
* @returns {{name: string, args: Object<string, string>}}
|
|
*/
|
|
function parse(name, rawArguments) {
|
|
const args = {};
|
|
|
|
for (const part of rawArguments.split('|')) {
|
|
// On the FIRST equals sign: a custom date pattern may hold one.
|
|
const at = part.indexOf('=');
|
|
const key = (-1 === at ? part : part.slice(0, at)).trim();
|
|
|
|
if ('' !== key) {
|
|
args[key] = -1 === at ? '' : part.slice(at + 1).trim();
|
|
}
|
|
}
|
|
|
|
return { name, args };
|
|
}
|
|
|
|
/** One token's text, read. Null when it is not a token. */
|
|
export function parseToken(text) {
|
|
const match = new RegExp(`^${TOKEN.source}$`).exec(text.trim());
|
|
|
|
return null === match ? null : parse(match[1], match[2]);
|
|
}
|
|
|
|
/** A token written back out in the canonical form. */
|
|
export function writeToken({ name, args = {} }) {
|
|
const parts = [name];
|
|
|
|
for (const [key, value] of Object.entries(args)) {
|
|
if ('' !== value) {
|
|
parts.push(`${key}=${value}`);
|
|
}
|
|
}
|
|
|
|
return `{{${parts.join('|')}}}`;
|
|
}
|
|
|
|
// ── Chips ─────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* How a token reads on its chip: the variable's name, then whatever about it
|
|
* is not the default.
|
|
*
|
|
* `words` is TemplateEditorViewData::chipLabels() — labels by variable name,
|
|
* formats by preset, units by letter, signatures by token argument — built in
|
|
* one place on the server so the editor and the picker cannot word the same
|
|
* variable two ways.
|
|
*
|
|
* A named signature that is no longer among the choices — its account was
|
|
* removed — reads as plain "Signature". That is also what it will do: the
|
|
* renderer falls back to the automatic one.
|
|
*
|
|
* @param {{name: string, args: Object<string, string>}} token
|
|
* @param {{labels?: Object, formats?: Object, units?: Object, signatures?: Object}} words
|
|
*/
|
|
export function describe(token, words) {
|
|
const label = words.labels?.[token.name] ?? token.name;
|
|
|
|
if ('date' === token.name) {
|
|
const parts = [label];
|
|
const offset = /^([+-]?)(\d+)([dwm])$/.exec(token.args.offset ?? '');
|
|
|
|
if (null !== offset && 0 !== Number(offset[2])) {
|
|
const sign = '-' === offset[1] ? '\u2212' : '+';
|
|
|
|
parts.push(`${sign}${Number(offset[2])} ${words.units?.[offset[3]] ?? offset[3]}`);
|
|
}
|
|
|
|
const format = token.args.format ?? '';
|
|
|
|
if (format.startsWith('pattern:')) {
|
|
parts.push(`\u00b7 ${format.slice(8)}`);
|
|
} else if ('' !== format && 'long' !== format) {
|
|
parts.push(`\u00b7 ${words.formats?.[format] ?? format}`);
|
|
}
|
|
|
|
return parts.join(' ');
|
|
}
|
|
|
|
if ('signature' === token.name) {
|
|
const named = words.signatures?.[signatureArgument(token)];
|
|
|
|
if (undefined !== named) {
|
|
return `${label} \u00b7 ${named}`;
|
|
}
|
|
}
|
|
|
|
return label;
|
|
}
|
|
|
|
/** `account=12` or `alias=7`: how a named signature is spelled in a token. */
|
|
export function signatureArgument(token) {
|
|
if (token.args.account) {
|
|
return `account=${token.args.account}`;
|
|
}
|
|
|
|
return token.args.alias ? `alias=${token.args.alias}` : '';
|
|
}
|
|
|
|
/**
|
|
* Draw every token in `root`'s text as a chip.
|
|
*
|
|
* A chip is a non-editable span carrying its token in `data-pl-token`; the
|
|
* token is the truth and the label is only how it reads. `known` is the list
|
|
* of variable names, so that braces around anything else are left as the text
|
|
* they are — the same rule the server applies.
|
|
*
|
|
* @param {Element} root
|
|
* @param {string[]} known
|
|
* @param {(token: {name: string, args: Object}) => string} describe
|
|
*/
|
|
export function chipify(root, known, describe) {
|
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
const texts = [];
|
|
|
|
while (walker.nextNode()) {
|
|
// Never inside a chip: its label is not template text.
|
|
if (null === walker.currentNode.parentElement?.closest('[data-pl-token]')) {
|
|
texts.push(walker.currentNode);
|
|
}
|
|
}
|
|
|
|
for (const node of texts) {
|
|
const text = node.nodeValue;
|
|
const pattern = new RegExp(TOKEN.source, 'g');
|
|
const pieces = document.createDocumentFragment();
|
|
let last = 0;
|
|
let found = false;
|
|
let match;
|
|
|
|
while (null !== (match = pattern.exec(text))) {
|
|
if (false === known.includes(match[1])) {
|
|
continue;
|
|
}
|
|
|
|
found = true;
|
|
pieces.append(text.slice(last, match.index), chip(parse(match[1], match[2]), describe));
|
|
last = match.index + match[0].length;
|
|
}
|
|
|
|
if (true === found) {
|
|
pieces.append(text.slice(last));
|
|
node.replaceWith(pieces);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** A chip for one token. */
|
|
export function chip(token, describe) {
|
|
const element = document.createElement('span');
|
|
|
|
element.className = 'pl-var-chip';
|
|
element.contentEditable = 'false';
|
|
element.dataset.plToken = writeToken(token);
|
|
element.textContent = describe(token);
|
|
|
|
return element;
|
|
}
|
|
|
|
/**
|
|
* The content of `root` with every chip turned back into its token — what is
|
|
* stored. Works on a copy: the editor on screen keeps its chips.
|
|
*
|
|
* @returns {Element} a detached clone
|
|
*/
|
|
export function unchipped(root) {
|
|
const clone = root.cloneNode(true);
|
|
|
|
clone.querySelectorAll('[data-pl-token]').forEach((element) => {
|
|
element.replaceWith(document.createTextNode(element.dataset.plToken));
|
|
});
|
|
|
|
return clone;
|
|
}
|
|
|
|
// ── Recipient ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* What a recipient chip says about a person.
|
|
*
|
|
* The compose window's chips read `Name <address>` for a contact that has a
|
|
* name and the bare address for one that has none or was just typed — that is
|
|
* ContactAutocompleteField's `choice_label`, and it is the ONLY place the
|
|
* format is decided. This function was first written against
|
|
* Contact::__toString(), which spells it `Name (address)` and is not what the
|
|
* field uses: every real chip failed to parse, so a reply to "Priya Raman"
|
|
* kept its "First name" placeholder with her name sitting in To. The
|
|
* parenthesised form is still read, because it costs nothing and a label that
|
|
* arrives that way should not be the next silent miss.
|
|
*
|
|
* A first name is the first word of the name, or the part after the comma
|
|
* when the name was stored surname-first — "Whitfield, Dana" is how a
|
|
* directory writes it and "Hi Whitfield," is how that goes wrong. Quotes
|
|
* around a name are the address syntax's, not the person's.
|
|
*
|
|
* Deliberately no guessing from the address. "Hi d.whitfield," is worse than
|
|
* a chip that says a name is missing.
|
|
*
|
|
* @param {string|null} label
|
|
* @returns {Object<string, string>} variable name → value; '' when unknown
|
|
*/
|
|
export function recipientValues(label) {
|
|
const text = (label ?? '').trim();
|
|
const match = /^(.*?)\s*<([^<>\s]+@[^<>\s]+)>$/.exec(text)
|
|
?? /^(.*?)\s*\(([^()\s]+@[^()\s]+)\)$/.exec(text);
|
|
|
|
let name = '';
|
|
let email = '';
|
|
|
|
if (null !== match) {
|
|
name = match[1].trim().replace(/^(["'])(.*)\1$/, '$2').trim();
|
|
email = match[2];
|
|
} else if (/^[^@\s]+@[^@\s]+$/.test(text)) {
|
|
email = text;
|
|
}
|
|
|
|
// A contact with no name of its own is listed under its address, and an
|
|
// address is not a name.
|
|
if (name.includes('@')) {
|
|
name = '';
|
|
}
|
|
|
|
const comma = name.indexOf(',');
|
|
const given = -1 === comma ? name : name.slice(comma + 1).trim();
|
|
|
|
return {
|
|
'recipient.first_name': given.split(/\s+/)[0] ?? '',
|
|
'recipient.name': -1 === comma ? name : `${given} ${name.slice(0, comma).trim()}`.trim(),
|
|
'recipient.email': email,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Close every open recipient marker in `root` that `values` can answer.
|
|
*
|
|
* A filled marker becomes plain text, not a span with a value in it: from that
|
|
* moment it is the user's writing, and a later change of recipient must not
|
|
* reach back into a sentence somebody has already read and approved.
|
|
*
|
|
* @returns {boolean} whether anything changed
|
|
*/
|
|
export function fillMarkers(root, values) {
|
|
let changed = false;
|
|
|
|
root.querySelectorAll('[data-pl-var]').forEach((marker) => {
|
|
const value = values[marker.dataset.plVar] ?? '';
|
|
|
|
if ('' !== value) {
|
|
marker.replaceWith(document.createTextNode(value));
|
|
changed = true;
|
|
}
|
|
});
|
|
|
|
return changed;
|
|
}
|
|
|
|
/** The same, for text: the subject line and a plain-text body. */
|
|
export function fillTokens(text, values) {
|
|
return text.replace(new RegExp(TOKEN.source, 'g'), (whole, name) => {
|
|
const value = values[name] ?? '';
|
|
|
|
return RECIPIENT.includes(name) && '' !== value ? value : whole;
|
|
});
|
|
}
|
|
|
|
/** Is a recipient variable still open in this text? */
|
|
export function hasOpenTokens(text) {
|
|
return [...text.matchAll(new RegExp(TOKEN.source, 'g'))].some((match) => RECIPIENT.includes(match[1]));
|
|
}
|
|
|
|
/**
|
|
* Markers written as tokens, for a body about to become plain text — a
|
|
* textarea has no span to keep one in.
|
|
*
|
|
* @returns {Element} a detached clone
|
|
*/
|
|
export function markersAsTokens(root) {
|
|
const clone = root.cloneNode(true);
|
|
|
|
clone.querySelectorAll('[data-pl-var]').forEach((marker) => {
|
|
marker.replaceWith(document.createTextNode(writeToken({ name: marker.dataset.plVar })));
|
|
});
|
|
|
|
return clone;
|
|
}
|