Files
webmail/lib/proofreading/annotations.ts
T

138 lines
5.5 KiB
TypeScript

// What a text checker sends back, checked before anything is drawn.
//
// A checker is a plugin; its answer is untrusted data. Every annotation must
// name a segment the host sent, sit inside that segment's text, cover
// neither a line break nor an inline object, and not cut a character in
// half. Strings are cut to size and the count is capped. Anything else is
// dropped, not repaired, except a range that runs past the end of its
// segment, which is cut at the end.
import { OBJECT_CHAR } from './segments';
export const CHECK_KINDS = ['spelling', 'grammar', 'style', 'other'] as const;
export type CheckKind = (typeof CHECK_KINDS)[number];
export const MAX_ANNOTATIONS = 300;
export const MAX_REPLACEMENTS = 5;
const MAX_MESSAGE = 500;
const MAX_SHORT = 100;
const MAX_REPLACEMENT = 100;
const MAX_RULE = 100;
const MAX_ID = 100;
export interface Annotation {
segmentId: string;
offset: number;
length: number;
kind: CheckKind;
message: string;
shortMessage?: string;
replacements: string[];
ruleId?: string;
ruleLabel?: string;
/** The checker's own id for it, given back in onAction. */
id?: string;
/** The text the annotation covers (filled in by the host). */
text: string;
}
/** The text without control characters other than tab and line breaks. */
function stripControls(s: string): string {
let out = '';
for (const ch of s) {
const c = ch.charCodeAt(0);
if ((c < 32 && c !== 9 && c !== 10 && c !== 13) || c === 127) continue;
out += ch;
}
return out;
}
const isKind = (v: unknown): v is CheckKind => typeof v === 'string' && (CHECK_KINDS as readonly string[]).includes(v);
function str(v: unknown, max: number): string | undefined {
if (typeof v !== 'string') return undefined;
const s = stripControls(v).trim();
if (!s) return undefined;
return s.length > max ? `${s.slice(0, max - 1)}…` : s;
}
const isHigh = (c: number) => c >= 0xd800 && c <= 0xdbff;
const isLow = (c: number) => c >= 0xdc00 && c <= 0xdfff;
/**
* The annotations of a checker's answer that may be drawn. `segments` maps
* the ids of the request to their text; `kinds` are the kinds the checker
* registered (others are dropped).
*/
export function validateAnnotations(
raw: unknown,
segments: ReadonlyMap<string, string>,
kinds: readonly CheckKind[],
max = MAX_ANNOTATIONS,
): { annotations: Annotation[]; dropped: number } {
const out: Annotation[] = [];
let dropped = 0;
if (!Array.isArray(raw)) return { annotations: out, dropped };
for (const item of raw) {
if (out.length >= max) { dropped++; continue; }
const a = item as Record<string, unknown> | null;
if (!a || typeof a !== 'object') { dropped++; continue; }
const text = typeof a.segmentId === 'string' ? segments.get(a.segmentId) : undefined;
const kind = isKind(a.kind) && kinds.includes(a.kind) ? a.kind : null;
const message = str(a.message, MAX_MESSAGE);
if (text === undefined || !kind || !message
|| !Number.isSafeInteger(a.offset) || !Number.isSafeInteger(a.length)) { dropped++; continue; }
const offset = a.offset as number;
let length = a.length as number;
if (offset < 0 || length < 1 || offset >= text.length) { dropped++; continue; }
if (offset + length > text.length) length = text.length - offset;
const covered = text.slice(offset, offset + length);
if (covered.includes('\n') || covered.includes(OBJECT_CHAR) || !covered.trim()
|| isLow(text.charCodeAt(offset)) || isHigh(text.charCodeAt(offset + length - 1))) { dropped++; continue; }
const replacements: string[] = [];
if (Array.isArray(a.replacements)) {
for (const r of a.replacements) {
if (replacements.length >= MAX_REPLACEMENTS) break;
// A replacement is put in the text as typed: no line breaks, no objects.
if (typeof r !== 'string' || r.length > MAX_REPLACEMENT || stripControls(r) !== r || /[\n\r]/.test(r) || r.includes(OBJECT_CHAR)) continue;
if (r === covered || replacements.includes(r)) continue;
replacements.push(r);
}
}
const ann: Annotation = { segmentId: a.segmentId as string, offset, length, kind, message, replacements, text: covered };
const short = str(a.shortMessage, MAX_SHORT);
if (short) ann.shortMessage = short;
const ruleId = str(a.ruleId, MAX_RULE);
if (ruleId) ann.ruleId = ruleId;
const ruleLabel = str(a.ruleLabel, MAX_RULE);
if (ruleLabel) ann.ruleLabel = ruleLabel;
const id = str(a.id, MAX_ID);
if (id) ann.id = id;
out.push(ann);
}
return { annotations: out, dropped };
}
/** What the checker said back besides annotations. */
export interface CheckOutcome {
annotations: unknown;
status: 'ok' | 'unavailable' | 'paused';
message?: string;
retryAfterMs?: number;
}
/** A checker's answer: an array of annotations or { annotations, status, message, retryAfterMs }. */
export function readCheckResult(raw: unknown): CheckOutcome {
if (Array.isArray(raw)) return { annotations: raw, status: 'ok' };
if (!raw || typeof raw !== 'object') return { annotations: [], status: 'ok' };
const r = raw as Record<string, unknown>;
const status = r.status === 'unavailable' || r.status === 'paused' ? r.status : 'ok';
const out: CheckOutcome = { annotations: r.annotations ?? [], status };
const message = str(r.message, MAX_MESSAGE);
if (message) out.message = message;
if (typeof r.retryAfterMs === 'number' && Number.isFinite(r.retryAfterMs) && r.retryAfterMs > 0) {
out.retryAfterMs = Math.min(r.retryAfterMs, 10 * 60_000);
}
return out;
}