textmachine/frontend/src/api/vocabulary.ts

156 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Closed wire vocabularies, and what each value MEANS to the user. One file per the ratified
// answer to the owner's control question (companion §5): "a new product state edits one file —
// the value → appearance map on the `src/api/` seam".
//
// Two things live here together on purpose. The list of values, because the seam has to narrow a
// value that came off the wire; and the product word, because splitting them means a new status
// is added in one file and stays unnamed in another.
//
// ⚠ The unknown branch is the point of this file, not a nicety. Generated types turn `enum` into
// a CLOSED union, a minor bump may legally add a value, and the typechecker does not catch it:
// indexing a `Record` by a finite union compiles silently, so `runStates[book.status]` returns
// `undefined` and the tree dies on `undefined.tone` (BACKLOG Ф-22).
import type { components } from './schema';
type Schemas = components['schemas'];
/**
* Colour is the second channel: four steps, and every one of them is a STATE — work in progress,
* finished, waiting on a person, refused. Never decoration.
*/
export type Tone = 'note' | 'ok' | 'warn' | 'danger';
export interface Vocabulary<Value extends string, Meaning> {
/** The values of version 0.x. Derived from the entries, so the two cannot drift. */
readonly values: readonly Value[];
/** Narrow a value that came off the wire. An unrecognized one becomes `null`, never a throw. */
read(value: string): Value | null;
/** What the value means, including the branch for a value this build does not know. */
describe(value: Value | null): Meaning;
}
// `Record<Value, Meaning>` is what makes this exhaustive: a value added to the spec fails the
// typecheck here until it is given a meaning, which is the whole mechanism.
function vocabulary<Value extends string, Meaning>(
entries: Readonly<Record<Value, Meaning>>,
unknown: Meaning,
): Vocabulary<Value, Meaning> {
const values = Object.keys(entries) as Value[];
return {
values,
read: (value) => (Object.hasOwn(entries, value) ? (value as Value) : null),
describe: (value) => (value === null ? unknown : entries[value]),
};
}
/**
* Product status of a book. The label is a WORD, not a dot: nine states could not be told apart
* by shape and tone alone — a probe put three pairs on the same pixels — so the word carries the
* meaning and colour only doubles it, for attention and refusal.
*/
export const bookStatus = vocabulary<Schemas['BookStatus'], { label: string; tone?: Tone }>(
{
uploading: { label: 'загрузка', tone: 'note' },
parsing: { label: 'разбор', tone: 'note' },
not_started: { label: 'в очереди' },
translating: { label: 'перевод', tone: 'note' },
awaiting_bank: { label: 'нужна подпись', tone: 'warn' },
finalizing: { label: 'финал', tone: 'note' },
ready: { label: 'готова', tone: 'ok' },
// Resumable stop, so no danger tone: the screen must not read as a failure (D39.100 K-8).
// ⚠ The wording is the OWNER'S and is not ours to shorten (В-6). S3.5 did shorten it to fit
// the badge slot and had to put it back: the badge truncates with a tooltip instead, and
// which word belongs in the tree is a question for the owner, not a layout decision.
paused: { label: 'остановлена: лимиты', tone: 'warn' },
stopped: { label: 'остановлена' },
rejected: { label: 'не разобрана', tone: 'danger' },
failed: { label: 'ошибка', tone: 'danger' },
},
// Neutral on purpose: a state from a future contract is not an error, and calling it one would
// put an alarm on a screen that the product forbids to look alarming.
{ label: 'неизвестное состояние' },
);
/**
* Why a run is paused. The API carries state, the phrase is drawn here — and the phrase for
* `credit_exhausted` is the owner's wording (D39.100 K-8), not ours.
*/
export const pausedReason = vocabulary<Schemas['PausedReason'], { label: string }>(
{ credit_exhausted: { label: 'перевод остановлен: лимиты исчерпаны' } },
// An unknown reason claims nothing about the cause. It still says the stop is resumable,
// because that is true of every value of this vocabulary by contract.
{ label: 'перевод остановлен' },
);
/**
* Severity step of a note → look of the callout.
*
* ⚠ An unknown step takes the ATTENTION branch, not the quiet one. The quiet default was the
* defect: a step from a future contract would be silently demoted to "have a glance", and the one
* thing a severity axis must never do is hide something that matters. The words for the steps
* themselves are the owner's and are not invented here (BACKLOG Ф-21, question В-3).
*/
export const noteSeverity = vocabulary<Schemas['NoteSeverity'], { tone: 'note' | 'quiet' }>(
{ attention: { tone: 'note' }, glance: { tone: 'quiet' } },
{ tone: 'note' },
);
/**
* Signing status of a bank row, three-valued. `canon` says whether the term is injected as canon;
* the label names the state for a reader who has to VERIFY the bank (owner's remarks 7/13/20).
*
* ⚠ The words are the contract's own description of each value, not new product verbs
* (`TermStatus`: "only `approved` is injected as canon", `auto` is "proposed by the engine,
* nobody looked", `draft` is "a human started and did not finish"). The signing SCREEN with its
* actions is S5, and no verb of that screen is invented here.
*/
export const termStatus = vocabulary<
Schemas['TermStatus'],
{ canon: boolean; label: string; tone?: Tone }
>(
{
auto: { canon: false, label: 'предложен' },
draft: { canon: false, label: 'черновик', tone: 'warn' },
approved: { canon: true, label: 'подписан', tone: 'ok' },
},
// Safe direction: an unknown status does not get to claim the row is signed.
{ canon: false, label: 'состояние неизвестно' },
);
/**
* Kind of term. Not cosmetic: the contract says `name` and `place` ROUTE a term into
* transliteration, so a bank row shown without its kind is a row signed blind — which is why the
* reference view now carries it.
*
* The unknown branch is the contract's own requirement, not our invention: a row whose kind the
* engine could not decide "MUST be shown as kind not decided and MUST NOT be dropped".
*/
export const termKind = vocabulary<Schemas['TermKind'], { label: string }>(
{
name: { label: 'имя' },
place: { label: 'место' },
title: { label: 'титул' },
term: { label: 'термин' },
nickname: { label: 'прозвище' },
},
{ label: 'тип не определён' },
);
/** Provenance of a bank row. Narrowing only, same reason as `termKind`. */
export const termOrigin = vocabulary<Schemas['TermOrigin'], Record<string, never>>(
{ seed: {}, ruby: {}, mined: {} },
{},
);
/** State of a pair. Narrowing only: the reader already has one phrase for "no text yet". */
export const unitState = vocabulary<Schemas['UnitState'], Record<string, never>>(
{ translated: {}, withheld: {}, pending: {} },
{},
);
/** Credit balance state. No screen reads it before S7 (settings) — see BACKLOG Ф-27. */
export const usageState = vocabulary<Schemas['Usage']['state'], Record<string, never>>(
{ ok: {}, low: {}, exhausted: {} },
{},
);